完善团课相关管理

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
+25 -2
View File
@@ -91,6 +91,23 @@ export interface GroupCourseType {
updatedAt?: string
}
export interface GroupCourseTypePageRequest {
page: number
size: number
keyword?: string
category?: string
sort?: string
order?: 'asc' | 'desc'
}
export interface CourseLabelPageRequest {
page: number
size: number
keyword?: string
sort?: string
order?: 'asc' | 'desc'
}
export const groupCourseTypeApi = {
getAll: () =>
request.get<GroupCourseType[]>('/groupCourse/types'),
@@ -102,11 +119,14 @@ export const groupCourseTypeApi = {
request.get<string[]>('/groupCourse/types/categories'),
getByCategory: (category: string) =>
request.get<GroupCourseType[]>(`/groupCourse/types/category/${category}`),
request.get<GroupCourseType[]>(`/groupCourse/types/category/${encodeURIComponent(category)}`),
getById: (id: number) =>
request.get<GroupCourseType>(`/groupCourse/types/${id}`),
getPage: (params: GroupCourseTypePageRequest) =>
request.post<PageResponse<GroupCourseType>>('/groupCourse/types/page', params),
create: (data: GroupCourseType) =>
request.post<GroupCourseType>('/groupCourse/types', data),
@@ -143,6 +163,9 @@ export const courseLabelApi = {
getByTypeId: (typeId: number) =>
request.get<CourseLabel[]>(`/groupCourse/types/${typeId}/labels`),
getPage: (params: CourseLabelPageRequest) =>
request.post<PageResponse<CourseLabel>>('/groupCourse/labels/page', params),
create: (data: CourseLabel) =>
request.post<CourseLabel>('/groupCourse/labels', data),
@@ -153,7 +176,7 @@ export const courseLabelApi = {
request.delete<void>(`/groupCourse/labels/${id}`),
addLabelsToType: (typeId: number, labelIds: number[]) =>
request.post<void>(`/groupCourse/types/${typeId}/labels`, labelIds),
request.post<void>(`/groupCourse/types/${typeId}/labels`, { labelIds }),
removeLabelFromType: (typeId: number, labelId: number) =>
request.delete<void>(`/groupCourse/types/${typeId}/labels/${labelId}`),
+9 -9
View File
@@ -97,15 +97,15 @@ onMounted(async () => {
if (!token) {
router.push('/login')
} else if (!permissionStore.loaded) {
permissionStore.initFromStorage()
if (!permissionStore.loaded || permissionStore.menus.length === 0) {
try {
await permissionStore.fetchUserMenus()
} catch (error) {
console.error('获取用户菜单失败:', error)
}
} else {
// 先尝试从 localStorage 快速恢复,再异步获取最新菜单
if (!permissionStore.loaded) {
permissionStore.initFromStorage()
}
try {
await permissionStore.fetchUserMenus()
} catch (error) {
console.error('获取用户菜单失败:', error)
}
}
})
+9 -1
View File
@@ -38,6 +38,10 @@ function transformMenuData(backendMenus: BackendMenuItem[]): MenuItem[] {
'audit/operation/index': '/oplog',
'audit/login/index': '/loginlog',
'audit/exception/index': '/exceptionlog',
'gymCourse/course/index': '/courses',
'gymCourse/type/index': '/courses/types',
'gymCourse/label/index': '/courses/labels',
'gymCourse/recommend/index': '/courses/recommend',
}
const filteredMenus = backendMenus.filter(menu => menu.menuType !== 'F')
@@ -92,7 +96,11 @@ function getMenuIcon(menuName: string): string {
'文件管理': 'Folder',
'操作日志': 'Document',
'登录日志': 'Document',
'异常日志': 'Warning'
'异常日志': 'Warning',
'团课管理': 'TrophyBase',
'团课类型': 'Grid',
'团课标签': 'Discount',
'团课推荐': 'Star'
}
return iconMap[menuName] || 'Document'
}
@@ -71,6 +71,17 @@
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
background
style="margin-top: 16px; justify-content: flex-end"
@size-change="onSizeChange"
@current-change="onPageChange"
/>
</el-card>
<el-dialog
@@ -112,12 +123,16 @@
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { courseLabelApi, type CourseLabel } from '@/api/groupCourse.api'
import { courseLabelApi, type CourseLabel, type CourseLabelPageRequest } from '@/api/groupCourse.api'
const loading = ref(false)
const dataSource = ref<CourseLabel[]>([])
const searchKeyword = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const modalVisible = ref(false)
const modalTitle = ref('')
const formRef = ref()
@@ -138,12 +153,17 @@ const formRules = {
const fetchData = async () => {
loading.value = true
try {
dataSource.value = await courseLabelApi.getAll()
if (searchKeyword.value) {
dataSource.value = dataSource.value.filter((item) =>
item.labelName.toLowerCase().includes(searchKeyword.value.toLowerCase())
)
const params: CourseLabelPageRequest = {
page: currentPage.value - 1,
size: pageSize.value,
sort: 'id',
order: 'asc',
}
if (searchKeyword.value) params.keyword = searchKeyword.value
const res = await courseLabelApi.getPage(params)
dataSource.value = res.content
total.value = Number(res.totalElements)
} catch {
ElMessage.error('加载数据失败')
} finally {
@@ -151,7 +171,16 @@ const fetchData = async () => {
}
}
const handleSearch = () => fetchData()
const onPageChange = () => fetchData()
const onSizeChange = () => {
currentPage.value = 1
fetchData()
}
const handleSearch = () => {
currentPage.value = 1
fetchData()
}
const handleAdd = () => {
modalTitle.value = '新增标签'
@@ -191,10 +220,15 @@ const handleModalOk = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
// Convert RGBA to hex before submitting
const data = { ...formState }
if (data.color?.startsWith('rgba')) {
data.color = rgbaToHex(data.color)
}
if (formState.id) {
await courseLabelApi.update(formState.id, formState)
await courseLabelApi.update(formState.id, data)
} else {
await courseLabelApi.create(formState)
await courseLabelApi.create(data)
}
ElMessage.success('操作成功')
modalVisible.value = false
@@ -206,6 +240,19 @@ const handleModalOk = async () => {
}
}
/** Convert rgba(r, g, b, a) to #rrggbb or #rrggbbaa hex */
const rgbaToHex = (rgba: string): string => {
const match = rgba.match(/[\d.]+/g)
if (!match || match.length < 3) return rgba
const r = parseInt(match[0])
const g = parseInt(match[1])
const b = parseInt(match[2])
const a = match.length >= 4 ? Math.round(parseFloat(match[3]) * 255) : 255
const toHex = (n: number) => n.toString(16).padStart(2, '0')
const hex = `#${toHex(r)}${toHex(g)}${toHex(b)}`
return a < 255 ? `${hex}${toHex(a)}` : hex
}
onMounted(() => fetchData())
</script>
@@ -4,20 +4,28 @@
<template #header>
<div class="card-title">
<span>团课推荐管理</span>
<el-button type="primary" @click="handleAdd">
新增推荐
</el-button>
<div class="header-actions">
<el-button size="small" @click="resetSort">恢复默认排序</el-button>
<el-button type="primary" @click="handleAdd">新增推荐</el-button>
</div>
</div>
</template>
<el-table
ref="tableRef"
v-loading="loading"
:data="dataSource"
:data="sortedData"
style="width: 100%"
@sort-change="onSortChange"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="courseId" label="团课ID" width="100" />
<el-table-column prop="id" label="ID" width="100" sortable="custom" />
<el-table-column prop="courseName" label="团课名称" min-width="160" show-overflow-tooltip sortable="custom">
<template #default="{ row }">
{{ courseMap[row.courseId] || `课程 #${row.courseId}` }}
</template>
</el-table-column>
<el-table-column prop="recommendTitle" label="推荐标题" min-width="160" show-overflow-tooltip />
<el-table-column label="优先级" width="100" sortable="custom">
<el-table-column prop="priority" label="优先级" width="100" sortable="custom">
<template #default="{ row }">
<el-tag
:type="row.priority >= 5 ? 'danger' : row.priority >= 3 ? 'warning' : 'info'"
@@ -40,6 +48,11 @@
</template>
</el-table-column>
<el-table-column prop="recommendReason" label="推荐理由" min-width="180" show-overflow-tooltip />
<el-table-column prop="createdAt" label="创建时间" width="170" sortable="custom">
<template #default="{ row }">
{{ formatTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">
@@ -82,8 +95,20 @@
:rules="formRules"
label-width="100px"
>
<el-form-item label="关联团课ID" prop="courseId">
<el-input-number v-model="formState.courseId" :min="1" style="width: 100%" placeholder="请输入团课ID" />
<el-form-item label="关联团课" prop="courseId">
<el-select
v-model="formState.courseId"
filterable
placeholder="请选择团课"
style="width: 100%"
>
<el-option
v-for="course in courseList"
:key="course.id"
:value="course.id!"
:label="course.courseName"
/>
</el-select>
</el-form-item>
<el-form-item label="推荐标题" prop="recommendTitle">
<el-input v-model="formState.recommendTitle" placeholder="请输入推荐标题" />
@@ -105,7 +130,7 @@
/>
</el-form-item>
<el-form-item label="优先级" prop="priority">
<el-input-number v-model="formState.priority" :min="0" :max="999" style="width: 100%" />
<el-input-number v-model="formState.priority" :min="0" :max="20" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
@@ -117,12 +142,55 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { groupCourseRecommendApi, type GroupCourseRecommend } from '@/api/groupCourse.api'
import { groupCourseRecommendApi, groupCourseApi, type GroupCourseRecommend, type GroupCourse } from '@/api/groupCourse.api'
const loading = ref(false)
const dataSource = ref<GroupCourseRecommend[]>([])
const courseMap = ref<Record<number, string>>({})
const courseList = ref<GroupCourse[]>([])
const sortState = ref<{ prop: string; order: 'ascending' | 'descending' | null }>({ prop: 'priority', order: 'descending' })
const sortedData = computed(() => {
const list = [...dataSource.value]
const { prop, order } = sortState.value
if (!order) return list
const dir = order === 'ascending' ? 1 : -1
const getName = (courseId: number) => courseMap.value[courseId] || ''
switch (prop) {
case 'id':
return list.sort((a, b) => ((a.id || 0) - (b.id || 0)) * dir)
case 'priority':
return list.sort((a, b) => ((a.priority || 0) - (b.priority || 0)) * dir)
case 'courseName':
return list.sort((a, b) => getName(a.courseId).localeCompare(getName(b.courseId), 'zh') * dir)
case 'createdAt':
return list.sort((a, b) => (a.createdAt || '').localeCompare(b.createdAt || '') * dir)
default:
return list
}
})
const onSortChange = ({ prop, order }: { prop: string; order: 'ascending' | 'descending' | null }) => {
sortState.value = { prop, order }
}
const tableRef = ref()
const resetSort = () => {
tableRef.value?.clearSort()
sortState.value = { prop: 'priority', order: 'descending' }
// Re-apply default sort arrows on the table
tableRef.value?.sort('priority', 'descending')
}
const formatTime = (time?: string) => {
if (!time) return '-'
return new Date(time).toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
})
}
const modalVisible = ref(false)
const modalTitle = ref('')
@@ -137,7 +205,7 @@ const formState = reactive<GroupCourseRecommend>({
})
const formRules = {
courseId: [{ required: true, message: '请输入团课ID', trigger: 'blur' }],
courseId: [{ required: true, message: '请选择团课', trigger: 'change' }],
recommendTitle: [
{ required: true, message: '请输入推荐标题', trigger: 'blur' },
{ max: 200, message: '标题长度不能超过200个字符', trigger: 'blur' },
@@ -148,7 +216,18 @@ const formRules = {
const fetchData = async () => {
loading.value = true
try {
dataSource.value = await groupCourseRecommendApi.getAll()
const [recommends, courses] = await Promise.all([
groupCourseRecommendApi.getAll(),
groupCourseApi.getAll(),
])
dataSource.value = recommends
courseList.value = courses
// Build courseId -> courseName map
const map: Record<number, string> = {}
courses.forEach((c) => {
if (c.id != null) map[c.id] = c.courseName
})
courseMap.value = map
} catch {
ElMessage.error('加载数据失败')
} finally {
@@ -243,4 +322,10 @@ onMounted(() => fetchData())
justify-content: space-between;
align-items: center;
}
.course-recommend-management .header-actions {
display: flex;
align-items: center;
gap: 8px;
}
</style>
@@ -44,17 +44,11 @@
:data="dataSource"
style="width: 100%"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="id" label="ID" width="80" sortable="custom" />
<el-table-column prop="typeName" label="类型名称" min-width="180" />
<el-table-column label="基础难度" width="100">
<template #default="{ row }">
<el-rate
v-model="row.baseDifficulty"
disabled
show-score
:max="10"
text-color="#ff9900"
/>
{{ difficultyLabel(row.baseDifficulty) }}
</template>
</el-table-column>
<el-table-column prop="category" label="分类" width="140">
@@ -64,6 +58,23 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="标签" min-width="160">
<template #default="{ row }">
<template v-if="typeLabelsMap[row.id!]?.length">
<el-tag
v-for="label in typeLabelsMap[row.id!]"
:key="label.id"
:color="label.color"
effect="dark"
size="small"
style="margin-right: 4px; border-color: transparent;"
>
{{ label.labelName }}
</el-tag>
</template>
<span v-else style="color: #999;">-</span>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
@@ -76,12 +87,23 @@
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
background
style="margin-top: 16px; justify-content: flex-end"
@size-change="onSizeChange"
@current-change="onPageChange"
/>
</el-card>
<el-dialog
v-model="modalVisible"
:title="modalTitle"
width="500px"
width="650px"
>
<el-form
ref="formRef"
@@ -119,6 +141,50 @@
placeholder="请输入类型描述"
/>
</el-form-item>
<el-form-item v-if="formState.id" label="类型标签">
<div class="label-select-area">
<div class="label-section-title">
已选标签{{ selectedLabelIds.length }}/5
</div>
<div class="label-tags-row">
<el-tag
v-for="id in selectedLabelIds"
:key="id"
:color="getLabelById(id)?.color"
effect="dark"
size="default"
closable
style="border-color: transparent; margin: 0 6px 6px 0;"
@close="toggleLabel(id)"
>
{{ getLabelById(id)?.labelName }}
</el-tag>
<span v-if="selectedLabelIds.length === 0" style="color: #999;">点击下方标签选择</span>
</div>
<div class="label-section-title">
可选标签
</div>
<div class="label-tags-row">
<el-tag
v-for="label in allLabels"
:key="label.id"
:color="label.color"
:effect="selectedLabelIds.includes(label.id!) ? 'dark' : 'plain'"
:class="{
'label-tag': true,
'label-tag--selected': selectedLabelIds.includes(label.id!),
'label-tag--disabled': !selectedLabelIds.includes(label.id!) && selectedLabelIds.length >= 5,
}"
size="default"
style="cursor: pointer; margin: 0 6px 6px 0; user-select: none;"
@click="toggleLabel(label.id!)"
>
{{ label.labelName }}
</el-tag>
<span v-if="allLabels.length === 0" style="color: #999;">暂无可用标签</span>
</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="modalVisible = false">取消</el-button>
@@ -132,7 +198,7 @@
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { groupCourseTypeApi, type GroupCourseType } from '@/api/groupCourse.api'
import { groupCourseTypeApi, courseLabelApi, type GroupCourseType, type CourseLabel, type GroupCourseTypePageRequest } from '@/api/groupCourse.api'
const loading = ref(false)
const dataSource = ref<GroupCourseType[]>([])
@@ -140,6 +206,14 @@ const searchKeyword = ref('')
const searchCategory = ref('')
const categories = ref<string[]>([])
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const allLabels = ref<CourseLabel[]>([])
const selectedLabelIds = ref<number[]>([])
const typeLabelsMap = ref<Record<number, CourseLabel[]>>({})
const modalVisible = ref(false)
const modalTitle = ref('')
const formRef = ref()
@@ -160,6 +234,15 @@ const formRules = {
],
}
const difficultyLabel = (val?: number) => {
if (!val) return '-'
if (val <= 2) return '入门'
if (val <= 4) return '初级'
if (val <= 6) return '中级'
if (val <= 8) return '进阶'
return '高级'
}
const fetchCategories = async () => {
try {
categories.value = await groupCourseTypeApi.getCategories()
@@ -168,19 +251,51 @@ const fetchCategories = async () => {
}
}
const fetchAllLabels = async () => {
try {
allLabels.value = await courseLabelApi.getAll()
} catch {
// ignore
}
}
const toggleLabel = (id: number) => {
const idx = selectedLabelIds.value.indexOf(id)
if (idx >= 0) {
selectedLabelIds.value.splice(idx, 1)
} else if (selectedLabelIds.value.length < 5) {
selectedLabelIds.value.push(id)
}
}
const getLabelById = (id: number): CourseLabel | undefined =>
allLabels.value.find(l => l.id === id)
const fetchTypeLabels = async (typeId: number) => {
try {
const labels = await courseLabelApi.getByTypeId(typeId)
selectedLabelIds.value = (labels || []).map(l => l.id!).filter(Boolean) as number[]
} catch {
selectedLabelIds.value = []
}
}
const fetchData = async () => {
loading.value = true
try {
if (searchCategory.value) {
dataSource.value = await groupCourseTypeApi.getByCategory(searchCategory.value)
} else {
dataSource.value = await groupCourseTypeApi.getAll()
}
if (searchKeyword.value) {
dataSource.value = dataSource.value.filter((item) =>
item.typeName.toLowerCase().includes(searchKeyword.value.toLowerCase())
)
const params: GroupCourseTypePageRequest = {
page: currentPage.value - 1,
size: pageSize.value,
sort: 'id',
order: 'asc',
}
if (searchKeyword.value) params.keyword = searchKeyword.value
if (searchCategory.value) params.category = searchCategory.value
const res = await groupCourseTypeApi.getPage(params)
dataSource.value = res.content
total.value = Number(res.totalElements)
loadTypeLabels()
} catch {
ElMessage.error('加载数据失败')
} finally {
@@ -188,7 +303,31 @@ const fetchData = async () => {
}
}
const handleSearch = () => fetchData()
const onPageChange = () => fetchData()
const onSizeChange = () => {
currentPage.value = 1
fetchData()
}
const loadTypeLabels = async () => {
const map: Record<number, CourseLabel[]> = {}
const promises = dataSource.value.map(async (type) => {
if (!type.id) return
try {
const labels = await courseLabelApi.getByTypeId(type.id)
map[type.id] = labels || []
} catch {
map[type.id] = []
}
})
await Promise.all(promises)
typeLabelsMap.value = map
}
const handleSearch = () => {
currentPage.value = 1
fetchData()
}
const handleAdd = () => {
modalTitle.value = '新增类型'
@@ -199,6 +338,7 @@ const handleAdd = () => {
description: '',
category: '',
})
selectedLabelIds.value = []
modalVisible.value = true
}
@@ -206,6 +346,9 @@ const handleEdit = (row: GroupCourseType) => {
modalTitle.value = '编辑类型'
Object.assign(formState, { ...row })
modalVisible.value = true
// Load labels after dialog opens
fetchAllLabels()
if (row.id) fetchTypeLabels(row.id)
}
const handleDelete = async (row: GroupCourseType) => {
@@ -218,9 +361,10 @@ const handleDelete = async (row: GroupCourseType) => {
await groupCourseTypeApi.delete(row.id!)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
const msg = error?.response?.data?.message || error?.message || '删除失败'
ElMessage.error(msg)
}
}
}
@@ -231,6 +375,11 @@ const handleModalOk = async () => {
await formRef.value.validate()
if (formState.id) {
await groupCourseTypeApi.update(formState.id, formState)
// Sync labels: clear all then add selected ones
await courseLabelApi.clearLabelsFromType(formState.id)
if (selectedLabelIds.value.length > 0) {
await courseLabelApi.addLabelsToType(formState.id, selectedLabelIds.value)
}
} else {
await groupCourseTypeApi.create(formState)
}
@@ -238,9 +387,10 @@ const handleModalOk = async () => {
modalVisible.value = false
fetchCategories()
fetchData()
} catch (error) {
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error('操作失败')
const msg = error?.response?.data?.message || error?.message || '操作失败'
ElMessage.error(msg)
}
}
}
@@ -265,5 +415,41 @@ onMounted(() => {
align-items: center;
}
}
}
.label-select-area {
width: 100%;
}
.label-section-title {
font-size: 12px;
color: #999;
margin-bottom: 6px;
margin-top: 4px;
}
.label-tags-row {
display: flex;
flex-wrap: wrap;
align-items: center;
min-height: 28px;
}
.label-tag {
transition: all 0.2s;
border: 2px solid transparent;
}
.label-tag--selected {
border-color: #fff !important;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15);
transform: scale(1.05);
}
.label-tag--disabled {
opacity: 0.4;
cursor: not-allowed !important;
}
</style>
@@ -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>