重构课程管理模块并更新路由选择

- 将课程管理从活动模块移至专用课程模块
- 使用新的课程管理结构更新 AdminLayout 侧边栏菜单
- 修改路由器配置以反映新的课程管理路由
- 从活动模块中删除已弃用的课程管理视图
-添加物种监测
This commit is contained in:
wzclm 2025-03-01 16:07:20 +08:00
parent 892f6a04c8
commit f401f40a6e
11 changed files with 1417 additions and 353 deletions

1
package-lock.json generated
View File

@ -1968,7 +1968,6 @@
"version": "5.6.0",
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz",
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "5.6.1"

View File

@ -0,0 +1,30 @@
import request from '@/utils/request'
/**
* 获取课程报名列表
* @returns {Promise} 返回报名列表数据
*/
export function getCourseEnrollmentList() {
return request.get('/api/admin/course-enrollments')
}
/**
* 导出报名记录
* @param {Object} params - 查询参数
* @param {number} [params.courseId] - 课程ID
* @param {string} [params.startTime] - 开始时间
* @param {string} [params.endTime] - 结束时间
* @param {number} [params.status] - 状态
* @returns {Promise} 返回文件内容
*/
export function exportCourseEnrollments(params = {}) {
return request.get('/api/admin/course-enrollments/export', {
params: {
course_id: params.courseId,
start_time: params.startTime,
end_time: params.endTime,
status: params.status === '' ? undefined : params.status
},
responseType: 'blob' // 设置响应类型为blob
})
}

View File

@ -0,0 +1,79 @@
import request from '@/utils/request'
/**
* 查询物种列表
* @param {Object} params - 查询参数
* @param {number} [params.page=1] - 页码
* @param {number} [params.page_size=10] - 每页条数
* @param {string} [params.species_code] - 物种编号
* @param {string} [params.chinese_name] - 中文名称
* @param {string} [params.category] - 物种类别
* @param {string} [params.protection_level] - 保护等级
* @param {number} [params.status] - 状态
* @returns {Promise} 返回物种列表数据
*/
export function getSpeciesList(params = {}) {
return request.get('/api/admin/species', {
params: {
page: params.page || 1,
page_size: params.page_size || 10,
species_code: params.species_code || undefined,
chinese_name: params.chinese_name || undefined,
category: params.category || undefined,
protection_level: params.protection_level || undefined,
status: params.status === '' ? undefined : params.status
}
})
}
/**
* 获取物种详细信息
* @param {string|number} id - 物种ID
* @returns {Promise} 返回物种详细信息
*/
export function getSpecies(id) {
return request.get(`/api/admin/species/${id}`)
}
/**
* 创建物种
* @param {Object} data - 物种数据
* @returns {Promise} 返回创建结果
*/
export function addSpecies(data) {
return request.post('/api/admin/species', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
/**
* 更新物种信息
* @param {string|number} id - 物种ID
* @param {Object} data - 更新数据
* @returns {Promise} 返回更新结果
*/
export function updateSpecies(id, data) {
return request.put(`/api/admin/species/${id}`, data)
}
/**
* 更新物种状态
* @param {string|number} id - 物种ID
* @param {number} status - 状态值1-启用0-禁用
* @returns {Promise} 返回状态更新结果
*/
export function updateSpeciesStatus(id, status) {
return request.put(`/api/admin/species/${id}/status`, {
status: status === 1 ? 1 : 0 // 确保只发送 1 或 0
})
}
/**
* 获取物种统计信息
* @returns {Promise} 返回统计数据包含物种类别和保护等级的统计
*/
export function getSpeciesStatistics() {
return request.get('/api/admin/species/statistics/overview')
}

View File

@ -164,11 +164,19 @@ const handleLogout = () => {
<el-icon><component :is="icons.Collection" /></el-icon>
<span>活动管理</span>
</template>
<el-menu-item index="/activity/course">课程管理</el-menu-item>
<el-menu-item index="/activity/study">研学管理</el-menu-item>
<el-menu-item index="/activity/knowledge">知识库管理</el-menu-item>
</el-sub-menu>
<el-sub-menu index="course">
<template #title>
<el-icon><component :is="icons.DataLine" /></el-icon>
<span>课程管理</span>
</template>
<el-menu-item index="/course/index">课程管理</el-menu-item>
<el-menu-item index="/course/application">报名管理</el-menu-item>
</el-sub-menu>
<el-sub-menu index="feedback">
<template #title>
<el-icon><component :is="icons.DataLine" /></el-icon>

View File

@ -136,9 +136,15 @@ const router = createRouter({
meta: { title: '轮播图管理', icon: 'picture' }
},
{
path: 'activity/course',
path: 'course/index',
name: 'CourseManagement',
component: () => import('../views/activity/course/index.vue')
component: () => import('../views/course/index.vue'),
meta: { title: '课程管理', icon: 'reading' }
},
{
path: 'course/application',
name: 'CourseApplication',
component: () => import('../views/course/Application.vue')
},
{
path: 'activity/study',

View File

@ -42,7 +42,6 @@ service.interceptors.response.use(
}
const res = response.data;
console.log('响应数据:', res)
// 如果响应成功
if (res.code === 200 || res.success) {

View File

@ -37,4 +37,16 @@ export function sortArrayByField(array, field, order = 'asc') {
});
return sortedArray;
}
/**
* 反转数组顺序
* @param {Array} array - 要反转的数组
* @returns {Array} 反转后的新数组
*/
export function reverseArray(array) {
if (!Array.isArray(array)) {
return array;
}
return [...array].reverse();
}

View File

@ -1,6 +1,6 @@
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { Plus, VideoCamera } from '@element-plus/icons-vue'
import { Plus, VideoCamera, Loading } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getDeviceList } from '@/api/device'
import { createFlvPlayer, destroyFlvPlayer } from '@/utils/videoPlayer'
@ -12,6 +12,7 @@ const mainVideoRef = ref(null)
const mainFlvPlayer = ref(null)
const videoRefs = ref([])
const flvPlayers = ref([])
const playingVideos = ref(new Set()) //
//
const initVideoPlayers = async () => {
@ -28,6 +29,9 @@ const initVideoPlayers = async () => {
})
flvPlayers.value = []
//
playingVideos.value.clear()
// DOM
await nextTick()
@ -36,6 +40,21 @@ const initVideoPlayers = async () => {
const deviceCode = droneList.value[currentDroneIndex.value].code
console.log('初始化主视频播放器:', deviceCode)
mainFlvPlayer.value = createFlvPlayer(mainVideoRef.value, deviceCode)
//
mainVideoRef.value.onplaying = () => {
playingVideos.value.add(deviceCode)
}
//
mainVideoRef.value.onpause = () => {
playingVideos.value.delete(deviceCode)
}
//
mainVideoRef.value.onerror = () => {
playingVideos.value.delete(deviceCode)
}
}
//
@ -45,6 +64,21 @@ const initVideoPlayers = async () => {
console.log('初始化列表视频播放器:', index, deviceCode)
const player = createFlvPlayer(videoRef, deviceCode)
flvPlayers.value.push(player)
//
videoRef.onplaying = () => {
playingVideos.value.add(deviceCode)
}
//
videoRef.onpause = () => {
playingVideos.value.delete(deviceCode)
}
//
videoRef.onerror = () => {
playingVideos.value.delete(deviceCode)
}
}
})
}
@ -141,6 +175,11 @@ onUnmounted(() => {
<el-icon class="offline-icon"><VideoCamera /></el-icon>
<span>无人机离线</span>
</div>
<!-- 只在视频未开始播放时显示loading -->
<div v-if="droneList[currentDroneIndex]?.status === 'online' && !playingVideos.value.has(droneList[currentDroneIndex].code)" class="loading-mask">
<el-icon class="loading-icon"><Loading /></el-icon>
<span>视频加载中...</span>
</div>
<div class="video-info">
<h2>{{ droneList[currentDroneIndex]?.name }}</h2>
<div class="drone-status">
@ -267,6 +306,32 @@ onUnmounted(() => {
}
}
.loading-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.8);
z-index: 2;
.loading-icon {
font-size: 48px;
color: #409EFF;
margin-bottom: 16px;
animation: rotate 1s linear infinite;
}
span {
font-size: 16px;
color: #fff;
}
}
.video-info {
position: absolute;
left: 0;

View File

@ -0,0 +1,342 @@
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getCourseList } from '@/api/activity/course'
import { getCourseEnrollmentList, exportCourseEnrollments } from '@/api/activity/courseEnrollment'
//
const courseOptions = ref([])
//
const searchForm = reactive({
courseId: '',
enrollmentTimeRange: [],
status: '',
currentPage: 1,
pageSize: 10
})
//
const tableData = ref([])
const total = ref(0)
const loading = ref(false)
//
const detailDialogVisible = ref(false)
//
const detailData = ref({})
//
const originalData = ref([]);
//
const filteredData = computed(() => {
return originalData.value.filter(item => {
const matchCourse = searchForm.courseId ? item.userId === searchForm.courseId : true;
const matchStatus = searchForm.status ? item.status === searchForm.status : true;
return matchCourse && matchStatus;
});
});
//
const fetchCourseOptions = async () => {
try {
const res = await getCourseList() //
if (res.success && res.data) {
courseOptions.value = res.data
} else {
courseOptions.value = []
}
} catch (error) {
console.error('获取课程列表失败:', error)
ElMessage.error('获取课程列表失败')
courseOptions.value = []
}
}
//
const fetchEnrollmentList = async () => {
loading.value = true;
try {
const res = await getCourseEnrollmentList();
if (res.success) {
//
originalData.value = [...(res.data.list || [])].reverse().map(item => ({
id: item.id,
courseName: item.course_title,
userName: item.real_name || item.username,
userId: item.user_id,
enrollmentTime: item.enrollment_time,
startTime: item.start_time,
progress: item.progress,
status: item.status
}));
//
tableData.value = filteredData.value;
total.value = res.data.pagination.total || 0;
} else {
console.error('获取失败:', res.message);
ElMessage.error(res.message || '获取报名列表失败');
}
} catch (error) {
console.error('获取报名列表失败:', error);
ElMessage.error('获取报名列表失败');
} finally {
loading.value = false;
}
};
//
const handleSearch = () => {
searchForm.currentPage = 1;
//
tableData.value = filteredData.value;
};
//
const handleDetail = (row) => {
detailData.value = { ...row }
detailDialogVisible.value = true
}
//
const resetForm = () => {
searchForm.courseId = '';
searchForm.status = '';
searchForm.currentPage = 1;
//
fetchEnrollmentList();
};
//
const handleSizeChange = (val) => {
searchForm.pageSize = val
fetchEnrollmentList()
}
const handleCurrentChange = (val) => {
searchForm.currentPage = val
fetchEnrollmentList()
}
//
const formatDateTime = (datetime) => {
if (!datetime) return '-'
return new Date(datetime).toLocaleString()
}
//
const getStatusType = (status) => {
const statusMap = {
0: 'info',
1: 'primary',
2: 'success'
}
return statusMap[status]
}
//
const getStatusText = (status) => {
const statusMap = {
0: '已取消',
1: '学习中',
2: '已完成'
}
return statusMap[status]
}
//
const handleExport = async () => {
try {
const params = {
courseId: searchForm.courseId || undefined,
startTime: searchForm.enrollmentTimeRange?.[0],
endTime: searchForm.enrollmentTimeRange?.[1],
status: searchForm.status
}
const res = await exportCourseEnrollments(params)
// Blob
const blob = new Blob([res], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
//
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
//
const now = new Date()
const fileName = `报名记录_${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}.xlsx`
link.setAttribute('download', fileName)
// body
document.body.appendChild(link)
//
link.click()
//
document.body.removeChild(link)
// URL
window.URL.revokeObjectURL(url)
ElMessage.success('文件下载已开始')
} catch (error) {
console.error('导出失败:', error)
ElMessage.error('导出失败')
}
}
//
onMounted(async () => {
await fetchCourseOptions()
fetchEnrollmentList()
})
</script>
<template>
<div class="course-application">
<div class="search-form">
<el-form :inline="true" :model="searchForm" class="demo-form-inline">
<el-form-item label="课程名称">
<el-select
v-model="searchForm.courseId"
placeholder="请选择课程"
clearable
style="width: 220px;"
@change="handleSearch"
>
<el-option
v-for="item in courseOptions"
:key="item.id"
:label="item.title"
:value="item.id"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable style="width: 220px;" @change="handleSearch">
<el-option label="学习中" :value="1"></el-option>
<el-option label="已完成" :value="2"></el-option>
<el-option label="已取消" :value="0"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="handleSearch">搜索</el-button>
<el-button @click="resetForm">重置</el-button>
<el-button type="warning" @click="handleExport">导出</el-button>
</el-form-item>
</el-form>
</div>
<div class="table-container">
<el-table
:data="tableData"
border
style="width: 100%"
v-loading="loading"
>
<el-table-column prop="id" label="ID" width="80" align="center"></el-table-column>
<el-table-column prop="courseName" label="课程名称" min-width="180" align="center"></el-table-column>
<el-table-column prop="userName" label="学员姓名" width="120" align="center"></el-table-column>
<el-table-column prop="enrollmentTime" label="报名时间" width="160" align="center">
<template #default="scope">
{{ formatDateTime(scope.row.enrollmentTime) }}
</template>
</el-table-column>
<el-table-column prop="startTime" label="开始学习时间" width="160" align="center">
<template #default="scope">
{{ formatDateTime(scope.row.startTime) }}
</template>
</el-table-column>
<el-table-column prop="progress" label="学习进度" width="120" align="center">
<template #default="scope">
<el-progress :percentage="Number(scope.row.progress)"></el-progress>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="scope">
<el-tag :type="getStatusType(scope.row.status)">
{{ getStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right" align="center">
<template #default="scope">
<el-button
type="primary"
link
size="small"
@click="handleDetail(scope.row)"
>查看详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-container" v-if="total > 10">
<el-pagination
background
layout="total, sizes, prev, pager, next"
:total="total"
:page-size="searchForm.pageSize"
:current-page="searchForm.currentPage"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
></el-pagination>
</div>
</div>
<!-- 详情对话框 -->
<el-dialog
v-model="detailDialogVisible"
title="报名详情"
width="600px"
>
<el-descriptions :column="2" border>
<el-descriptions-item label="ID">{{ detailData.id }}</el-descriptions-item>
<el-descriptions-item label="课程名称">{{ detailData.courseName }}</el-descriptions-item>
<el-descriptions-item label="学员姓名">{{ detailData.userName }}</el-descriptions-item>
<el-descriptions-item label="报名时间">{{ formatDateTime(detailData.enrollmentTime) }}</el-descriptions-item>
<el-descriptions-item label="开始学习时间">{{ formatDateTime(detailData.startTime) }}</el-descriptions-item>
<el-descriptions-item label="学习进度">
<el-progress :percentage="Number(detailData.progress)"></el-progress>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusType(detailData.status)">
{{ getStatusText(detailData.status) }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div>
</template>
<style scoped>
.course-application {
padding: 20px;
}
.search-form {
background-color: #fff;
padding: 20px;
border-radius: 4px;
margin-bottom: 20px;
}
.table-container {
background-color: #fff;
padding: 20px;
border-radius: 4px;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.el-button + .el-button {
margin-left: 8px;
}
</style>

File diff suppressed because it is too large Load Diff