新增答辩用后台管理系统
This commit is contained in:
@@ -0,0 +1,773 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user