提交 e58a1c6d authored 作者: 王鹏飞's avatar 王鹏飞

chore: update

上级 1d99e640
差异被折叠。
...@@ -15,13 +15,16 @@ ...@@ -15,13 +15,16 @@
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.0.6", "@element-plus/icons-vue": "^2.0.6",
"@tinymce/tinymce-vue": "^5.0.0", "@tinymce/tinymce-vue": "^5.0.0",
"@vueuse/core": "^8.9.4", "@vueuse/core": "^9.0.0",
"axios": "^0.27.2", "axios": "^0.27.2",
"blueimp-md5": "^2.19.0", "blueimp-md5": "^2.19.0",
"dayjs": "^1.11.4",
"element-plus": "^2.2.10", "element-plus": "^2.2.10",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"pinia": "^2.0.16", "lodash-es": "^4.17.21",
"pinia": "^2.0.17",
"qs": "^6.11.0", "qs": "^6.11.0",
"swiper": "^8.3.2",
"video.js": "^7.20.1", "video.js": "^7.20.1",
"vue": "^3.2.37", "vue": "^3.2.37",
"vue-router": "^4.1.2" "vue-router": "^4.1.2"
...@@ -38,12 +41,12 @@ ...@@ -38,12 +41,12 @@
"@vue/tsconfig": "^0.1.3", "@vue/tsconfig": "^0.1.3",
"ali-oss": "^6.17.1", "ali-oss": "^6.17.1",
"eslint": "^8.5.0", "eslint": "^8.5.0",
"eslint-plugin-vue": "^9.2.0", "eslint-plugin-vue": "^9.3.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"sass": "^1.54.0", "sass": "^1.54.0",
"typescript": "~4.7.4", "typescript": "~4.7.4",
"unplugin-auto-import": "^0.10.1", "unplugin-auto-import": "^0.10.1",
"vite": "^3.0.2", "vite": "^3.0.3",
"vue-tsc": "^0.39.0" "vue-tsc": "^0.39.0"
} }
} }
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
const DEFAULT_OPTIONS = { const DEFAULT_OPTIONS = {
controls: true, controls: true,
autoplay: false, autoplay: false,
fluid: true, // fluid: true,
playbackRates: [0.5, 1, 1.5, 2], playbackRates: [0.5, 1, 1.5, 2],
restoreEl: true restoreEl: true
} }
...@@ -80,7 +80,8 @@ function initPlayer() { ...@@ -80,7 +80,8 @@ function initPlayer() {
return player return player
} }
function changeSrc(src: string | { src: string; type?: string }) { function changeSrc(src: string | { src: string; type?: string }) {
if (!player) return if (!player || !src) return
// if (!player.paused()) { // if (!player.paused()) {
// console.log(2) // console.log(2)
// player.pause() // player.pause()
...@@ -98,7 +99,5 @@ onUnmounted(() => { ...@@ -98,7 +99,5 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div> <video class="video-js vjs-default-skin vjs-big-play-centered" ref="videoRef"></video>
<video class="video-js vjs-default-skin vjs-big-play-centered vjs-16-9" ref="videoRef"></video>
</div>
</template> </template>
...@@ -4,7 +4,9 @@ export default { name: 'AppMain' } ...@@ -4,7 +4,9 @@ export default { name: 'AppMain' }
<template> <template>
<section class="app-main"> <section class="app-main">
<router-view></router-view> <div class="app-main-inner">
<router-view></router-view>
</div>
</section> </section>
</template> </template>
...@@ -15,4 +17,9 @@ export default { name: 'AppMain' } ...@@ -15,4 +17,9 @@ export default { name: 'AppMain' }
margin: 20px; margin: 20px;
overflow: hidden; overflow: hidden;
} }
.app-main-inner {
max-width: 1440px;
height: 100%;
margin: 0 auto;
}
</style> </style>
...@@ -26,6 +26,12 @@ export function getCourse(params: { course_id: string; semester_id: string }) { ...@@ -26,6 +26,12 @@ export function getCourse(params: { course_id: string; semester_id: string }) {
export function getChapterTreeList(data: { course_id: string; semester_id: string }) { export function getChapterTreeList(data: { course_id: string; semester_id: string }) {
return httpRequest.post('/api/saas/api/v1/chapter/tree', data) return httpRequest.post('/api/saas/api/v1/chapter/tree', data)
} }
// 课程考核列表
export function getChapterVideoTreeList(data: { course_id: string; semester_id: string }) {
return httpRequest.post('/api/saas/api/v1/chapter/video-tree', data)
}
// 收藏/取消收藏 // 收藏/取消收藏
export function collectionResource(data: { export function collectionResource(data: {
course_id: string course_id: string
...@@ -38,7 +44,8 @@ export function collectionResource(data: { ...@@ -38,7 +44,8 @@ export function collectionResource(data: {
}) { }) {
return httpRequest.post('/api/saas/api/v1/collection/resource', data) return httpRequest.post('/api/saas/api/v1/collection/resource', data)
} }
// 获取大作业详情
// 获取课程大作业详情
export function getCourseWork(data: { course_id: string; semester_id: string }) { export function getCourseWork(data: { course_id: string; semester_id: string }) {
return httpRequest.post('/api/saas/api/v1/job/detail', data) return httpRequest.post('/api/saas/api/v1/job/detail', data)
} }
...@@ -53,6 +60,16 @@ export function submitCourseWork(data: { ...@@ -53,6 +60,16 @@ export function submitCourseWork(data: {
return httpRequest.post('/api/saas/api/v1/job/submit', data) return httpRequest.post('/api/saas/api/v1/job/submit', data)
} }
// 获取课程直播列表
export function getCourseExamList(params: { course_id: string; semester_id: string }) {
return httpRequest.get('/api/saas/api/v1/question-bank/papers', { params })
}
// 获取课程直播列表
export function getCourseLiveList(data: { course_id: string; semester_id: string }) {
return httpRequest.post('/api/saas/api/v1/meeting/list', data)
}
// 获取课程小节信息 // 获取课程小节信息
export function getCourseSection(data: { section_id: string; semester_id: string }) { export function getCourseSection(data: { section_id: string; semester_id: string }) {
return httpRequest.post('/api/saas/api/v1/chapter/section/detail', data) return httpRequest.post('/api/saas/api/v1/chapter/section/detail', data)
...@@ -64,26 +81,25 @@ export function getCoursePlayInfo(params: { source_id: string }) { ...@@ -64,26 +81,25 @@ export function getCoursePlayInfo(params: { source_id: string }) {
} }
// 获取视频观看记录 // 获取视频观看记录
export function getVideoRecords(data: { export function getVideoRecords(data: {
sso_id: string
semester_id: string
chapter_id: string chapter_id: string
course_id: string course_id: string
video_id: string
section_id: string section_id: string
current_playing_time: string semester_id: string
max_playing_time: string video_id: string
valid_playing_time: string
cumulative_playing_time: string
}) { }) {
return httpRequest.post('/api/saas/api/v1/course/video/recent-viewings', data) return httpRequest.post('/api/saas/api/v1/course/video/recent-viewings', data)
} }
// 上传视频观看记录 // 上传视频观看记录
export function uploadVideoRecords(data: { export function uploadVideoRecords(data: {
semester_id: string
chapter_id: string chapter_id: string
course_id: string course_id: string
video_id: string cumulative_playing_time: string
current_playing_time: number
max_playing_time: number
section_id: string section_id: string
semester_id: string
valid_playing_time: number
video_id: string
}) { }) {
return httpRequest.post('/api/saas/api/v1/course/video/upload-records', data) return httpRequest.post('/api/saas/api/v1/course/video/upload-records', data)
} }
<script setup lang="ts"> <script setup lang="ts">
import type { CourseListItemTypes } from '../types' import type { CourseListItemType } from '../types'
import { useMapStore } from '@/stores/map' import { useMapStore } from '@/stores/map'
import { topCourse } from '../api' import { topCourse } from '../api'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
interface Props { interface Props {
data: CourseListItemTypes data: CourseListItemType
} }
const props = defineProps<Props>() const props = defineProps<Props>()
...@@ -28,7 +28,7 @@ const electiveTypeText = computed(() => { ...@@ -28,7 +28,7 @@ const electiveTypeText = computed(() => {
// 是否选中 // 是否选中
const isActive = computed(() => props.data.course_id === courseId) const isActive = computed(() => props.data.course_id === courseId)
// 置顶 // 置顶
function handleTop(data: CourseListItemTypes) { function handleTop(data: CourseListItemType) {
topCourse({ id: data.id, status: data.is_top ? 0 : 1 }).then(() => { topCourse({ id: data.id, status: data.is_top ? 0 : 1 }).then(() => {
emit('change') emit('change')
ElMessage.success('操作成功') ElMessage.success('操作成功')
......
<script setup lang="ts"> <script setup lang="ts">
import { Search, Filter } from '@element-plus/icons-vue' import { Search, Filter } from '@element-plus/icons-vue'
import type { SemesterType } from '@/types' import type { SemesterType } from '@/types'
import type { CourseListParamsTypes } from '../types' import type { CourseListParamsType } from '../types'
import { searchCourseList, getSemesterList } from '../api' import { searchCourseList, getSemesterList } from '../api'
import { useMapStore } from '@/stores/map' import { useMapStore } from '@/stores/map'
...@@ -12,7 +12,7 @@ const mapStore = useMapStore() ...@@ -12,7 +12,7 @@ const mapStore = useMapStore()
const electiveTypes = mapStore.getMapValuesByKey('system_elective_type') const electiveTypes = mapStore.getMapValuesByKey('system_elective_type')
// 列表参数 // 列表参数
const params = reactive<CourseListParamsTypes>({ id: '', semester_ids: [], elective_types: [] }) const params = reactive<CourseListParamsType>({ id: '', semester_ids: [], elective_types: [] })
// 搜索课程 // 搜索课程
const searchValue = $ref('') const searchValue = $ref('')
......
<!-- 学习 --> <!-- 学习 -->
<script setup lang="ts"> <script setup lang="ts">
import * as api from '../api' import type { CourseChapterType } from '../types'
let chapterList = $ref<CourseType[]>([]) interface Props {
const { query } = useRoute() chapterList: CourseChapterType[]
const courseId = $ref(query.course_id as string)
const semesterId = $ref(query.semester_id as string)
// 获取章节列表
function fetchList() {
if (!courseId || !semesterId) {
return
}
api.getChapterTreeList({ course_id: courseId, semester_id: semesterId }).then(res => {
chapterList = res.data.items
})
} }
onMounted(() => { defineProps<Props>()
fetchList()
const route = useRoute()
let courseId = $ref<string>('')
let sectionId = $ref<string>('')
let semesterId = $ref<string>('')
watchEffect(() => {
courseId = route.query.course_id as string
sectionId = route.query.section_id as string
semesterId = route.query.semester_id as string
}) })
</script> </script>
<template> <template>
<div class="course-player-chapter"> <div class="course-player-chapter">
<el-tabs> <el-tabs>
<el-tab-pane label="章节"></el-tab-pane> <el-tab-pane label="章节">
<dl v-for="(item, index) in chapterList" :key="item.id">
<dt>
<span>{{ index + 1 }}</span>
<p>{{ item.name }}</p>
</dt>
<dd v-for="section in item.sections" :key="section.id" :class="{ 'is-active': section.id === sectionId }">
<router-link
:to="`/course/player?course_id=${courseId}&chapter_id=${item.id}&section_id=${section.id}&semester_id=${semesterId}`"
>
{{ section.name }}
</router-link>
</dd>
</dl>
</el-tab-pane>
<el-tab-pane label="讲义"></el-tab-pane> <el-tab-pane label="讲义"></el-tab-pane>
</el-tabs> </el-tabs>
<el-collapse>
<el-collapse-item :title="item.name" :name="item.id" v-for="item in chapterList" :key="item.id">
<el-collapse>
<el-collapse-item :title="section.name" :name="section.id" v-for="section in item.sections" :key="section.id">
<ul>
<li v-for="resource in section.resources" :key="resource.id">
<router-link
:to="`/course/player?course_id=${courseId}&section_id=${section.id}&semester_id=${semesterId}`"
>
{{ resource.name }}
</router-link>
</li>
</ul>
</el-collapse-item>
</el-collapse>
</el-collapse-item>
</el-collapse>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.course-player-chapter { .course-player-chapter {
width: 258px; width: 258px;
height: 100%;
padding: 20px; padding: 20px;
background-color: #1f1e24; background-color: #1f1e24;
} box-sizing: border-box;
li { :deep(.el-tabs__nav) {
display: flex; float: none;
align-items: center; display: flex;
height: 48px; }
line-height: 48px; :deep(.el-tabs__item) {
border-bottom: 1px solid #e6e6e6;
a {
flex: 1; flex: 1;
height: 40px;
font-size: 16px;
line-height: 40px;
color: #fff;
text-align: center;
&.is-active {
color: var(--main-color);
}
}
dl {
color: #fff;
padding: 20px 0;
}
dl + dl {
border-top: 1px dashed #b2b2b2;
}
dt {
display: flex;
align-items: center;
margin-bottom: 10px;
span {
display: inline-block;
width: 26px;
height: 26px;
color: var(--main-color);
line-height: 26px;
text-align: center;
background-color: #fff;
border-radius: 50%;
}
p {
margin-left: 8px;
font-size: 16px;
font-weight: 500;
line-height: 26px;
color: #fff;
}
}
dd {
margin-left: 34px;
padding: 5px 0;
font-size: 14px;
font-weight: 400;
line-height: 24px;
color: #ffffff;
&.is-active {
color: var(--main-color);
}
} }
} }
</style> </style>
<script setup lang="ts">
import type { CourseResourceType } from '../types'
import ResourceIcon from '@/components/ResourceIcon.vue'
import { Download } from '@element-plus/icons-vue'
import { saveAs } from 'file-saver'
import { collectionResource } from '../api'
const { query } = useRoute()
const courseId = $ref(query.course_id as string)
const chapterId = $ref(query.chapter_id as string)
const semesterId = $ref(query.semester_id as string)
const sectionId = $ref(query.section_id as string)
interface Props {
data: CourseResourceType
}
const props = defineProps<Props>()
// 跳转链接
const targetHref = computed(() => {
const info = props.data.info
if (['pptx', 'doc', 'docx', 'xls', 'xlsx'].includes(info.type)) {
return `https://view.officeapps.live.com/op/view.aspx?src=${info.url}`
} else {
return info.url
}
})
// 收藏/取消收藏
function toggleCollection(data: CourseResourceType) {
// 资源类型: 1章节,2视频,3作业,4其他,6腾讯会议,9考试,10课件,11教案
// 收藏类型: 1其他, 2视频,3课件,4教案,5作业,6帖子
const typeMap: Record<number, number> = { 4: 1, 2: 2, 10: 3, 11: 4, 3: 5, 9: 5 }
collectionResource({
course_id: courseId,
semester_id: semesterId,
chapter_id: chapterId,
section_id: sectionId,
source_id: data.resource_id,
type: typeMap[data.resource_type],
status: data.collection_count ? 0 : 1
}).then(() => {
data.collection_count = data.collection_count ? 0 : 1
})
}
// 是否可以下载
function canDownload(type: number) {
return [4, 10, 11].includes(type)
}
// 下载资源
function downloadFile(data: CourseResourceType) {
data.info.url && saveAs(data.info.url, data.info.name)
}
</script>
<template>
<div class="course-resource-item">
<p>
<a :href="targetHref" target="_blank">
<ResourceIcon :resourceType="data.resource_type" />
{{ data.name }}
</a>
</p>
<div class="actions">
<i class="icon-star" :class="!!data.collection_count ? 'is-active' : ''" @click="toggleCollection(data)"></i>
<i class="icon-download" @click="downloadFile(data)" v-if="canDownload(data.resource_type)"><Download /></i>
</div>
</div>
</template>
<style lang="scss" scoped>
.course-resource-item {
display: flex;
align-items: center;
height: 48px;
padding: 0 20px;
border-bottom: 1px solid #e6e6e6;
p {
flex: 1;
line-height: 48px;
}
&:hover {
color: var(--main-color);
}
.actions {
width: 60px;
display: flex;
align-items: center;
justify-content: space-between;
}
}
.icon-star {
display: inline-block;
width: 16px;
height: 16px;
background: url(@/assets/images/icon_star.png) no-repeat;
background-size: contain;
cursor: pointer;
&.is-active {
background: url(@/assets/images/icon_star_hover.png) no-repeat;
background-size: contain;
}
}
.icon-download {
display: inline-block;
width: 20px;
height: 20px;
cursor: pointer;
}
</style>
<script setup lang="ts"> <script setup lang="ts">
import AppVideoPlayer from '@/components/base/AppVideoPlayer.vue' import type { CourseChapterType, CourseSectionType, CourseResourceType, VideoRecordType, PlayItemType } from '../types'
import type { VideoJsPlayer, VideoJsPlayerOptions } from 'video.js' import type { VideoJsPlayer, VideoJsPlayerOptions } from 'video.js'
import { throttle } from 'lodash'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { getCoursePlayInfo } from '../api' import { Swiper, SwiperSlide } from 'swiper/vue'
import type { PlayItemTypes } from '../types' import 'swiper/css'
import AppVideoPlayer from '@/components/base/AppVideoPlayer.vue'
import { getCoursePlayInfo, getVideoRecords, uploadVideoRecords } from '../api'
interface Props {
chapterList: CourseChapterType[]
}
const props = defineProps<Props>()
const options = $ref<VideoJsPlayerOptions>() const options = $ref<VideoJsPlayerOptions>()
const { query } = useRoute() const route = useRoute()
const sourceId = $ref(query.source_id as string) let courseId = $ref<string>('')
let chapterId = $ref<string>('')
let sectionId = $ref<string>('')
let resourceId = $ref<string>('')
let semesterId = $ref<string>('')
watchEffect(() => {
courseId = route.query.course_id as string
chapterId = route.query.chapter_id as string
sectionId = route.query.section_id as string
resourceId = route.query.resource_id as string
semesterId = route.query.semester_id as string
})
// 当前章
const chapter = $computed<CourseSectionType>(() => {
return props.chapterList.find(item => item.id === chapterId)
})
// 当前节
const section = $computed<CourseSectionType>(() => {
return chapter?.sections.find(item => item.id === sectionId)
})
// 当前节
const resource = $computed<CourseResourceType>(() => {
return section?.resources.find(item => item.resource_id === resourceId)
})
// 资源视频列表
const videoList = $computed<CourseResourceType[]>(() => {
const list = section?.resources ?? []
return list.filter(item => item.resource_type === 2)
})
let playList = $ref<PlayItemTypes[]>([]) watchEffect(() => {
const currentPlayList = $computed<PlayItemTypes[]>(() => { if (videoList.length) {
const found = videoList.find(item => item.resource_id === resourceId)
resourceId = found ? resourceId : videoList[0]?.resource_id
}
})
const progress = reactive<VideoRecordType>({
cumulative_playing_time: '',
current_playing_time: 0,
max_playing_time: 0,
valid_playing_time: 0,
watchedTimePoint: []
})
let playList = $ref<PlayItemType[]>([])
const currentPlayList = $computed<PlayItemType[]>(() => {
return playList.filter(item => item.StreamType === 'video' && item.Format === 'mp4') return playList.filter(item => item.StreamType === 'video' && item.Format === 'mp4')
}) })
// 获取视频信息
function fetchInfo() { function fetchInfo() {
getCoursePlayInfo({ source_id: sourceId }).then(res => { if (!resource?.info.source_id) return
return getCoursePlayInfo({ source_id: resource.info.source_id }).then(res => {
playList = res.data.playing_list playList = res.data.playing_list
changeSrc(currentPlayList[0]) changeSrc(currentPlayList[0])
}) })
} }
onMounted(fetchInfo) // 获取视频记录
function fetchVideoRecords() {
if (!resource) return
getVideoRecords({
chapter_id: chapterId,
course_id: courseId,
section_id: sectionId,
semester_id: semesterId,
video_id: resourceId
}).then(res => {
const { detail = {} } = res.data
progress.current_playing_time = detail.current_playing_time ? parseFloat(detail.current_playing_time) : 0
progress.max_playing_time = detail.max_playing_time ? parseFloat(detail.max_playing_time) : 0
progress.valid_playing_time = detail.valid_playing_time ? parseFloat(detail.valid_playing_time) : 0
progress.watchedTimePoint = []
if (videoJsPlayer && progress.current_playing_time) {
videoJsPlayer.currentTime(progress.current_playing_time)
}
})
}
watchEffect(async () => {
await fetchInfo()
fetchVideoRecords()
})
const throttledFn = throttle(
() => {
if (progress.watchedTimePoint.length < 5) return
uploadVideoRecords({
chapter_id: chapterId,
course_id: courseId,
section_id: sectionId,
semester_id: semesterId,
video_id: resourceId,
valid_playing_time: progress.valid_playing_time,
current_playing_time: progress.current_playing_time,
max_playing_time: progress.max_playing_time,
cumulative_playing_time: progress.watchedTimePoint.join(',')
})
// 清空已经上传过的观看时间点
progress.watchedTimePoint = []
},
5000,
{ leading: false }
)
let watchedTime = 0
function onTimeUpdate() {
console.log('onTimeUpdate')
if (!videoJsPlayer) return
const time = Math.floor(videoJsPlayer.currentTime() ?? 0)
// 更新当前播放时间
progress.current_playing_time = time
// 观看的最大点
progress.max_playing_time = Math.max(time, progress.max_playing_time)
// 观看时间点
const hasTimePoint = progress.watchedTimePoint.includes(time)
if (!hasTimePoint) {
progress.watchedTimePoint.push(time)
}
// 更新观看累计时长
if (time !== watchedTime) {
watchedTime = time
// // 增加跳过片头时间
// if (this.isSkip && !this.progress.pt) {
// this.progress.pt = this.skipTime + 20
// }
// 默认增加时间
progress.valid_playing_time = progress.valid_playing_time || 20
progress.valid_playing_time++
}
throttledFn()
}
let src = $ref({ type: '', src: '' }) /**
* 视频播放器相关
*/
let src = $ref<{ src: string; type: string }>()
// 跳过片头 // 跳过片头
const isSkip = useStorage('isSkip', false) const isSkip = useStorage('isSkip', false)
// 连续播放 // 连续播放
...@@ -34,16 +164,26 @@ let videoJsPlayer = $ref<VideoJsPlayer | null>() ...@@ -34,16 +164,26 @@ let videoJsPlayer = $ref<VideoJsPlayer | null>()
const onReady = (player: VideoJsPlayer) => { const onReady = (player: VideoJsPlayer) => {
videoJsPlayer = player videoJsPlayer = player
isReady = true isReady = true
console.log(videoJsPlayer)
} }
function changeSrc(data: PlayItemTypes) { function changeSrc(data: PlayItemType) {
// src = { src: data.PlayURL, type: 'application/x-mpegURL' } // src = { src: data.PlayURL, type: 'application/x-mpegURL' }
src = { src: data.PlayURL, type: 'video/mp4' } src = { src: data.PlayURL, type: 'video/mp4' }
} }
function changeResource(data: CourseResourceType) {
throttledFn && throttledFn.cancel()
resourceId = data.resource_id
}
</script> </script>
<template> <template>
<AppVideoPlayer :options="options" :src="src" @ready="onReady"></AppVideoPlayer> <AppVideoPlayer
:options="options"
:src="src"
@ready="onReady"
@timeupdate="onTimeUpdate"
style="width: 100%; height: 510px"
v-if="src"
></AppVideoPlayer>
<!-- 设置 --> <!-- 设置 -->
<teleport to=".vjs-control-bar" v-if="isReady"> <teleport to=".vjs-control-bar" v-if="isReady">
<el-popover trigger="hover" effect="dark" placement="top" :teleported="false"> <el-popover trigger="hover" effect="dark" placement="top" :teleported="false">
...@@ -68,6 +208,18 @@ function changeSrc(data: PlayItemTypes) { ...@@ -68,6 +208,18 @@ function changeSrc(data: PlayItemTypes) {
</ul> </ul>
</el-popover> </el-popover>
</teleport> </teleport>
<swiper :slidesPerView="'auto'" :spaceBetween="30">
<swiper-slide
v-for="item in videoList"
:key="item.id"
class="video-item"
:class="{ 'is-active': item.resource_id === resourceId }"
@click="changeResource(item)"
>
<img :src="item.info?.cover" />
<p>{{ item.info.name }}</p>
</swiper-slide>
</swiper>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
...@@ -75,4 +227,37 @@ function changeSrc(data: PlayItemTypes) { ...@@ -75,4 +227,37 @@ function changeSrc(data: PlayItemTypes) {
.vjs-icon-cog { .vjs-icon-cog {
font-size: 1.8em; font-size: 1.8em;
} }
.video-item {
position: relative;
margin: 20px 0;
width: 200px;
height: 100px;
border-radius: 5px;
cursor: pointer;
overflow: hidden;
box-sizing: border-box;
p {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 0 10px;
font-size: 14px;
line-height: 30px;
color: #fff;
background-color: rgba(0, 0, 0, 0.5);
text-align: center;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
&.is-active {
border: 2px solid var(--main-color);
}
}
</style> </style>
<!-- 课程考核 --> <!-- 课程考核 -->
<script setup lang="ts"></script> <script setup lang="ts">
<template>课程考核</template> import { getChapterVideoTreeList } from '../api'
const { query } = useRoute()
const courseId = $ref(query.course_id as string)
const semesterId = $ref(query.semester_id as string)
let list = $ref([])
// 树列表
const treeList = $computed(() => {
return makeTree(list)
})
// 扁平列表
const flatList = $computed(() => {
return tree2List(list)
})
// 累计学习时长
const studyTime = $computed(() => {
return list.reduce((total, item) => {
return total + item.watch_video_length || 0
}, 0)
})
// 视频统计
const videoStatistics = $computed(() => {
return flatList.reduce(
(results, item) => {
if (item.depth === 3) {
results.length++
if (item.is_finished) {
results.completedLength++
}
}
return results
},
{ length: 0, completedLength: 0 }
)
})
// 完成率
const completedPercent = $computed(() => {
const percent = (videoStatistics.completedLength / videoStatistics.length) * 100
return parseFloat(percent.toFixed(2)) || 0
})
// 树结构转换为列表
function tree2List(tree, parent) {
return tree.reduce(function (acc, item) {
if (item.depth === 2) {
item.chapter_id = parent.id
}
if (item.depth === 3) {
item.chapter_id = parent.chapter_id
item.section_id = parent.id
}
acc.push(item)
if (item.sections) acc = acc.concat(tree2List(item.sections, item))
if (item.resources) acc = acc.concat(tree2List(item.resources, item))
return acc
}, [])
}
// 将列表转换为树结构
function makeTree(list: any) {
return list.map(item => {
if (item.sections || item.resources) item.children = makeTree(item.sections || item.resources)
return item
})
}
function fetchList() {
getChapterVideoTreeList({ course_id: courseId, semester_id: semesterId }).then(res => {
list = res.data.items
})
}
onMounted(() => {
fetchList()
})
</script>
<template>
<div class="course-assess">
<div class="course-assess-hd">
<p class="t1">课程“音视频”观看统计(累计学习时长:{{ studyTime }}</p>
<div class="course-assess-hd__aside">
<p class="t2">完成率</p>
<el-progress :percentage="completedPercent" style="width: 200px" />
</div>
</div>
<el-table
:data="treeList"
default-expand-all
row-key="id"
style="width: 100%"
:header-cell-style="{ 'background-color': '#F7F8FA' }"
>
<el-table-column prop="name" label="章节">
<template #default="{ row }">
<template v-if="row.depth === 3">
<router-link
:to="`/course/player?course_id=${courseId}&chapter_id=${row.chapter_id}&section_id=${row.section_id}&resource_id=${row.resource_id}&semester_id=${semesterId}`"
target="_blank"
>
{{ row.name }}
</router-link>
</template>
<template v-else>{{ row.name }}</template>
</template>
</el-table-column>
<el-table-column prop="watch_video_length" label="学习时长" width="180" align="center" />
<el-table-column prop="name" label="百分比" width="180" align="center">
<template #default="{ row }">
<el-progress :percentage="row.schedule" />
</template>
</el-table-column>
</el-table>
</div>
</template>
<style lang="scss" scoped>
.course-assess-hd {
padding-bottom: 20px;
display: flex;
align-items: center;
.t1 {
flex: 1;
font-size: 16px;
font-weight: 400;
color: var(--main-color);
}
}
.course-assess-hd__aside {
display: flex;
.t2 {
padding: 0 20px;
font-size: 16px;
color: #333333;
}
}
</style>
<!-- 学习 --> <!-- 学习 -->
<script setup lang="ts"> <script setup lang="ts">
import type { CourseChapterTypes, CourseSectionTypes, CourseResourceTypes } from '../types' import type { CourseChapterType, CourseSectionType, CourseResourceType } from '../types'
import ResourceIcon from '@/components/ResourceIcon.vue' import ResourceIcon from '@/components/ResourceIcon.vue'
import { getChapterTreeList, collectionResource } from '../api' import { getChapterTreeList, collectionResource } from '../api'
let chapterList = $ref<CourseChapterTypes[]>([]) let chapterList = $ref<CourseChapterType[]>([])
const { query } = useRoute() const { query } = useRoute()
const courseId = $ref(query.course_id as string) const courseId = $ref(query.course_id as string)
const semesterId = $ref(query.semester_id as string) const semesterId = $ref(query.semester_id as string)
...@@ -21,14 +21,14 @@ onMounted(() => { ...@@ -21,14 +21,14 @@ onMounted(() => {
fetchList() fetchList()
}) })
// 是否可以收藏 // 是否可以收藏
function canCollection(data: CourseResourceTypes) { function canCollection(data: CourseResourceType) {
return [4, 2, 10, 11, 3].includes(data.resource_type) return [4, 2, 10, 11, 3].includes(data.resource_type)
} }
// 收藏/取消收藏 // 收藏/取消收藏
function toggleCollection(resource: CourseResourceTypes, section: CourseSectionTypes, chapter: CourseChapterTypes) { function toggleCollection(resource: CourseResourceType, section: CourseSectionType, chapter: CourseChapterType) {
// 资源类型: 1章节,2视频,3作业,4其他,6腾讯会议,9考试,10课件,11教案 // 资源类型: 1章节,2视频,3作业,4其他,6腾讯会议,9考试,10课件,11教案
// 收藏类型: 1其他, 2视频,3课件,4教案,5作业,6帖子 // 收藏类型: 1其他, 2视频,3课件,4教案,5作业,6帖子
const typeMap: Record<number, number> = { 4: 1, 2: 2, 10: 3, 11: 4, 3: 5 } const typeMap: Record<number, number> = { 4: 1, 2: 2, 10: 3, 11: 4, 3: 5, 9: 5 }
collectionResource({ collectionResource({
course_id: courseId, course_id: courseId,
semester_id: semesterId, semester_id: semesterId,
...@@ -49,13 +49,11 @@ function toggleCollection(resource: CourseResourceTypes, section: CourseSectionT ...@@ -49,13 +49,11 @@ function toggleCollection(resource: CourseResourceTypes, section: CourseSectionT
<template #title><i class="icon-chapter"></i>{{ item.name }}</template> <template #title><i class="icon-chapter"></i>{{ item.name }}</template>
<el-collapse class="course-sections"> <el-collapse class="course-sections">
<el-collapse-item :name="section.id" v-for="section in item.sections" :key="section.id"> <el-collapse-item :name="section.id" v-for="section in item.sections" :key="section.id">
<template #title><i class="icon-chapter"></i>{{ item.name }}</template> <template #title><i class="icon-chapter"></i>{{ section.name }}</template>
<ul> <ul>
<li class="course-resource-item" v-for="resource in section.resources" :key="resource.id"> <li class="course-resource-item" v-for="resource in section.resources" :key="resource.id">
<router-link <router-link
:to="`/course/player?course_id=${courseId}&section_id=${ :to="`/course/player?course_id=${courseId}&chapter_id=${item.id}&section_id=${section.id}&resource_id=${resource.resource_id}&semester_id=${semesterId}`"
section.id
}&semester_id=${semesterId}&&source_id=${resource.info?.source_id || ''}`"
target="_blank" target="_blank"
> >
<ResourceIcon :resourceType="resource.resource_type" /> <ResourceIcon :resourceType="resource.resource_type" />
......
<!-- 考试 --> <!-- 考试 -->
<script setup lang="ts"></script> <script setup lang="ts">
<template>考试</template> import type { PaperType } from '@/types'
import CourseViewExamItem from './CourseViewExamItem.vue'
import { getCourseExamList } from '../api'
const { query } = useRoute()
const courseId = $ref(query.course_id as string)
const semesterId = $ref(query.semester_id as string)
let list = $ref<PaperType[]>([])
function fetchList() {
getCourseExamList({ course_id: courseId, semester_id: semesterId }).then(res => {
list = res.data.items
})
}
onMounted(() => {
fetchList()
})
</script>
<template>
<div class="course-exam">
<CourseViewExamItem v-for="item in list" :data="item" :key="item.id"></CourseViewExamItem>
</div>
</template>
<script setup lang="ts">
import type { PaperType } from '@/types'
interface Props {
data: PaperType
}
defineProps<Props>()
</script>
<template>
<div class="course-exam-item" :key="data.id">
<p>
{{ data.paper_title }}
</p>
</div>
</template>
<style lang="scss" scoped>
.course-exam-item {
display: flex;
align-items: center;
height: 48px;
border-bottom: 1px solid #e6e6e6;
p {
flex: 1;
line-height: 48px;
}
&:hover {
color: var(--main-color);
}
.actions {
display: flex;
align-items: center;
justify-content: space-between;
}
}
</style>
<!-- 直播 --> <!-- 直播 -->
<script setup lang="ts"></script> <script setup lang="ts">
<template>直播</template> import type { LiveType } from '@/types'
import CourseViewLiveItem from './CourseViewLiveItem.vue'
import { getCourseLiveList } from '../api'
const { query } = useRoute()
const courseId = $ref(query.course_id as string)
const semesterId = $ref(query.semester_id as string)
let list = $ref<LiveType[]>([])
function fetchList() {
getCourseLiveList({ course_id: courseId, semester_id: semesterId }).then(res => {
list = res.data.items
})
}
onMounted(() => {
fetchList()
})
</script>
<template>
<div class="course-live">
<CourseViewLiveItem v-for="item in list" :data="item" :key="item.id"></CourseViewLiveItem>
</div>
</template>
<script setup lang="ts">
import type { LiveType } from '@/types'
import dayjs from 'dayjs'
import { VideoPlay } from '@element-plus/icons-vue'
interface Props {
data: LiveType
}
defineProps<Props>()
function formatDate(startTime: number) {
return dayjs(startTime * 1000).format('YYYY-MM-DD HH:mm')
}
</script>
<template>
<div class="course-live-item">
<p>
<a :href="data.join_url" target="_blank">
<el-icon><VideoPlay /></el-icon>
{{ data.subject }}
</a>
</p>
<div class="actions">{{ formatDate(data.start_time) }}</div>
</div>
</template>
<style lang="scss" scoped>
.course-live-item {
display: flex;
align-items: center;
height: 48px;
border-bottom: 1px solid #e6e6e6;
p {
flex: 1;
line-height: 48px;
}
&:hover {
color: var(--main-color);
}
.actions {
display: flex;
align-items: center;
justify-content: space-between;
}
}
</style>
...@@ -16,7 +16,10 @@ const form = reactive({ ...@@ -16,7 +16,10 @@ const form = reactive({
semester_id: semesterId, semester_id: semesterId,
title: '', title: '',
content: '', content: '',
attachments: [] attachments: [],
is_critiqued: 0,
reviews: '',
score: ''
}) })
const rules = ref<FormRules>({ const rules = ref<FormRules>({
...@@ -38,6 +41,7 @@ const update = () => { ...@@ -38,6 +41,7 @@ const update = () => {
} }
// 获取作业 // 获取作业
let workInfo = $ref('') let workInfo = $ref('')
function fetchInfo() { function fetchInfo() {
getCourseWork({ course_id: courseId, semester_id: semesterId }).then(res => { getCourseWork({ course_id: courseId, semester_id: semesterId }).then(res => {
const { detail, essay } = res.data const { detail, essay } = res.data
...@@ -52,6 +56,11 @@ onMounted(() => { ...@@ -52,6 +56,11 @@ onMounted(() => {
<template> <template>
<div class="course-work"> <div class="course-work">
<div class="course-work-item" v-if="form.is_critiqued">
<h2>作业评价</h2>
<h3>分数:{{ form.score }}</h3>
<div v-html="form.reviews"></div>
</div>
<div class="course-work-item"> <div class="course-work-item">
<h2>作业说明</h2> <h2>作业说明</h2>
<div v-html="workInfo"></div> <div v-html="workInfo"></div>
......
import type { CourseType, ChapterType, ResourceType } from '@/types' import type { CourseType, ChapterType, ResourceType } from '@/types'
export interface CourseListParamsTypes { export interface CourseListParamsType {
[key: string]: any [key: string]: any
elective_types: string[] elective_types: string[]
id: string id: string
semester_ids: string[] semester_ids: string[]
} }
export interface CourseListItemTypes extends CourseType { export interface CourseListItemType extends CourseType {
schedule: string schedule: string
section?: ChapterType section?: ChapterType
semester_id: string semester_id: string
...@@ -14,25 +14,25 @@ export interface CourseListItemTypes extends CourseType { ...@@ -14,25 +14,25 @@ export interface CourseListItemTypes extends CourseType {
} }
// 课程章类型 // 课程章类型
export interface CourseChapterTypes { export interface CourseChapterType {
id: string id: string
name: string name: string
resource_id: string resource_id: string
resource_type: number resource_type: number
sections: CourseSectionTypes[] sections: CourseSectionType[]
} }
// 课程节类型 // 课程节类型
export interface CourseSectionTypes { export interface CourseSectionType {
id: string id: string
name: string name: string
resource_id: string resource_id: string
resource_type: number resource_type: number
resources: CourseResourceTypes[] resources: CourseResourceType[]
} }
// 课程资源类型 // 课程资源类型
export interface CourseResourceTypes { export interface CourseResourceType {
id: string id: string
name: string name: string
resource_id: string resource_id: string
...@@ -41,7 +41,15 @@ export interface CourseResourceTypes { ...@@ -41,7 +41,15 @@ export interface CourseResourceTypes {
info: ResourceType info: ResourceType
} }
export interface PlayItemTypes { export interface VideoRecordType {
cumulative_playing_time: string
current_playing_time: number
max_playing_time: number
valid_playing_time: number
watchedTimePoint: number[]
}
export interface PlayItemType {
BitDepth: number BitDepth: number
Bitrate: string Bitrate: string
CreationTime: string CreationTime: string
......
<script setup lang="ts"> <script setup lang="ts">
import CourseListSearch from '../components/CourseListSearch.vue' import CourseListSearch from '../components/CourseListSearch.vue'
import CourseListItem from '../components/CourseListItem.vue' import CourseListItem from '../components/CourseListItem.vue'
import type { CourseListParamsTypes, CourseListItemTypes } from '../types' import type { CourseListParamsType, CourseListItemType } from '../types'
import { getCourseList } from '../api' import { getCourseList } from '../api'
// 列表参数 // 列表参数
const listParams = reactive<CourseListParamsTypes>({ id: '', semester_ids: [], elective_types: [] }) const listParams = reactive<CourseListParamsType>({ id: '', semester_ids: [], elective_types: [] })
let courseList = $ref<CourseListItemTypes[]>([]) let courseList = $ref<CourseListItemType[]>([])
// 获取课程列表 // 获取课程列表
function fetchList() { function fetchList() {
const params = Object.assign({}, listParams, { const params = Object.assign({}, listParams, {
...@@ -18,7 +18,7 @@ function fetchList() { ...@@ -18,7 +18,7 @@ function fetchList() {
}) })
} }
// 筛选 // 筛选
function handleSearch(params: CourseListParamsTypes) { function handleSearch(params: CourseListParamsType) {
Object.assign(listParams, params) Object.assign(listParams, params)
fetchList() fetchList()
} }
......
<script setup lang="ts"> <script setup lang="ts">
import { getCourseSection } from '../api' import type { CourseResourceType, CourseChapterType } from '../types'
import { getCourseSection, getChapterTreeList } from '../api'
import CoursePlayerResourceItem from '../components/CoursePlayerResourceItem.vue'
const CoursePlayerVideo = defineAsyncComponent(() => import('../components/CoursePlayerVideo.vue')) const CoursePlayerVideo = defineAsyncComponent(() => import('../components/CoursePlayerVideo.vue'))
const CoursePlayerChapter = defineAsyncComponent(() => import('../components/CoursePlayerChapter.vue')) const CoursePlayerChapter = defineAsyncComponent(() => import('../components/CoursePlayerChapter.vue'))
const { query } = useRoute() const route = useRoute()
const semesterId = $ref(query.semester_id as string) let courseId = $ref<string>('')
const sectionId = $ref(query.section_id as string) let sectionId = $ref<string>('')
const sourceId = $ref(query.source_id as string) let semesterId = $ref<string>('')
watchEffect(() => {
courseId = route.query.course_id as string
sectionId = route.query.section_id as string
semesterId = route.query.semester_id as string
})
const detail = reactive<{
course_name: string
coursewares: CourseResourceType[]
exams: CourseResourceType[]
jobs: CourseResourceType[]
lesson_plans: CourseResourceType[]
other_infos: CourseResourceType[]
}>({ course_name: '', coursewares: [], exams: [], jobs: [], lesson_plans: [], other_infos: [] })
let loading = $ref<boolean>(false)
// 获取详情信息
function fetchInfo() { function fetchInfo() {
if (!sectionId) return
loading = true
getCourseSection({ section_id: sectionId, semester_id: semesterId }).then(res => { getCourseSection({ section_id: sectionId, semester_id: semesterId }).then(res => {
console.log(res) Object.assign(detail, res.data)
loading = false
})
}
watchEffect(() => {
fetchInfo()
})
let chapterList = $ref<CourseChapterType[]>([])
// 获取章节列表
function fetchList() {
getChapterTreeList({ course_id: courseId, semester_id: semesterId }).then(res => {
chapterList = res.data.items
}) })
} }
onMounted(fetchInfo)
onMounted(() => {
fetchList()
})
</script> </script>
<template> <template>
<div class="course-player"> <div class="course-player">
<div class="course-player-main"> <div class="course-player-hd">
<CoursePlayerVideo v-if="sourceId" /> <h1 class="course-player-main__title">{{ detail.course_name }}</h1>
<el-tabs>
<el-tab-pane label="课件"> </el-tab-pane>
<el-tab-pane label="教案" lazy> </el-tab-pane>
<el-tab-pane label="作业" lazy> </el-tab-pane>
<el-tab-pane label="资料" lazy> </el-tab-pane>
<el-tab-pane label="考试/测验" lazy></el-tab-pane>
<el-tab-pane label="直播" lazy></el-tab-pane>
</el-tabs>
</div> </div>
<div class="course-player-aside"> <div class="course-player-bd">
<CoursePlayerChapter /> <div class="course-player-main" v-loading="loading">
<CoursePlayerVideo :chapterList="chapterList" :key="sectionId" />
<el-tabs>
<el-tab-pane label="课件">
<CoursePlayerResourceItem
v-for="item in detail.coursewares"
:data="item"
:key="item.id"
></CoursePlayerResourceItem>
</el-tab-pane>
<el-tab-pane label="教案" lazy>
<CoursePlayerResourceItem
v-for="item in detail.lesson_plans"
:data="item"
:key="item.id"
></CoursePlayerResourceItem>
</el-tab-pane>
<el-tab-pane label="作业" lazy>
<CoursePlayerResourceItem
v-for="item in detail.jobs"
:data="item"
:key="item.id"
></CoursePlayerResourceItem>
</el-tab-pane>
<el-tab-pane label="资料" lazy>
<CoursePlayerResourceItem
v-for="item in detail.other_infos"
:data="item"
:key="item.id"
></CoursePlayerResourceItem>
</el-tab-pane>
<el-tab-pane label="考试/测验" lazy>
<CoursePlayerResourceItem
v-for="item in detail.exams"
:data="item"
:key="item.id"
></CoursePlayerResourceItem>
</el-tab-pane>
<!-- <el-tab-pane label="直播" lazy></el-tab-pane> -->
</el-tabs>
</div>
<div class="course-player-aside">
<CoursePlayerChapter :chapterList="chapterList" />
</div>
</div> </div>
</div> </div>
</template> </template>
...@@ -38,8 +107,26 @@ onMounted(fetchInfo) ...@@ -38,8 +107,26 @@ onMounted(fetchInfo)
<style lang="scss" scoped> <style lang="scss" scoped>
.course-player { .course-player {
display: flex; display: flex;
flex-direction: column;
height: 100%;
padding: 20px;
background-color: #fff;
border-radius: 6px;
box-sizing: border-box;
}
.course-player-bd {
flex: 1;
display: flex;
} }
.course-player-main { .course-player-main {
flex: 1; flex: 1;
overflow: hidden;
}
.course-player-main__title {
margin-bottom: 10px;
font-size: 20px;
font-weight: 500;
line-height: 30px;
color: #333333;
} }
</style> </style>
...@@ -85,7 +85,7 @@ function downloadFile(data: CollectionType) { ...@@ -85,7 +85,7 @@ function downloadFile(data: CollectionType) {
<li class="collection-item" v-for="item in list" :key="item.id"> <li class="collection-item" v-for="item in list" :key="item.id">
<p> <p>
<router-link <router-link
:to="`/course/player?course_id=${item.course_id}&section_id=${item.section_id}&semester_id=${item.semester_id}&source_id=${item.info?.source_id}`" :to="`/course/player?course_id=${item.course_id}&chapter_id=${item.chapter_id}&section_id=${item.section_id}&resource_id=${item.source_id}&semester_id=${item.semester_id}`"
target="_blank" target="_blank"
> >
<ResourceIcon :resourceType="item.type" /> <ResourceIcon :resourceType="item.type" />
...@@ -137,8 +137,8 @@ function downloadFile(data: CollectionType) { ...@@ -137,8 +137,8 @@ function downloadFile(data: CollectionType) {
} }
.icon-download { .icon-download {
display: inline-block; display: inline-block;
width: 16px; width: 20px;
height: 16px; height: 20px;
cursor: pointer; cursor: pointer;
} }
</style> </style>
...@@ -102,9 +102,52 @@ export interface ResourceType { ...@@ -102,9 +102,52 @@ export interface ResourceType {
id: string id: string
knowledge_points: string knowledge_points: string
name: string name: string
type: string
pdf?: string pdf?: string
url?: string url?: string
cover?: string
size: number size: number
source_id: string source_id: string
paper_title?: string paper_title?: string
} }
// 直播类型
export interface LiveType {
end_time: number
format_status: number
id: string
join_url: string
meeting_code: string
meeting_id: string
meeting_type: number
start_time: number
status: number
sub_meetings: []
subject: string
}
// 试卷类型
export interface PaperType {
id: string
is_multiple_exams: 0 | 1
minimum_paper_handing_time: 0 | 1
multiple_test_score_rule: 0 | 1
paper_category: PaperCategoryType
paper_labels: string
paper_question_order: 0 | 1
paper_times: number
paper_title: string
paper_total_score: number
paper_type: number
paper_uses: number
pass_score: number
permission: number
project_prefix: string
}
// 试卷分类类型
export interface PaperCategoryType {
id: string
category_name: string
name: string
}
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论