Files
gym-manage/gym-manage-cuit/src/views/groupCourse/index.vue
T

774 lines
25 KiB
Vue

<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getAllGroupCourses,
createGroupCourse,
updateGroupCourse,
deleteGroupCourse,
restoreGroupCourse,
uploadImage,
getPresignedUrl,
} from '@/api/modules/groupCourse'
import { getAllGroupCourseTypes } from '@/api/modules/groupCourseType'
import { formatDate } from '@/utils/format'
interface CourseItem {
id: number
courseName: string
coachId: number
courseType: number
startTime: string | null
endTime: string | null
maxMembers: number
currentMembers: number
status: number
location: string
coverImage: string | null
description: string
storedValueAmount: number
createdAt: string | null
updatedAt: string | null
deletedAt: string | null
}
// ---- State ----
const loading = ref(false)
const list = ref<CourseItem[]>([])
const total = ref(0)
const typeMap = ref<Record<number, string>>({})
// stats (from full dataset)
const allCourses = ref<CourseItem[]>([])
const statsNonDeleted = ref({ total: 0, normal: 0, cancelled: 0, ended: 0, deleted: 0 })
const stats = computed(() => statsNonDeleted.value)
// filters
const keyword = ref('')
const typeFilter = ref<number | null>(null)
const statusFilter = ref<number | null>(null)
const dateRange = ref<[string, string] | null>(null)
const timePeriod = ref<string | null>(null)
// sorting
type SortMode = 'default' | 'priceAsc' | 'priceDesc' | 'remainingMost'
const sortMode = ref<SortMode>('default')
const currentPage = ref(0)
const pageSize = ref(10)
const displayPage = computed({
get: () => currentPage.value + 1,
set: (val: number) => {
currentPage.value = val - 1
applyPage()
},
})
// dialogs
const dialogVisible = ref(false)
const isEdit = ref(false)
const saving = ref(false)
const adminPassword = ref('')
const pendingCoverFile = ref<File | null>(null)
const coverPreviewUrl = ref('')
const form = reactive({
id: 0,
courseName: '',
coachId: null as number | null,
courseType: null as number | null,
startTime: '',
endTime: '',
maxMembers: 20,
currentMembers: 0,
status: 0,
location: '',
coverImage: '',
description: '',
storedValueAmount: 0,
})
const coverUrlMap = ref<Record<number, string>>({})
// ---- Helpers ----
const toNum = (v: any): number => Number(v)
const statusLabel = (s: any) => {
const map: Record<number, string> = { 0: '正常', 1: '已取消', 2: '已结束' }
return map[toNum(s)] ?? '未知'
}
const statusTagType = (s: any) => {
const map: Record<number, string> = { 0: 'success', 1: 'danger', 2: 'info' }
return map[toNum(s)] ?? 'info'
}
const isDeleted = (row: CourseItem) => row.deletedAt != null
// ---- Methods ----
async function fetchTypes() {
try {
const res: any = await getAllGroupCourseTypes()
const map: Record<number, string> = {}
if (Array.isArray(res)) {
res.forEach((t: any) => {
if (t.id != null && t.typeName) map[t.id] = t.typeName
})
}
typeMap.value = map
} catch {
/* ignore */
}
}
async function fetchStats() {
try {
// 未删除的课程(用于统计)
const res: any = await getAllGroupCourses(false)
const courses: CourseItem[] = Array.isArray(res) ? res : []
statsNonDeleted.value = {
total: courses.length,
normal: courses.filter((m) => toNum(m.status) === 0).length,
cancelled: courses.filter((m) => toNum(m.status) === 1).length,
ended: courses.filter((m) => toNum(m.status) === 2).length,
deleted: 0,
}
// 同时获取已删除课程数
const allRes: any = await getAllGroupCourses(true)
const allCoursesData: CourseItem[] = Array.isArray(allRes) ? allRes : []
statsNonDeleted.value.deleted = allCoursesData.filter((m) => m.deletedAt != null).length
} catch {
statsNonDeleted.value = { total: 0, normal: 0, cancelled: 0, ended: 0, deleted: 0 }
}
}
async function fetchCoverUrls() {
const newMap: Record<number, string> = {}
for (const course of allCourses.value) {
if (course.coverImage) {
try {
const res = await getPresignedUrl(course.coverImage)
if (res.code === 200 && res.data?.presignedUrl) {
newMap[course.id] = res.data.presignedUrl
}
} catch {
// ignore individual failures
}
}
}
coverUrlMap.value = newMap
}
async function loadData() {
loading.value = true
await fetchStats()
// 根据筛选状态加载数据
if (statusFilter.value === 3) {
try {
const res: any = await getAllGroupCourses(true)
allCourses.value = (Array.isArray(res) ? res : []).filter((m: CourseItem) => m.deletedAt != null)
} catch {
allCourses.value = []
}
} else {
try {
const res: any = await getAllGroupCourses(false)
allCourses.value = Array.isArray(res) ? res : []
} catch {
allCourses.value = []
}
}
await fetchCoverUrls()
loading.value = false
applyPage()
}
function applyFilters(): CourseItem[] {
let data = [...allCourses.value]
// keyword filter (course name fuzzy)
if (keyword.value) {
const kw = keyword.value.toLowerCase()
data = data.filter((item) =>
(item.courseName || '').toLowerCase().includes(kw)
)
}
// type filter
if (typeFilter.value != null) {
data = data.filter((item) => item.courseType === typeFilter.value)
}
// status filter(值 3 为"已删除",已在 loadData 数据源层处理,此处跳过)
if (statusFilter.value != null && statusFilter.value !== 3) {
data = data.filter((item) => toNum(item.status) === statusFilter.value)
}
// date range filter
if (dateRange.value) {
const [start, end] = dateRange.value
if (start) {
data = data.filter((item) => item.startTime && item.startTime >= `${start}T00:00:00`)
}
if (end) {
data = data.filter((item) => item.startTime && item.startTime <= `${end}T23:59:59`)
}
}
// time period filter
if (timePeriod.value) {
data = data.filter((item) => {
if (!item.startTime) return false
const hour = new Date(item.startTime).getHours()
switch (timePeriod.value) {
case 'morning':
return hour >= 6 && hour < 12
case 'afternoon':
return hour >= 12 && hour < 18
case 'evening':
return hour >= 18
default:
return true
}
})
}
// sorting
if (sortMode.value === 'priceAsc') {
data.sort((a, b) => (a.storedValueAmount ?? 0) - (b.storedValueAmount ?? 0))
} else if (sortMode.value === 'priceDesc') {
data.sort((a, b) => (b.storedValueAmount ?? 0) - (a.storedValueAmount ?? 0))
} else if (sortMode.value === 'remainingMost') {
data.sort((a, b) => {
const remainA = (a.maxMembers ?? 0) - (a.currentMembers ?? 0)
const remainB = (b.maxMembers ?? 0) - (b.currentMembers ?? 0)
return remainB - remainA
})
} else {
// default: id descending
data.sort((a, b) => (b.id ?? 0) - (a.id ?? 0))
}
return data
}
function applyPage() {
const filtered = applyFilters()
total.value = filtered.length
const start = currentPage.value * pageSize.value
list.value = filtered.slice(start, start + pageSize.value)
}
function handleSearch() {
currentPage.value = 0
loadData()
}
function resetFilters() {
const wasDeleted = statusFilter.value === 3
keyword.value = ''
typeFilter.value = null
statusFilter.value = null
dateRange.value = null
timePeriod.value = null
sortMode.value = 'default'
currentPage.value = 0
if (wasDeleted) {
loadData()
} else {
applyPage()
}
}
// watch sortMode to re-paginate
watch(sortMode, () => {
currentPage.value = 0
applyPage()
})
// 切换“已删除”筛选时重新加载数据
watch(statusFilter, (newVal, oldVal) => {
if (newVal === 3 || oldVal === 3) {
currentPage.value = 0
loadData()
} else {
currentPage.value = 0
applyPage()
}
})
// ---- Create / Edit ----
function openCreate() {
isEdit.value = false
adminPassword.value = ''
pendingCoverFile.value = null
coverPreviewUrl.value = ''
Object.assign(form, {
id: 0,
courseName: '',
coachId: null,
courseType: null,
startTime: '',
endTime: '',
maxMembers: 20,
currentMembers: 0,
status: 0,
location: '',
coverImage: '',
description: '',
storedValueAmount: 0,
})
dialogVisible.value = true
}
function openEdit(row: CourseItem) {
isEdit.value = true
adminPassword.value = ''
pendingCoverFile.value = null
coverPreviewUrl.value = ''
form.id = row.id
form.courseName = row.courseName || ''
form.coachId = row.coachId ?? null
form.courseType = toNum(row.courseType)
form.maxMembers = row.maxMembers ?? 20
form.currentMembers = row.currentMembers ?? 0
form.status = toNum(row.status)
form.location = row.location || ''
form.coverImage = row.coverImage || ''
form.description = row.description || ''
form.storedValueAmount = row.storedValueAmount ?? 0
form.startTime = row.startTime ? formatDate(row.startTime, 'YYYY-MM-DD HH:mm:ss') : ''
form.endTime = row.endTime ? formatDate(row.endTime, 'YYYY-MM-DD HH:mm:ss') : ''
dialogVisible.value = true
// 如果有已有封面,获取预签名URL用于预览
if (form.coverImage) {
getPresignedUrl(form.coverImage).then((res) => {
if (res.code === 200 && res.data?.presignedUrl) {
coverPreviewUrl.value = res.data.presignedUrl
}
}).catch(() => {})
}
}
function onUploadChange(uploadFile: any) {
const rawFile = uploadFile?.raw || uploadFile
if (rawFile) {
pendingCoverFile.value = rawFile
if (coverPreviewUrl.value) URL.revokeObjectURL(coverPreviewUrl.value)
coverPreviewUrl.value = URL.createObjectURL(rawFile)
}
}
function handleImgError(e: Event) {
const img = e.target as HTMLImageElement
img.style.display = 'none'
const fallback = img.nextElementSibling as HTMLElement | null
if (fallback) fallback.style.display = ''
}
function handleDialogClosed() {
Object.assign(form, { startTime: '', endTime: '' })
adminPassword.value = ''
if (coverPreviewUrl.value) {
URL.revokeObjectURL(coverPreviewUrl.value)
coverPreviewUrl.value = ''
}
pendingCoverFile.value = null
}
async function handleSave() {
saving.value = true
try {
const body: any = { ...form }
delete body.id
if (body.startTime) body.startTime = new Date(body.startTime).toISOString()
if (body.endTime) body.endTime = new Date(body.endTime).toISOString()
if (isEdit.value) {
if (!adminPassword.value) {
ElMessage.warning('请输入管理员密码')
saving.value = false
return
}
// upload cover if user selected a new one
if (pendingCoverFile.value) {
const res: any = await uploadImage(pendingCoverFile.value)
if (res.code === 200) {
body.coverImage = res.data.ossKey
} else {
ElMessage.error(res.message || '封面上传失败')
saving.value = false
return
}
}
await updateGroupCourse(form.id, body, { params: { adminPassword: adminPassword.value } })
} else {
if (pendingCoverFile.value) {
const res: any = await uploadImage(pendingCoverFile.value)
if (res.code === 200) {
body.coverImage = res.data.ossKey
} else {
ElMessage.error(res.message || '封面上传失败')
saving.value = false
return
}
}
await createGroupCourse(body)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
loadData()
} catch {
ElMessage.error('操作失败')
} finally {
saving.value = false
}
}
async function handleDelete(row: CourseItem) {
try {
await ElMessageBox.confirm(`确定要删除团课"${row.courseName}"吗?`, '确认删除', { type: 'warning' })
const { value: pwd } = await ElMessageBox.prompt('请输入管理员密码以确认删除', '管理员验证', {
inputType: 'password',
confirmButtonText: '确认删除',
cancelButtonText: '取消',
})
if (!pwd) return
await deleteGroupCourse(row.id, { params: { adminPassword: pwd } })
ElMessage.success('已删除')
loadData()
} catch (e: any) {
if (e !== 'cancel' && e?.message !== 'cancel') {
// error already shown by axios interceptor
}
}
}
async function handleRestore(row: CourseItem) {
try {
await ElMessageBox.confirm(`确定要恢复团课"${row.courseName}"为已取消状态吗?`, '确认恢复', { type: 'info' })
const { value: pwd } = await ElMessageBox.prompt('请输入管理员密码以确认恢复', '管理员验证', {
inputType: 'password',
confirmButtonText: '确认恢复',
cancelButtonText: '取消',
})
if (!pwd) return
await restoreGroupCourse(row.id, { params: { adminPassword: pwd } })
ElMessage.success('已恢复')
loadData()
} catch (e: any) {
if (e !== 'cancel' && e?.message !== 'cancel') {
// error already shown by axios interceptor
}
}
}
onMounted(() => {
fetchTypes()
loadData()
})
</script>
<template>
<div>
<h2 style="margin-bottom: 16px">团课列表</h2>
<!-- Stats -->
<el-row :gutter="16" style="margin-bottom: 16px">
<el-col :span="4" v-for="s in [
{ label: '团课总数', value: stats.total, color: '#1890ff' },
{ label: '正常进行', value: stats.normal, color: '#52c41a' },
{ label: '已取消', value: stats.cancelled, color: '#ff4d4f' },
{ label: '已结束', value: stats.ended, color: '#909399' },
{ label: '已删除', value: stats.deleted, color: '#fa8c16' },
]" :key="s.label">
<el-card shadow="hover">
<div style="text-align: center">
<div style="font-size: 26px; font-weight: bold" :style="{ color: s.color }">
{{ s.value }}
</div>
<div style="color: var(--text-secondary); margin-top: 4px; font-size: 13px">{{ s.label }}</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- Search -->
<el-card class="search-card">
<el-form inline>
<el-form-item label="课程名称">
<el-input
v-model="keyword"
placeholder="请输入课程名称"
clearable
style="width: 180px"
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="课程类型">
<el-select v-model="typeFilter" placeholder="全部" clearable style="width: 130px">
<el-option
v-for="(name, id) in typeMap"
:key="id"
:label="name"
:value="Number(id)"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="statusFilter" placeholder="全部" clearable style="width: 100px">
<el-option label="正常" :value="0" />
<el-option label="已取消" :value="1" />
<el-option label="已结束" :value="2" />
<el-option label="已删除" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="日期范围">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 230px"
/>
</el-form-item>
<el-form-item label="时间段">
<el-select v-model="timePeriod" placeholder="全部" clearable style="width: 100px">
<el-option label="早晨" value="morning" />
<el-option label="下午" value="afternoon" />
<el-option label="夜晚" value="evening" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 8px">
<el-button type="primary" @click="openCreate" v-permission="'business:groupCourse:create'">+ 新增团课</el-button>
<div style="display: flex; align-items: center; gap: 10px">
<span style="color: var(--text-secondary); font-size: 13px">排序:</span>
<el-select v-model="sortMode" style="width: 180px" size="default">
<el-option label="默认排序(ID倒序)" value="default" />
<el-option label="价格从低到高" value="priceAsc" />
<el-option label="价格从高到低" value="priceDesc" />
<el-option label="剩余名额最多" value="remainingMost" />
</el-select>
</div>
</div>
</el-card>
<!-- Table -->
<el-card>
<el-table :data="list" v-loading="loading" stripe highlight-current-row>
<el-table-column prop="id" label="团课ID" width="110" align="center" />
<el-table-column prop="courseName" label="课程名称" min-width="150" show-overflow-tooltip />
<el-table-column label="课程类型" width="110">
<template #default="{ row }">
{{ typeMap[row.courseType] || row.courseType || '-' }}
</template>
</el-table-column>
<el-table-column label="封面" width="72" align="center">
<template #default="{ row }">
<template v-if="row.coverImage && coverUrlMap[row.id]">
<img
:src="coverUrlMap[row.id]"
style="width: 56px; height: 40px; object-fit: cover; border-radius: 4px; border: 1px solid #dcdfe6"
@error="handleImgError"
/>
<span style="display: none; color: #c0c4cc; font-size: 18px">✕</span>
</template>
<span v-else style="color: #c0c4cc; font-size: 18px">✕</span>
</template>
</el-table-column>
<el-table-column label="上课时间" width="170">
<template #default="{ row }">
{{ row.startTime ? formatDate(row.startTime, 'MM-DD HH:mm') : '-' }}
</template>
</el-table-column>
<el-table-column label="下课时间" width="170">
<template #default="{ row }">
{{ row.endTime ? formatDate(row.endTime, 'MM-DD HH:mm') : '-' }}
</template>
</el-table-column>
<el-table-column prop="location" label="上课地点" min-width="120" show-overflow-tooltip />
<el-table-column label="报名/上限" width="100" align="center">
<template #default="{ row }">
<span :style="{ color: row.currentMembers >= row.maxMembers ? '#ff4d4f' : '' }">
{{ row.currentMembers ?? 0 }}/{{ row.maxMembers ?? 0 }}
</span>
</template>
</el-table-column>
<el-table-column label="储值消耗" width="100" align="right">
<template #default="{ row }">
{{ row.storedValueAmount != null ? '¥' + Number(row.storedValueAmount).toFixed(2) : '-' }}
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="isDeleted(row)" type="warning" size="small">已删除</el-tag>
<el-tag v-else :type="statusTagType(row.status)" size="small">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">
{{ row.createdAt ? formatDate(row.createdAt, 'YYYY-MM-DD HH:mm') : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="openEdit(row)" v-permission="'business:groupCourse:edit'">编辑</el-button>
<el-button
v-if="isDeleted(row)"
type="success"
link
@click="handleRestore(row)"
v-permission="'business:groupCourse:edit'"
>恢复</el-button>
<el-button v-else type="danger" link @click="handleDelete(row)" v-permission="'business:groupCourse:delete'">删除</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 16px; display: flex; justify-content: space-between; align-items: center">
<span style="color: var(--text-secondary); font-size: 13px">
共 {{ total }} 条记录
</span>
<el-pagination
v-model:current-page="displayPage"
:page-size="pageSize"
:total="total"
layout="prev, pager, next, sizes"
:page-sizes="[10, 20, 50, 100]"
@size-change="(s: number) => { pageSize = s; currentPage = 0; applyPage() }"
/>
</div>
</el-card>
<!-- Create / Edit Dialog -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑团课' : '新增团课'"
width="580px"
:close-on-click-modal="false"
@closed="handleDialogClosed"
>
<el-form :model="form" label-width="100px">
<el-form-item label="课程名称" required>
<el-input v-model="form.courseName" placeholder="请输入课程名称" maxlength="50" />
</el-form-item>
<el-form-item label="课程类型" required>
<el-select v-model="form.courseType" placeholder="请选择课程类型" style="width: 100%">
<el-option
v-for="(name, id) in typeMap"
:key="id"
:label="name"
:value="Number(id)"
/>
</el-select>
</el-form-item>
<el-form-item label="上课时间">
<el-date-picker
v-model="form.startTime"
type="datetime"
placeholder="选择上课时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="下课时间">
<el-date-picker
v-model="form.endTime"
type="datetime"
placeholder="选择下课时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="上课地点">
<el-input v-model="form.location" placeholder="请输入上课地点" maxlength="100" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="最大人数">
<el-input-number v-model="form.maxMembers" :min="1" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="已报名人数">
<el-input-number v-model="form.currentMembers" :min="0" :max="form.maxMembers" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="状态">
<el-select v-model="form.status" style="width: 100%">
<el-option label="正常" :value="0" />
<el-option label="已取消" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="课程描述">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入课程描述"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item label="封面图片">
<el-upload
:auto-upload="false"
:show-file-list="false"
accept="image/*"
:on-change="onUploadChange"
>
<template #trigger>
<el-button type="primary">选择封面图片</el-button>
</template>
</el-upload>
<div v-if="coverPreviewUrl" style="margin-top: 8px">
<a :href="coverPreviewUrl" target="_blank" :title="coverPreviewUrl">
<img
:src="coverPreviewUrl"
style="width: 180px; height: 100px; object-fit: cover; border-radius: 6px; border: 1px solid #dcdfe6"
@error="handleImgError"
/>
</a>
</div>
</el-form-item>
<el-form-item label="储值卡额度">
<el-input-number v-model="form.storedValueAmount" :min="0" :precision="2" style="width: 200px" />
</el-form-item>
<el-form-item v-if="isEdit" label="管理员密码" required>
<el-input
v-model="adminPassword"
type="password"
placeholder="修改团课信息需要验证管理员密码"
show-password
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">确定</el-button>
</template>
</el-dialog>
</div>
</template>