774 lines
25 KiB
Vue
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>
|