first commit

This commit is contained in:
2026-08-26 14:25:17 +08:00
commit 06debfd1fc
10595 changed files with 1836924 additions and 0 deletions
@@ -0,0 +1,12 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<el-card shadow="never">
<div slot="header">课程介绍</div>
<el-empty description="课程介绍页面后续设计"></el-empty>
</el-card>
</div>
<!--#
}
#-->
@@ -0,0 +1,350 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
.course-display {
padding: 8px 10px 22px;
background: #f4f5f7;
min-height: calc(100vh - 88px);
}
.course-filter {
background: #fff;
border: 1px solid #e5e8ef;
margin-bottom: 28px;
}
.filter-row {
display: flex;
min-height: 70px;
border-bottom: 1px solid #e8ebf0;
}
.filter-row:last-child {
border-bottom: 0;
}
.filter-label {
width: 132px;
padding: 20px 16px;
background: #eef5ff;
color: #1f2d3d;
font-weight: 600;
text-align: center;
}
.filter-options {
flex: 1;
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: 10px 18px;
padding: 16px 18px;
}
.filter-chip {
min-width: 76px;
height: 32px;
padding: 0 18px;
border: 0;
border-radius: 9px;
background: transparent;
color: #526070;
cursor: pointer;
line-height: 32px;
text-align: center;
}
.filter-chip.active {
background: #1e63e9;
color: #fff;
font-weight: 600;
}
.course-result-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
}
.course-count {
color: #1f2d3d;
}
.course-count span {
color: #2468f2;
padding: 0 4px;
}
.course-search {
width: 310px;
}
.course-list {
background: #fff;
border-top: 1px solid #dfe4ec;
}
.course-item {
display: flex;
gap: 20px;
padding: 18px 0;
border-bottom: 1px solid #dfe4ec;
}
.course-cover {
position: relative;
width: 270px;
height: 150px;
flex: 0 0 270px;
background: #e9eef5;
border-radius: 4px;
overflow: hidden;
}
.course-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.course-cover-empty {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #8a97a8;
background: linear-gradient(135deg, #eaf0f8, #d8e3f2);
font-weight: 600;
}
.course-cover-tag {
position: absolute;
right: 0;
bottom: 12px;
min-width: 70px;
height: 24px;
padding: 0 10px;
background: #f5a400;
color: #fff;
font-size: 12px;
line-height: 24px;
text-align: center;
border-radius: 12px 0 0 12px;
}
.course-info {
flex: 1;
min-width: 0;
padding-right: 12px;
}
.course-title {
display: inline-block;
margin: 4px 0 8px;
color: #111827;
font-size: 18px;
font-weight: 700;
cursor: pointer;
}
.course-title:hover {
color: #006fc9;
}
.course-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
color: #8b96a6;
font-size: 13px;
margin-bottom: 12px;
}
.course-intro {
color: #4f5f73;
line-height: 1.8;
margin-bottom: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.course-time {
color: #8b96a6;
font-size: 13px;
}
.recommend-tags {
display: inline-flex;
gap: 6px;
margin-left: 8px;
vertical-align: 2px;
}
.course-empty {
background: #fff;
padding: 80px 0;
text-align: center;
color: #8b96a6;
border-top: 1px solid #dfe4ec;
}
</style>
<div id="app" class="course-display" v-cloak>
<div class="course-filter">
<div class="filter-row">
<div class="filter-label">推荐标识</div>
<div class="filter-options">
<button class="filter-chip" :class="{active: !query.recommendFlag}" @click="selectRecommend('')">全部</button>
<button
v-for="item in recommendOptions"
:key="item.code"
class="filter-chip"
:class="{active: query.recommendFlag === item.code}"
@click="selectRecommend(item.code)">
{{item.name}}
</button>
</div>
</div>
<div class="filter-row">
<div class="filter-label">课程类型</div>
<div class="filter-options">
<button class="filter-chip" :class="{active: !query.courseTypeId}" @click="selectCourseType('')">全部</button>
<button
v-for="item in courseTypeOptions"
:key="item.id"
class="filter-chip"
:class="{active: query.courseTypeId === item.id}"
@click="selectCourseType(item.id)">
{{item.typeName}}
</button>
</div>
</div>
</div>
<div class="course-result-bar">
<div class="course-count">为您找到相关课程<span>{{pageForm.totalCount || 0}}</span></div>
<el-input
class="course-search"
v-model="query.keyword"
clearable
placeholder="搜索关键字"
prefix-icon="el-icon-search"
@keyup.enter.native="doSearch"
@clear="doSearch">
<el-button slot="append" icon="el-icon-search" @click="doSearch"></el-button>
</el-input>
</div>
<div v-if="pageForm.list && pageForm.list.length" class="course-list">
<div v-for="course in pageForm.list" :key="course.id" class="course-item">
<div class="course-cover">
<img v-if="getCoverUrl(course.cover)" :src="getCoverUrl(course.cover)" :alt="course.courseName">
<div v-else class="course-cover-empty">{{course.courseTypeName || '课程'}}</div>
<div v-if="firstRecommendName(course.recommendFlags)" class="course-cover-tag">{{firstRecommendName(course.recommendFlags)}}</div>
</div>
<div class="course-info">
<div>
<span class="course-title" @click="openDetail(course)">{{course.courseName}}</span>
<span class="recommend-tags">
<el-tag v-for="code in splitFlags(course.recommendFlags)" :key="code" size="mini" type="warning">{{getRecommendName(code)}}</el-tag>
</span>
</div>
<div class="course-meta">
<span><i class="el-icon-office-building"></i> {{course.courseTypeName || '未设置课程类型'}}</span>
<span><i class="el-icon-user"></i> {{course.lecturerName || '未设置讲师'}}</span>
</div>
<div class="course-intro">{{course.courseIntro || '暂无课程介绍'}}</div>
<div class="course-time">
<i class="el-icon-time"></i>
<span v-if="course.openType === 'long_term'">长期开放</span>
<span v-else>{{course.startTimeText || '-'}} 至 {{course.endTimeText || '-'}}</span>
</div>
</div>
</div>
</div>
<div v-else class="course-empty">暂无相关课程</div>
<el-pagination
class="mt20"
background
layout="total, sizes, prev, pager, next"
:current-page.sync="pageForm.pageNumber"
:page-size.sync="pageForm.pageSize"
:total="pageForm.totalCount"
@size-change="pageData"
@current-change="pageData">
</el-pagination>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
query: {
keyword: "",
courseTypeId: "",
recommendFlag: ""
},
courseTypeOptions: [],
recommendOptions: [],
pageForm: {
pageNumber: 1,
pageSize: 10,
totalCount: 0,
list: []
}
}
},
methods: {
async pageData() {
const resp = await this.$axios.post(loc() + "/pageData", Object.assign({}, this.query, {
pageNumber: this.pageForm.pageNumber,
pageSize: this.pageForm.pageSize
}))
if (resp.code === 0) {
this.pageForm = resp.data
}
},
doSearch() {
this.pageForm.pageNumber = 1
this.pageData()
},
selectRecommend(code) {
this.query.recommendFlag = code
this.doSearch()
},
selectCourseType(id) {
this.query.courseTypeId = id
this.doSearch()
},
async loadOptions() {
const typeResp = await this.$axios.post(loc() + "/courseTypes")
if (typeResp.code === 0) {
this.courseTypeOptions = typeResp.data
}
const recommendResp = await this.$axios.post(loc() + "/recommendOptions")
if (recommendResp.code === 0) {
this.recommendOptions = recommendResp.data
}
},
getCoverUrl(cover) {
if (!cover) return ""
if (Array.isArray(cover)) {
return cover.length ? (cover[0].url || cover[0].response?.data || cover[0].data || "") : ""
}
if (typeof cover === "string" && cover.trim().startsWith("[")) {
try {
const files = JSON.parse(cover)
return files.length ? (files[0].url || files[0].response?.data || files[0].data || "") : ""
} catch (e) {
return ""
}
}
return cover
},
splitFlags(flags) {
return flags ? flags.split(",").filter(Boolean) : []
},
getRecommendName(code) {
const item = this.recommendOptions.find(v => v.code === code)
return item ? item.name : code
},
firstRecommendName(flags) {
const codes = this.splitFlags(flags)
return codes.length ? this.getRecommendName(codes[0]) : ""
},
openDetail(course) {
window.location.href = loc() + "/study?id=" + course.id
}
},
async created() {
await this.loadOptions()
await this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,213 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
[v-cloak] { display: none; }
#sub-app-container-main-content { padding: 0 !important; overflow: hidden; background: #f5f8fc; }
#sub-app-container-main-content-body { height: 100%; overflow: hidden; }
.learning-list-page { height: 100%; min-height: 0; overflow: hidden; color: #17233d; background: #f5f8fc; }
.learning-body { width: min(1180px, calc(100% - 24px)); height: 100%; min-height: 0; margin: 0 auto; padding: 12px 0 22px; display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 30px; box-sizing: border-box; }
.filter-panel { min-height: 0; overflow: hidden; display: flex; flex-direction: column; gap: 16px; }
.filter-group { padding: 16px; flex: none; background: #fff; border-radius: 9px; box-shadow: 0 6px 22px rgba(31, 70, 121, .08); }
.filter-group + .filter-group { margin-top: 0; }
.filter-title { margin: 2px 0 10px; padding-left: 9px; position: relative; color: #24344e; font-size: 14px; font-weight: 700; line-height: 20px; }
.filter-title::before { content: ""; width: 3px; height: 14px; position: absolute; left: 0; top: 3px; border-radius: 2px; background: #1677ff; }
.filter-option { height: 34px; padding: 0 10px; display: flex; align-items: center; color: #45536a; border-radius: 6px; cursor: pointer; font-size: 14px; transition: .2s; }
.filter-option-icon { width: 18px; margin-right: 10px; color: #6e7d94; font-size: 16px; text-align: center; }
.filter-option:hover { color: #1677ff; background: #edf5ff; }
.filter-option.active { color: #1677ff; background: #eaf3ff; font-weight: 600; }
.filter-option.active .filter-option-icon { color: #1677ff; }
.course-content { min-width: 0; min-height: 0; overflow-y: auto; padding: 0 10px 6px 0; scrollbar-gutter: stable; }
.course-content::-webkit-scrollbar { width: 7px; }
.course-content::-webkit-scrollbar-thumb { border-radius: 5px; background: #d6e1ef; }
.course-toolbar { height: 38px; margin-bottom: 10px; position: sticky; top: 0; z-index: 2; display: flex; align-items: flex-start; justify-content: space-between; color: #4e5d74; font-size: 14px; background: #f5f8fc; }
.course-toolbar > span { padding-top: 8px; font-size: 15px; font-weight: 600; }
.course-toolbar .el-select { width: 122px; }
.course-toolbar .el-input__inner { border-color: #e2e9f2; border-radius: 6px; background: #fff; color: #53627a; }
.course-list { display: flex; flex-direction: column; gap: 16px; }
.course-card { min-height: 160px; display: grid; grid-template-columns: 236px minmax(0, 1fr) 112px; overflow: hidden; background: #fff; border: 0; border-radius: 10px; box-shadow: 0 5px 18px rgba(30, 66, 115, .08); transition: box-shadow .2s, transform .2s; }
.course-card:hover { transform: translateY(-1px); box-shadow: 0 9px 24px rgba(30, 67, 119, .12); }
.course-cover { position: relative; min-height: 160px; overflow: hidden; background: #eaf5ee; }
.course-cover img { width: 100%; height: 100%; display: block; object-fit: cover; }
.course-cover-empty { width: 100%; height: 100%; min-height: 160px; display: flex; align-items: center; justify-content: center; color: #77a28a; font-size: 18px; background: linear-gradient(135deg, #e5f5ea, #f3faf6); }
.course-cover-empty i { margin-right: 8px; font-size: 32px; }
.course-card:nth-child(3n+2) .course-cover-empty { color: #7188ae; background: linear-gradient(135deg, #e7effc, #f3f7fe); }
.course-card:nth-child(3n) .course-cover-empty { color: #bc8768; background: linear-gradient(135deg, #fff0e7, #fff8f3); }
.course-info { min-width: 0; padding: 18px 18px 14px 22px; box-sizing: border-box; }
.course-heading { display: flex; align-items: center; gap: 8px; min-width: 0; }
.course-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #17243b; font-size: 19px; font-weight: 700; cursor: pointer; }
.course-type { flex: none; color: #98a2b3; font-size: 13px; }
.recommend-tag { flex: none; padding: 2px 5px; color: #ff4d4f; background: #fff1f0; border-radius: 3px; font-size: 12px; }
.course-intro { height: 43px; margin-top: 7px; overflow: hidden; color: #59677c; font-size: 14px; line-height: 22px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.course-meta { margin-top: 11px; display: flex; align-items: center; flex-wrap: wrap; gap: 18px; color: #91a0b7; font-size: 13px; }
.course-meta span { display: inline-flex; align-items: center; gap: 4px; }
.course-meta i { font-size: 14px; }
.course-action { display: flex; align-items: center; justify-content: center; padding-right: 18px; }
.course-action .el-button { width: 84px; border-radius: 18px; color: #1677ff; border-color: #a9d0ff; background: #f5faff; font-weight: 600; }
.course-empty { height: 280px; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #9aa7ba; background: #fff; border: 1px solid #e7e9ed; border-radius: 12px; }
.course-empty i { margin-bottom: 12px; font-size: 45px; }
.course-pagination { margin: 24px 0 4px; text-align: right; }
@media (max-width: 980px) { .learning-body { grid-template-columns: 180px minmax(0, 1fr); gap: 18px; } .course-card { grid-template-columns: 190px minmax(0, 1fr) 96px; } }
@media (max-width: 820px) { #sub-app-container-main-content, #sub-app-container-main-content-body { overflow-y: auto; } .learning-list-page { height: auto; min-height: 100%; overflow: visible; } .learning-body { height: auto; min-height: auto; grid-template-columns: 1fr; padding-bottom: 24px; overflow: visible; } .filter-panel { display: flex; flex-direction: row; gap: 12px; overflow-x: auto; } .filter-group { min-width: 180px; } .course-content { overflow: visible; padding-right: 0; } .course-toolbar { position: static; } .course-card { grid-template-columns: 170px minmax(0, 1fr); } .course-action { grid-column: 2; justify-content: flex-end; padding: 0 16px 14px; } }
</style>
<div id="app" class="learning-list-page" v-cloak>
<div class="learning-body">
<aside class="filter-panel">
<div class="filter-group">
<div class="filter-title">课程分类</div>
<div class="filter-option" :class="{active: !query.courseTypeId}" @click="selectCourseType('')"><i class="filter-option-icon el-icon-menu"></i>全部</div>
<div v-for="item in courseTypeOptions" :key="item.id" class="filter-option" :class="{active: query.courseTypeId === item.id}" @click="selectCourseType(item.id)"><i class="filter-option-icon el-icon-collection-tag"></i>{{item.typeName}}</div>
</div>
<div v-if="recommendOptions.length" class="filter-group">
<div class="filter-title">推荐类型</div>
<div class="filter-option" :class="{active: !query.recommendFlag}" @click="selectRecommend('')"><i class="filter-option-icon el-icon-star-on"></i>全部</div>
<div v-for="item in recommendOptions" :key="item.code" class="filter-option" :class="{active: query.recommendFlag === item.code}" @click="selectRecommend(item.code)"><i class="filter-option-icon el-icon-star-off"></i>{{item.name}}</div>
</div>
</aside>
<main class="course-content" v-loading="loading">
<div class="course-toolbar">
<span>共 {{pageForm.totalCount || 0}} 门课程</span>
<div>
<span style="margin-right: 8px">排序:</span>
<el-select v-model="query.sortType" size="small" @change="doSearch">
<el-option label="综合排序" value="default"></el-option>
<el-option label="观看最多" value="view_count"></el-option>
</el-select>
</div>
</div>
<div v-if="pageForm.list && pageForm.list.length" class="course-list">
<article v-for="course in pageForm.list" :key="course.id" class="course-card">
<div class="course-cover">
<img v-if="getCoverUrl(course.cover)" :src="getCoverUrl(course.cover)" :alt="course.courseName">
<div v-else class="course-cover-empty"><i class="el-icon-reading"></i>{{course.courseTypeName || '在线课程'}}</div>
</div>
<div class="course-info">
<div class="course-heading">
<span class="course-name" @click="startStudy(course)">{{course.courseName}}</span>
<span class="course-type">{{course.courseTypeName || '未分类'}}</span>
<span v-if="firstRecommendName(course.recommendFlags)" class="recommend-tag">{{firstRecommendName(course.recommendFlags)}}</span>
</div>
<div class="course-intro">{{course.courseIntro || '暂无课程介绍'}}</div>
<div class="course-meta">
<span><i class="el-icon-collection"></i>{{course.resourceCount || 0}} 课节</span>
<span><i class="el-icon-time"></i>{{formatDuration(course.totalDurationSeconds)}}</span>
<span><i class="el-icon-user"></i>{{course.lecturerName || '未设置讲师'}}</span>
<span><i class="el-icon-view"></i>{{course.viewCount || 0}}</span>
</div>
</div>
<div class="course-action">
<el-button size="small" type="primary" plain @click="startStudy(course)">开始学习</el-button>
</div>
</article>
</div>
<div v-else-if="!loading" class="course-empty"><i class="el-icon-reading"></i><span>暂无相关课程</span></div>
<el-pagination
v-if="pageForm.totalCount > pageForm.pageSize"
class="course-pagination"
background
layout="total, prev, pager, next"
:current-page.sync="pageForm.pageNumber"
:page-size="pageForm.pageSize"
:total="pageForm.totalCount"
@current-change="pageData">
</el-pagination>
</main>
</div>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
loading: false,
query: { keyword: "", courseTypeId: "", recommendFlag: "", sortType: "default" },
courseTypeOptions: [],
recommendOptions: [],
pageForm: { pageNumber: 1, pageSize: 10, totalCount: 0, list: [] }
}
},
methods: {
pageData() {
this.loading = true
this.$axios.post(loc() + "/pageData", Object.assign({}, this.query, {
pageNumber: this.pageForm.pageNumber,
pageSize: this.pageForm.pageSize
})).then((resp) => {
if (resp.code === 0) {
this.pageForm = resp.data
} else {
this.$message.warning(resp.msg)
}
}).finally(() => {
this.loading = false
})
},
loadOptions() {
return Promise.all([
this.$axios.post(loc() + "/courseTypes"),
this.$axios.post(loc() + "/recommendOptions")
]).then((responses) => {
this.courseTypeOptions = responses[0].code === 0 ? responses[0].data : []
this.recommendOptions = responses[1].code === 0 ? responses[1].data : []
})
},
doSearch() {
this.pageForm.pageNumber = 1
this.pageData()
},
selectCourseType(id) {
this.query.courseTypeId = id
this.doSearch()
},
selectRecommend(code) {
this.query.recommendFlag = code
this.doSearch()
},
getCoverUrl(cover) {
if (!cover) return ""
if (Array.isArray(cover)) return cover.length ? (cover[0].url || cover[0].data || "") : ""
if (typeof cover === "string" && cover.trim().startsWith("[")) {
try {
const files = JSON.parse(cover)
return files.length ? (files[0].url || files[0].data || "") : ""
} catch (e) {
return ""
}
}
return cover
},
splitFlags(flags) {
return flags ? flags.split(",").filter(Boolean) : []
},
firstRecommendName(flags) {
const codes = this.splitFlags(flags)
if (!codes.length) return ""
const item = this.recommendOptions.find((option) => option.code === codes[0])
return item ? item.name : codes[0]
},
formatDuration(seconds) {
const total = Number(seconds) || 0
if (total < 3600) return Math.max(1, Math.ceil(total / 60)) + " 分钟"
const hours = Math.floor(total / 3600)
const minutes = Math.ceil((total % 3600) / 60)
return minutes ? hours + "小时" + minutes + "分钟" : hours + "小时"
},
startStudy(course) {
window.location.href = loc() + "/study?id=" + encodeURIComponent(course.id || "")
}
},
created() {
this.loadOptions().then(() => {
this.pageData()
})
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,755 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
#sidebar-menu,
#menu-toggle-btn,
#menu-overlay {
display: none !important;
}
#sub-app-container-main-content {
padding: 0 !important;
}
#sub-app-container-main-content-body {
height: 100%;
}
.study-page {
min-height: calc(100vh - 50px);
background: #eef3f8;
}
.study-header {
height: 150px;
margin: 12px 14px 0;
padding: 20px 24px;
color: #1f2937;
background: linear-gradient(120deg, #ffffff 0%, #f4f8ff 58%, #eaf2ff 100%);
border: 1px solid #dbe7f5;
border-radius: 10px;
box-shadow: 0 6px 18px rgba(31, 64, 116, .08);
display: flex;
align-items: center;
gap: 22px;
box-sizing: border-box;
}
.study-back {
align-self: flex-start;
margin-left: auto;
order: 5;
color: #1e63c8;
border-color: #b8d0f2;
background: #fff;
}
.study-cover {
width: 190px;
height: 102px;
object-fit: cover;
background: #eef4fb;
border: 1px solid #d7e3f1;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(31, 64, 116, .12);
}
.study-cover-empty {
width: 190px;
height: 102px;
display: flex;
align-items: center;
justify-content: center;
background: #eef4fb;
color: #6b7d90;
border: 1px solid #d7e3f1;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(31, 64, 116, .12);
font-weight: 600;
}
.study-course-main {
flex: 1;
min-width: 0;
}
.study-title {
font-size: 28px;
line-height: 1.2;
font-weight: 700;
margin-bottom: 14px;
}
.study-title .el-tag {
margin-left: 8px;
vertical-align: 5px;
}
.study-meta {
display: flex;
flex-wrap: wrap;
gap: 10px 26px;
color: #526376;
font-size: 14px;
}
.study-body {
display: flex;
gap: 14px;
height: calc(100vh - 212px);
padding: 14px;
box-sizing: border-box;
}
.study-tree-panel {
width: 340px;
background: #fff;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.study-tree-title {
height: 54px;
padding: 0 18px;
display: flex;
align-items: center;
justify-content: space-between;
color: #1768e5;
font-size: 18px;
font-weight: 700;
border-bottom: 1px solid #edf1f7;
}
.study-tree {
flex: 1;
overflow: auto;
padding: 10px;
}
.study-node {
display: inline-flex;
align-items: center;
max-width: 100%;
gap: 6px;
}
.study-node-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.study-content {
flex: 1;
min-width: 0;
background: #fff;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.resource-head {
min-height: 54px;
padding: 0 18px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #edf1f7;
}
.resource-title {
color: #1f2937;
font-size: 16px;
font-weight: 600;
}
.resource-actions {
display: flex;
align-items: center;
gap: 10px;
}
.study-progress {
width: 180px;
}
.resource-view {
flex: 1;
overflow: auto;
padding: 20px;
background: #f8fafc;
}
.media-player {
width: 100%;
max-height: calc(100vh - 300px);
background: #000;
}
.audio-wrap {
display: flex;
align-items: center;
justify-content: center;
height: 320px;
background: linear-gradient(135deg, #132544, #0a1224);
border-radius: 6px;
}
.audio-wrap audio {
width: 80%;
}
.image-preview {
max-width: 100%;
display: block;
margin: 0 auto;
background: #fff;
}
.doc-frame {
width: 100%;
height: calc(100vh - 275px);
border: 0;
background: #fff;
}
.file-fallback {
padding: 60px 20px;
text-align: center;
color: #667085;
background: #fff;
border-radius: 6px;
}
</style>
<div id="app" class="study-page" v-cloak>
<div class="study-header">
<el-button class="study-back" icon="el-icon-back" size="small" @click="goBack">返回</el-button>
<img v-if="coverUrl" class="study-cover" :src="coverUrl" :alt="course.courseName">
<div v-else class="study-cover-empty">课程图片</div>
<div class="study-course-main">
<div class="study-title">
{{course.courseName || '课程学习'}}
<el-tag v-for="code in splitFlags(course.recommendFlags)" :key="code" size="small" type="warning">{{getRecommendName(code)}}</el-tag>
</div>
<div class="study-meta">
<span><i class="el-icon-user"></i> 授课讲师:{{course.lecturerName || '未设置'}}</span>
<span><i class="el-icon-date"></i> {{course.openType === 'long_term' ? '长期开放' : ((course.startTimeText || '-') + ' 至 ' + (course.endTimeText || '-'))}}</span>
<span><i class="el-icon-collection-tag"></i> 课程类型:{{course.courseTypeName || '未设置'}}</span>
</div>
</div>
</div>
<div class="study-body">
<div class="study-tree-panel">
<div class="study-tree-title">
<span>课程安排</span>
<el-button type="text" size="mini" @click="collapseAll">收起</el-button>
</div>
<div class="study-tree">
<el-tree
ref="studyTree"
:data="treeData"
node-key="id"
default-expand-all
highlight-current
:expand-on-click-node="false"
:props="{children:'children', label:'title'}"
@node-click="nodeClick">
<span slot-scope="{ data }" class="study-node">
<i :class="getNodeIcon(data)"></i>
<span class="study-node-title">{{data.title}}</span>
<el-tag v-if="data.required" size="mini" type="warning">必学</el-tag>
</span>
</el-tree>
</div>
</div>
<div class="study-content">
<div class="resource-head">
<div class="resource-title">{{selectedResource.title || '请选择课程资料'}}</div>
<div class="resource-actions">
<el-progress
v-if="selectedResource.id"
class="study-progress"
:percentage="recordProgress"
:stroke-width="8">
</el-progress>
<el-tag v-if="studying" size="mini" type="success">学习中 {{recordStudyTime}}</el-tag>
<el-button v-if="selectedResource.id && !studying" size="mini" type="primary" icon="el-icon-video-play" @click="startStudy">开始学习</el-button>
<el-button v-if="studying" size="mini" type="danger" icon="el-icon-video-pause" @click="finishStudy(false)">结束学习</el-button>
<el-button v-if="fileUrl" size="mini" type="text" icon="el-icon-download" @click="openFile">打开原文件</el-button>
</div>
</div>
<div class="resource-view">
<template v-if="selectedResource.id">
<video
v-if="selectedResource.resourceType === 'video'"
ref="mediaPlayer"
class="media-player"
:src="fileUrl"
controls
controlslist="nodownload"
@play="mediaPlay"
@ended="mediaEnded"
@timeupdate="saveProgress"
@loadedmetadata="mediaReady">
</video>
<div v-else-if="selectedResource.resourceType === 'audio'" class="audio-wrap">
<audio
ref="mediaPlayer"
:src="fileUrl"
controls
controlslist="nodownload"
@play="mediaPlay"
@ended="mediaEnded"
@timeupdate="saveProgress"
@loadedmetadata="mediaReady">
</audio>
</div>
<img v-else-if="selectedResource.resourceType === 'image'" class="image-preview" :src="fileUrl" :alt="selectedResource.title">
<iframe v-else-if="canInlinePreview(selectedResource)" class="doc-frame" :src="inlinePreviewUrl"></iframe>
<div v-else class="file-fallback">
<file-preview :files="selectedResource.fileData" complete_result></file-preview>
<el-button class="mt20" type="primary" icon="el-icon-view" @click="openFile">打开资料</el-button>
</div>
</template>
<el-empty v-else description="请选择左侧课程资料"></el-empty>
</div>
</div>
</div>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
courseId: "",
apiBase: "/platform/learning/course/display",
recordApi: "/platform/learning/study/record",
course: {},
treeData: [],
selectedResource: {},
recommendOptions: [],
studying: false,
currentSegmentId: "",
recordProgress: 0,
recordStudyTime: "0秒",
pendingSeconds: 0,
heartbeatTimer: null,
startingStudy: false,
autoResumeKey: "",
lastPositionSyncAt: 0,
lastActiveAt: Date.now(),
inactiveLimit: 60000
}
},
computed: {
coverUrl() {
return this.getFileUrl(this.course.cover)
},
fileUrl() {
return this.getFileUrl(this.selectedResource.fileData)
},
inlinePreviewUrl() {
const id = this.getFileId(this.selectedResource.fileData)
return id ? this.apiBase + "/pdfPreview?id=" + encodeURIComponent(id) : this.fileUrl
}
},
methods: {
getQuery(name) {
return new URLSearchParams(window.location.search).get(name) || ""
},
async loadCourse() {
const resp = await this.$axios.post(this.apiBase + "/courseInfo", { id: this.courseId })
if (resp.code === 0) {
this.course = resp.data
} else {
this.$message.warning(resp.msg)
}
},
async loadTree() {
const resp = await this.$axios.post(this.apiBase + "/studyTree", { courseId: this.courseId })
if (resp.code === 0) {
this.treeData = resp.data || []
const first = this.findFirstResource(this.treeData)
if (first) {
this.nodeClick(first)
this.$nextTick(() => this.$refs.studyTree && this.$refs.studyTree.setCurrentKey(first.id))
}
}
},
async loadRecommendOptions() {
const resp = await this.$axios.post(this.apiBase + "/recommendOptions")
if (resp.code === 0) {
this.recommendOptions = resp.data
}
},
findFirstResource(nodes) {
for (const node of nodes || []) {
if (node.type === "resource") return node
const child = this.findFirstResource(node.children || [])
if (child) return child
}
return null
},
nodeClick(data) {
if (data.type !== "resource") return
if (this.studying && this.selectedResource.id !== data.id) {
this.$message.warning("请先结束当前章节资料的学习")
return
}
this.selectedResource = data
this.recordProgress = data.progressPercent || 0
this.recordStudyTime = this.formatSeconds(Number(data.studySeconds || 0))
},
getNodeIcon(data) {
if (data.type === "resource") {
const map = {
video: "el-icon-video-camera",
audio: "el-icon-headset",
image: "el-icon-picture-outline",
pdf: "el-icon-document",
word: "el-icon-document",
ppt: "el-icon-document"
}
return map[data.resourceType] || "el-icon-paperclip"
}
return data.nodeType === "chapter" ? "el-icon-folder" : "el-icon-notebook-2"
},
getFileUrl(value) {
if (!value) return ""
if (Array.isArray(value)) {
return value.length ? (value[0].url || value[0].response?.data || value[0].data || "") : ""
}
if (typeof value === "string" && value.trim().startsWith("[")) {
try {
const files = JSON.parse(value)
return files.length ? (files[0].url || files[0].response?.data || files[0].data || "") : ""
} catch (e) {
return ""
}
}
return value
},
getFileId(value) {
const url = this.getFileUrl(value)
if (!url) return ""
const matched = url.match(/[?&]id=([^&]+)/)
if (matched) return decodeURIComponent(matched[1])
if (!url.includes("/") && !url.includes(".")) return url
return ""
},
canInlinePreview(resource) {
const type = resource.resourceType
const ext = (resource.fileExt || "").toLowerCase()
return ["pdf", "ppt", "word"].includes(type) || ["pdf", "ppt", "pptx", "doc", "docx"].includes(ext)
},
progressKey() {
return "learning-progress-" + this.courseId + "-" + this.selectedResource.id
},
mediaReady() {
this.askResume()
},
askResume() {
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return
if (this.studying || this.startingStudy) return
const currentKey = this.progressKey()
if (this.autoResumeKey === currentKey) return
this.autoResumeKey = currentKey
const player = this.$refs.mediaPlayer
const localSaved = Number(localStorage.getItem(this.progressKey()) || 0)
const serverSaved = Number(this.selectedResource.lastPositionSeconds || 0)
const saved = Math.max(localSaved, serverSaved)
if (!player || !saved || saved < 5 || saved >= player.duration - 5) return
this.$confirm("检测到上次学习到 " + this.formatSeconds(saved) + ",请选择继续学习或从头开始。", "继续学习", {
confirmButtonText: "继续学习",
cancelButtonText: "从头开始",
type: "info"
}).then(() => {
this.startStudy({ autoPlay: true, seekTo: saved })
}).catch(() => {
localStorage.removeItem(this.progressKey())
this.saveServerPosition(0)
this.startStudy({ autoPlay: true, seekTo: 0 })
})
},
async mediaPlay() {
if (this.studying || this.startingStudy) return
await this.startStudy({ fromMedia: true })
},
mediaEnded() {
this.finishStudy(false, { silent: true })
localStorage.removeItem(this.progressKey())
},
async playSelectedMedia(seekTo) {
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return
await this.$nextTick()
const player = this.$refs.mediaPlayer
if (!player) return
try {
if (typeof seekTo === "number" && !Number.isNaN(seekTo)) {
await this.seekBeforePlay(player, seekTo)
}
const playPromise = player.play && player.play()
if (playPromise && playPromise.catch) {
await playPromise.catch(() => {
this.$message.warning("浏览器阻止了自动播放,请点击播放器播放按钮继续")
})
}
} catch (e) {
this.$message.warning("播放器定位失败,请手动点击播放后继续")
}
},
async seekBeforePlay(player, seekTo) {
await this.waitForMediaReady(player)
const target = this.normalizeSeekTarget(player, seekTo)
if (target === null) return
if (Math.abs((player.currentTime || 0) - target) <= 1) return
player.pause()
player.currentTime = target
await this.waitForSeek(player, target, 1800)
if (Math.abs((player.currentTime || 0) - target) > 1) {
player.currentTime = target
await this.waitForSeek(player, target, 1200)
}
},
waitForMediaReady(player) {
if (player.readyState >= 1 && !Number.isNaN(player.duration)) {
return Promise.resolve()
}
return new Promise(resolve => {
const done = () => {
player.removeEventListener("loadedmetadata", done)
player.removeEventListener("durationchange", done)
resolve()
}
player.addEventListener("loadedmetadata", done, { once: true })
player.addEventListener("durationchange", done, { once: true })
setTimeout(done, 2000)
})
},
waitForSeek(player, target, timeout) {
return new Promise(resolve => {
const done = () => {
player.removeEventListener("seeked", done)
player.removeEventListener("timeupdate", done)
resolve()
}
player.addEventListener("seeked", done, { once: true })
player.addEventListener("timeupdate", done, { once: true })
setTimeout(done, timeout)
})
},
normalizeSeekTarget(player, seekTo) {
const raw = Math.max(0, Number(seekTo || 0))
if (Number.isNaN(raw)) return null
if (!Number.isNaN(player.duration) && player.duration > 0) {
return Math.min(raw, Math.max(0, player.duration - 1))
}
return raw
},
saveProgress() {
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return
const player = this.$refs.mediaPlayer
if (player && player.currentTime > 0) {
const position = Math.floor(player.currentTime)
localStorage.setItem(this.progressKey(), String(position))
this.$set(this.selectedResource, "lastPositionSeconds", position)
if (Date.now() - this.lastPositionSyncAt > 5000) {
this.saveServerPosition(position)
}
}
},
saveServerPosition(position, useBeacon) {
if (!this.selectedResource.id || !["video", "audio"].includes(this.selectedResource.resourceType)) return
const seconds = Math.max(0, Math.floor(Number(position || 0)))
this.lastPositionSyncAt = Date.now()
if (useBeacon && navigator.sendBeacon) {
const body = new URLSearchParams()
body.append("courseId", this.courseId)
body.append("resourceId", this.selectedResource.id)
body.append("positionSeconds", String(seconds))
const blob = new Blob([body.toString()], { type: "application/x-www-form-urlencoded;charset=UTF-8" })
navigator.sendBeacon(this.recordApi + "/position", blob)
return
}
this.$axios.post(this.recordApi + "/position", {
courseId: this.courseId,
resourceId: this.selectedResource.id,
positionSeconds: seconds
})
},
async startStudy(options) {
const config = options && !options.target ? options : {}
if (!this.selectedResource.id) {
this.$message.warning("请选择左侧课程资料")
return
}
if (this.studying) {
if (config.autoPlay) {
this.playSelectedMedia(config.seekTo)
}
return
}
this.startingStudy = true
let resp
try {
resp = await this.$axios.post(this.recordApi + "/start", {
courseId: this.courseId,
resourceId: this.selectedResource.id,
positionSeconds: typeof config.seekTo === "number" ? Math.floor(config.seekTo) : this.getMediaPosition()
})
} finally {
this.startingStudy = false
}
if (resp.code !== 0) {
this.$message.warning(resp.msg)
return
}
this.currentSegmentId = resp.data.segmentId
this.studying = true
this.pendingSeconds = 0
this.lastActiveAt = Date.now()
this.applyRecordState(resp.data)
this.startHeartbeatTimer()
if (config.autoPlay !== false && !config.fromMedia) {
this.playSelectedMedia(config.seekTo)
}
this.$message.success("已开始学习")
},
async heartbeat() {
if (!this.studying || !this.currentSegmentId || this.pendingSeconds <= 0) return
const seconds = this.pendingSeconds
this.pendingSeconds = 0
const resp = await this.$axios.post(this.recordApi + "/heartbeat", {
segmentId: this.currentSegmentId,
activeSeconds: seconds,
positionSeconds: this.getMediaPosition()
})
if (resp.code === 0) {
this.applyRecordState(resp.data)
} else {
this.stopHeartbeatTimer()
this.studying = false
this.currentSegmentId = ""
this.$message.warning(resp.msg)
}
},
async finishStudy(force, options) {
const config = options || {}
if (!this.currentSegmentId) return
const segmentId = this.currentSegmentId
const seconds = this.pendingSeconds
this.pendingSeconds = 0
this.stopHeartbeatTimer()
this.studying = false
this.currentSegmentId = ""
if (!force) {
this.pauseSelectedMedia()
}
if (force && navigator.sendBeacon) {
this.saveServerPosition(this.getMediaPosition(), true)
const formData = new FormData()
formData.append("segmentId", segmentId)
formData.append("activeSeconds", String(seconds))
formData.append("positionSeconds", String(this.getMediaPosition()))
navigator.sendBeacon(this.recordApi + "/finish", formData)
return
}
const resp = await this.$axios.post(this.recordApi + "/finish", {
segmentId: segmentId,
activeSeconds: seconds,
positionSeconds: this.getMediaPosition()
})
if (resp.code === 0 && resp.data) {
this.applyRecordState(resp.data)
if (!config.silent) {
this.$message.success("学习已结束")
}
}
},
pauseSelectedMedia() {
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return
const player = this.$refs.mediaPlayer
if (player && !player.paused) {
player.pause()
}
},
startHeartbeatTimer() {
this.stopHeartbeatTimer()
this.heartbeatTimer = setInterval(() => {
if (!this.studying) return
if (this.isEffectiveLearning()) {
this.pendingSeconds += 1
}
if (this.pendingSeconds >= 15) {
this.heartbeat()
}
}, 1000)
},
stopHeartbeatTimer() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
},
isEffectiveLearning() {
if (document.hidden) return false
if (Date.now() - this.lastActiveAt > this.inactiveLimit) return false
if (["video", "audio"].includes(this.selectedResource.resourceType)) {
const player = this.$refs.mediaPlayer
return !!player && !player.paused && !player.ended
}
return true
},
markActive() {
this.lastActiveAt = Date.now()
},
applyRecordState(data) {
this.recordProgress = Number(data.progressPercent || 0)
this.recordStudyTime = data.studyTimeText || this.formatSeconds(Number(data.studySeconds || 0))
if (this.selectedResource.id && data.lastPositionSeconds !== undefined) {
this.$set(this.selectedResource, "lastPositionSeconds", Number(data.lastPositionSeconds || 0))
}
},
getMediaPosition() {
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return 0
const player = this.$refs.mediaPlayer
return player ? Math.floor(player.currentTime || 0) : Number(this.selectedResource.lastPositionSeconds || 0)
},
formatSeconds(seconds) {
const hour = Math.floor(seconds / 3600)
const minute = Math.floor(seconds % 3600 / 60)
const second = Math.floor(seconds % 60)
if (hour > 0) return hour + "小时" + minute + "分" + second + "秒"
if (minute > 0) return minute + "分" + second + "秒"
return second + "秒"
},
splitFlags(flags) {
return flags ? flags.split(",").filter(Boolean) : []
},
getRecommendName(code) {
const item = this.recommendOptions.find(v => v.code === code)
return item ? item.name : code
},
openFile() {
if (this.fileUrl) window.open(this.fileUrl)
},
collapseAll() {
const nodesMap = this.$refs.studyTree && this.$refs.studyTree.store.nodesMap
Object.keys(nodesMap || {}).forEach(key => {
nodesMap[key].expanded = false
})
},
async goBack() {
this.saveServerPosition(this.getMediaPosition())
await this.finishStudy(false, { silent: true })
window.location.href = this.apiBase
}
},
async created() {
this.courseId = this.getQuery("id")
window.addEventListener("mousemove", this.markActive)
window.addEventListener("keydown", this.markActive)
window.addEventListener("click", this.markActive)
window.addEventListener("scroll", this.markActive, true)
window.addEventListener("beforeunload", () => this.finishStudy(true))
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
this.saveServerPosition(this.getMediaPosition(), true)
}
})
await this.loadRecommendOptions()
await this.loadCourse()
await this.loadTree()
},
beforeDestroy() {
this.finishStudy(true)
this.stopHeartbeatTimer()
window.removeEventListener("mousemove", this.markActive)
window.removeEventListener("keydown", this.markActive)
window.removeEventListener("click", this.markActive)
window.removeEventListener("scroll", this.markActive, true)
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,251 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
[v-cloak] { display: none; }
#sidebar-menu, #menu-toggle-btn, #menu-overlay { display: none !important; }
#sub-app-container-main-content { height: calc(100vh - 64px); padding: 0 !important; overflow: hidden; background: #f6f8fb; }
#sub-app-container-main-content-body { height: 100%; overflow: hidden; }
.study-v2 { height: 100%; min-height: 0; display: flex; flex-direction: column; overflow: hidden; color: #17233d; background: #f6f8fb; }
.study-topbar { height: 56px; flex: none; background: #fff; border-bottom: 1px solid #e7eaf0; }
.study-topbar-inner { width: 1124px; height: 100%; margin: 0 auto; display: flex; align-items: center; }
.back-link { color: #65728a; cursor: pointer; font-size: 13px; }
.breadcrumb-course { margin-left: 16px; padding-left: 16px; border-left: 1px solid #dce1e8; font-size: 13px; }
.breadcrumb-course strong { color: #111d34; }
.top-progress { width: 166px; margin-left: auto; display: flex; align-items: center; gap: 9px; color: #56637a; font-size: 12px; }
.top-progress .el-progress { flex: 1; }
.top-progress-value { color: #1769ff; }
.study-layout { width: 1124px; min-height: 0; flex: 1; margin: 0 auto; padding: 18px 0; display: grid; grid-template-columns: 230px 875px; gap: 19px; overflow: hidden; box-sizing: border-box; }
.study-catalog { max-height: 100%; align-self: start; display: flex; flex-direction: column; overflow: hidden; background: #fff; border: 1px solid #e5e8ed; border-radius: 10px; box-sizing: border-box; }
.catalog-head { height: 52px; padding: 0 15px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #edf0f4; }
.catalog-title { font-size: 15px; font-weight: 700; }
.catalog-summary { color: #8793a7; font-size: 11px; }
.catalog-tree { max-height: calc(100vh - 190px); overflow-x: hidden; overflow-y: auto; padding: 0 0 8px; }
.catalog-node { width: 100%; min-width: 0; height: 32px; display: inline-flex; align-items: center; gap: 8px; padding: 0 14px; box-sizing: border-box; }
.catalog-node-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #344258; font-size: 13px; }
.catalog-outline-node .catalog-node-title { color: #202d43; font-weight: 400; }
.catalog-resource-node { padding-left: 15px; }
.catalog-resource-node .catalog-node-title { font-weight: 400; }
.catalog-node-time { flex: none; color: #8e9bb0; font-size: 11px; }
.catalog-status-complete { flex: none; color: #2f80ed; font-size: 15px; }
.catalog-status-radio { flex: none; margin-right: 0; line-height: 1; }
.catalog-status-radio .el-radio__inner { width: 15px; height: 15px; border-color: #cbd4e1; }
.catalog-status-radio .el-radio__input.is-checked .el-radio__inner { border-color: #2f80ed; background: #fff; }
.catalog-status-radio .el-radio__input.is-checked .el-radio__inner::after { width: 5px; height: 5px; background: #2f80ed; }
.catalog-status-radio .el-radio__label { display: none; }
.catalog-toggle { width: 22px; height: 22px; flex: none; padding: 0; display: inline-flex; align-items: center; justify-content: center; color: #96a3b6; border: 0; border-radius: 4px; background: transparent; cursor: pointer; }
.catalog-toggle:hover { color: #2f80ed; background: #eef5ff; }
.catalog-toggle i { font-size: 12px; }
.study-catalog .el-tree-node__content { height: 32px; padding-left: 0 !important; }
.study-catalog .el-tree-node__expand-icon { display: none; }
.study-catalog .el-tree-node__content:hover { background: #f1f6ff; }
.study-catalog .el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content { background: #eaf2ff; }
.study-catalog .el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content .catalog-node-title { color: #1769ff; }
.catalog-node-current { color: #1769ff; background: #eaf2ff; }
.catalog-node-current .catalog-node-title { color: #1769ff; }
/* 桌面端仅允许右侧课程学习内容滚动,顶部导航和左侧课程清单始终保持固定。 */
.study-main { min-width: 0; min-height: 0; height: 100%; overflow-x: hidden; overflow-y: auto; box-sizing: border-box; }
.study-main::-webkit-scrollbar { width: 6px; }
.study-main::-webkit-scrollbar-thumb { background: #c8d2df; border-radius: 6px; }
.study-main::-webkit-scrollbar-track { background: transparent; }
.player-card { height: 345px; display: flex; align-items: center; justify-content: center; overflow: hidden; background: #101010; }
.media-player { width: 100%; height: 345px; display: block; background: #000; object-fit: contain; }
.audio-panel { width: 100%; height: 345px; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #dfe9f5; background: #142c46; }
.audio-panel i { margin-bottom: 24px; font-size: 52px; }
.audio-panel audio { width: 80%; }
.image-preview { max-width: 100%; max-height: 345px; }
.doc-frame { width: 100%; height: 345px; border: 0; background: #fff; }
.file-fallback { width: 100%; padding: 40px; color: #65748a; text-align: center; background: #fff; box-sizing: border-box; }
.empty-resource { color: #9aa6b7; text-align: center; }
.empty-resource i { display: block; margin-bottom: 12px; font-size: 44px; }
.course-detail-card { margin-top: 14px; padding: 18px 20px 20px; background: #fff; border: 1px solid #e6e9ee; border-radius: 10px; box-sizing: border-box; }
.course-detail-head { display: flex; align-items: flex-start; justify-content: space-between; }
.detail-course-title { font-size: 18px; font-weight: 700; }
.detail-course-type { margin-left: 6px; color: #7f8ba0; font-size: 13px; font-weight: 400; }
.detail-learning-state { margin-top: 5px; color: #5f6d82; font-size: 12px; }
.detail-actions { display: flex; gap: 7px; }
.detail-actions .el-button { margin: 0; border-radius: 17px; }
.course-statistics { margin-top: 12px; padding-top: 12px; display: flex; gap: 16px; color: #8290a6; border-top: 1px solid #edf0f4; font-size: 12px; }
.course-description { margin-top: 12px; color: #344258; font-size: 13px; line-height: 22px; }
.study-bottom { margin-top: 11px; display: flex; align-items: center; justify-content: space-between; color: #8491a5; font-size: 12px; }
.study-bottom .el-button { border-radius: 18px; }
.page-loading { min-height: 0; flex: 1; display: flex; align-items: center; justify-content: center; color: #2878e5; font-size: 28px; }
@media (max-width: 1180px) { .study-topbar-inner, .study-layout { width: calc(100% - 32px); } .study-layout { grid-template-columns: 230px minmax(0, 1fr); } }
@media (max-width: 760px) { #sub-app-container-main-content, #sub-app-container-main-content-body { overflow-y: auto; } .study-v2 { height: auto; min-height: 100%; overflow: visible; } .study-layout { min-height: auto; flex: none; grid-template-columns: 1fr; overflow: visible; } .study-catalog { height: auto; order: 2; } .catalog-tree { max-height: 360px; overflow-y: auto; } .study-main { height: auto; padding-right: 0; overflow: visible; } .player-card, .media-player, .audio-panel, .doc-frame { height: 320px; min-height: 320px; } .breadcrumb-course { display: none; } }
</style>
<div id="app" class="study-v2" v-cloak>
<div v-if="pageLoading" class="page-loading"><i class="el-icon-loading"></i></div>
<template v-else>
<header class="study-topbar">
<div class="study-topbar-inner">
<span class="back-link" @click="goBack"><i class="el-icon-arrow-left"></i> 返回课程列表</span>
<div class="breadcrumb-course"><strong>{{course.courseName || '课程学习'}}</strong><span v-if="selectedResource.title"> / {{selectedResource.title}}</span></div>
<div class="top-progress"><span>学习进度</span><el-progress :percentage="courseProgress" :show-text="false" :stroke-width="5"></el-progress><span class="top-progress-value">{{courseProgress}}%</span></div>
</div>
</header>
<main class="study-layout">
<aside class="study-catalog">
<div class="catalog-head"><span class="catalog-title">课程清单</span><span class="catalog-summary">{{completedCount}}/{{resourceCount}} 已完成</span></div>
<div class="catalog-tree">
<el-tree ref="studyTree" :data="treeData" node-key="id" default-expand-all highlight-current :expand-on-click-node="false" :props="{children:'children', label:'title'}" @node-click="nodeClick">
<span slot-scope="{ node, data }" class="catalog-node" :class="{'catalog-outline-node': data.type === 'outline', 'catalog-resource-node': data.type === 'resource', 'catalog-node-current': data.type === 'resource' && data.id === selectedResource.id}">
<template v-if="data.type === 'resource'">
<i v-if="isResourceCompleted(data)" class="el-icon-success catalog-status-complete"></i>
<el-radio v-else class="catalog-status-radio" :value="selectedResource.id" :label="data.id" @click.native.stop @change="nodeClick(data)"><span></span></el-radio>
</template>
<span class="catalog-node-title" :title="data.title">{{data.title}}</span>
<span v-if="data.type === 'resource'" class="catalog-node-time">{{formatShortDuration(data.durationSeconds)}}</span>
<button v-else-if="node.childNodes && node.childNodes.length" type="button" class="catalog-toggle" :aria-label="node.expanded ? '收起' + data.title : '展开' + data.title" @click.stop="toggleCatalogNode(node)">
<i :class="node.expanded ? 'el-icon-arrow-up' : 'el-icon-arrow-down'"></i>
</button>
</span>
</el-tree>
</div>
</aside>
<section class="study-main">
<div class="player-card">
<template v-if="selectedResource.id">
<video v-if="selectedResource.resourceType === 'video'" ref="mediaPlayer" class="media-player" :src="mediaUrl" controls controlslist="nodownload" @play="mediaPlay" @ended="mediaEnded" @timeupdate="saveProgress" @loadedmetadata="mediaReady" @seeking="handleMediaSeeking" @seeked="handleMediaSeeked"></video>
<div v-else-if="selectedResource.resourceType === 'audio'" class="audio-panel"><i class="el-icon-headset"></i><div class="mb20">{{selectedResource.title}}</div><audio ref="mediaPlayer" :src="fileUrl" controls controlslist="nodownload" @play="mediaPlay" @ended="mediaEnded" @timeupdate="saveProgress" @loadedmetadata="mediaReady" @seeking="handleMediaSeeking" @seeked="handleMediaSeeked"></audio></div>
<img v-else-if="selectedResource.resourceType === 'image'" class="image-preview" :src="fileUrl" :alt="selectedResource.title">
<iframe v-else-if="canInlinePreview(selectedResource)" class="doc-frame" :src="inlinePreviewUrl"></iframe>
<div v-else class="file-fallback"><file-preview :files="selectedResource.fileData" complete_result></file-preview><el-button v-if="fileUrl" class="mt20" type="primary" icon="el-icon-view" @click="openFile">打开学习资料</el-button></div>
</template>
<div v-else class="empty-resource"><i class="el-icon-reading"></i><div>请从左侧目录选择需要学习的资料</div></div>
</div>
<div class="course-detail-card">
<div class="course-detail-head">
<div><div><span class="detail-course-title">{{course.courseName}}</span><span class="detail-course-type">{{course.courseTypeName || '课程'}}</span></div><div class="detail-learning-state">正在学习:{{selectedResource.title || '请选择课节'}}</div></div>
<div class="detail-actions">
<el-button v-if="fileUrl" size="mini" icon="el-icon-share" @click="openFile">打开资料</el-button>
</div>
</div>
<div class="course-statistics"><span><i class="el-icon-collection"></i> 共 {{resourceCount}} 课节</span><span><i class="el-icon-time"></i> 总时长 {{formatCourseDuration(course.totalDurationSeconds)}}</span><span><i class="el-icon-view"></i> {{course.viewCount || 0}} 次观看</span></div>
<div class="course-description">{{course.courseIntro || '暂无课程介绍'}}</div>
</div>
<div class="study-bottom">
<span>{{studying ? '正在记录有效学习时长:' + recordStudyTime : '播放课程后,系统将自动记录有效学习时长。'}}</span>
<div>
<el-button v-if="!studying && selectedResource.id && !isMediaResource" size="small" type="primary" plain :loading="startingStudy" @click="startStudy">开始学习</el-button>
<el-button v-if="studying" size="small" type="danger" plain @click="finishStudy(false)">结束学习</el-button>
<el-button v-if="hasNextResource" size="small" type="primary" @click="nextResource">下一节 <i class="el-icon-arrow-right"></i></el-button>
</div>
</div>
</section>
</main>
</template>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
components: {"file-preview": httpVueLoader("/components/plugins/sysFilePreview/index.vue")},
data() {
return { courseId: "", preferredResourceId: "", apiBase: "/platform/learning/course/display", recordApi: "/platform/learning/study/record", course: {}, treeData: [], selectedResource: {}, studying: false, startingStudy: false, currentSegmentId: "", recordProgress: 0, recordStudyTime: "0秒", pendingSeconds: 0, heartbeatTimer: null, autoResumeKey: "", lastPositionSyncAt: 0, lastActiveAt: Date.now(), inactiveLimit: 60000, lastAllowedPositionSeconds: 0, restoringMediaPosition: false, pageLoading: true, loadingCount: 0 }
},
computed: {
fileUrl() { return this.getFileUrl(this.selectedResource.fileData) },
mediaUrl() {
if (this.selectedResource.resourceType !== "video") return this.fileUrl
const fileId = this.getFileId(this.selectedResource.fileData)
// 视频续播和手动拖动依赖 Range 请求,普通文件地址会导致 currentTime 定位被浏览器重置。
return fileId ? "/platform/sys/file/videoPlay?id=" + encodeURIComponent(fileId) : this.fileUrl
},
inlinePreviewUrl() { const id = this.getFileId(this.selectedResource.fileData); return id ? this.apiBase + "/pdfPreview?id=" + encodeURIComponent(id) : this.fileUrl },
resourceTypeText() { const names = {video: "视频资料", audio: "音频资料", image: "图片资料", pdf: "文档资料", word: "文档资料", ppt: "演示资料"}; return names[this.selectedResource.resourceType] || "课程资料" },
resourceList() { return this.collectResources(this.treeData) },
resourceCount() { return this.resourceList.length },
completedCount() { return this.resourceList.filter((item) => item.completeStatus === "completed" || Number(item.progressPercent || 0) >= 100).length },
courseProgress() { if (!this.resourceCount) return 0; return Math.round(this.resourceList.reduce((total, item) => total + Number(item.progressPercent || 0), 0) / this.resourceCount) },
currentResourceIndex() { return this.resourceList.findIndex((item) => item.id === this.selectedResource.id) },
hasNextResource() { return this.currentResourceIndex >= 0 && this.currentResourceIndex < this.resourceList.length - 1 },
isMediaResource() { return ["video", "audio"].includes(this.selectedResource.resourceType) }
},
methods: {
beginLoading() { this.loadingCount += 1; this.pageLoading = true },
endLoading() { this.loadingCount -= 1; if (this.loadingCount <= 0) { this.loadingCount = 0; this.pageLoading = false } },
getQuery(name) { return new URLSearchParams(window.location.search).get(name) || "" },
// 请求参数 id 为课程主键;接口返回课程标题等基础字段,作为学习页顶部信息。
loadCourse() { this.beginLoading(); this.$axios.post(this.apiBase + "/courseInfo", {id: this.courseId}).then((res) => { if (res.code === 0) { this.course = res.data || {} } else { this.$message.warning(res.msg) } }).finally(() => { this.endLoading() }) },
// 请求参数 courseId 为课程主键;优先选中 URL 中 resourceId 指定的资料,否则选中首个可学习资料。
loadTree() { this.beginLoading(); this.$axios.post(this.apiBase + "/studyTree", {courseId: this.courseId}).then((res) => { if (res.code !== 0) { this.$message.warning(res.msg); return } this.treeData = res.data || []; const resources = this.collectResources(this.treeData); const current = resources.find(item => item.id === this.preferredResourceId) || resources[0]; if (current) { this.selectResource(current); this.$nextTick(() => { if (this.$refs.studyTree) this.$refs.studyTree.setCurrentKey(current.id) }) } }).finally(() => { this.endLoading() }) },
collectResources(nodes) { let resources = []; (nodes || []).forEach((node) => { if (node.type === "resource") resources.push(node); if (node.children && node.children.length) resources = resources.concat(this.collectResources(node.children)) }); return resources },
nodeClick(data) { if (data.type !== "resource") return; if (this.studying && this.selectedResource.id !== data.id) { this.$message.warning("请先结束当前资料的学习"); return } this.selectResource(data) },
selectResource(resource) { this.selectedResource = resource; this.recordProgress = Number(resource.progressPercent || 0); this.recordStudyTime = this.formatSeconds(Number(resource.studySeconds || 0)); this.autoResumeKey = ""; this.lastAllowedPositionSeconds = Number(resource.maxPositionSeconds || 0); this.restoringMediaPosition = false },
// 参数 data 为课节资源;接口完成状态或进度达到 100% 时,清单显示蓝色完成图标。
isResourceCompleted(data) { return data.completeStatus === "completed" || Number(data.progressPercent || 0) >= 100 },
// 参数 node 为 Element Tree 章节节点;点击右侧按钮只切换章节展开状态,不触发课节选择。
toggleCatalogNode(node) { if (!node || node.isLeaf) return; this.$set(node, "expanded", !node.expanded) },
getFileUrl(value) { if (!value) return ""; if (Array.isArray(value)) return value.length ? (value[0].url || value[0].response?.data || value[0].data || "") : ""; if (typeof value === "string" && value.trim().startsWith("[")) { try { const files = JSON.parse(value); return files.length ? (files[0].url || files[0].response?.data || files[0].data || "") : "" } catch (e) { return "" } } return value },
getFileId(value) { const url = this.getFileUrl(value); if (!url) return ""; const matched = url.match(/[?&]id=([^&]+)/); if (matched) return decodeURIComponent(matched[1]); return !url.includes("/") && !url.includes(".") ? url : "" },
canInlinePreview(resource) { const ext = (resource.fileExt || "").toLowerCase(); return ["pdf", "ppt", "word"].includes(resource.resourceType) || ["pdf", "ppt", "pptx", "doc", "docx"].includes(ext) },
progressKey() { return "learning-progress-" + this.courseId + "-" + this.selectedResource.id },
mediaReady() { this.askResume() },
askResume() {
if (!["video", "audio"].includes(this.selectedResource.resourceType) || this.studying || this.startingStudy) return
const currentKey = this.progressKey()
if (this.autoResumeKey === currentKey) return
this.autoResumeKey = currentKey
const player = this.$refs.mediaPlayer
// 仅使用当前登录人的服务端学习记录,加载视频后直接定位但不自动播放。
const saved = Math.max(0, Number(this.selectedResource.lastPositionSeconds || 0))
if (!player || !saved || saved < 5 || saved >= player.duration - 5) return
this.restoreMediaPosition(player, saved, 0)
},
mediaPlay() { if (!this.studying && !this.startingStudy) this.startStudy({fromMedia: true}) },
mediaEnded() { this.finishStudy(false, {silent: true, completed: true}); localStorage.removeItem(this.progressKey()) },
playSelectedMedia(seekTo) { if (!["video", "audio"].includes(this.selectedResource.resourceType)) return; this.$nextTick(() => { const player = this.$refs.mediaPlayer; if (!player) return; const play = () => { if (typeof seekTo === "number" && !Number.isNaN(seekTo)) this.restoreMediaPosition(player, seekTo, 0); const playPromise = player.play && player.play(); if (playPromise && playPromise.catch) playPromise.catch(() => { this.$message.warning("浏览器阻止了自动播放,请点击播放器继续") }) }; if (player.readyState >= 1) { play() } else { player.addEventListener("loadedmetadata", play, {once: true}) } }) },
// player 为当前视频或音频播放器,positionSeconds 为服务端保存的续播秒数;定位失败时仅重试一次,避免播放器无法拖动时循环请求。
restoreMediaPosition(player, positionSeconds, retryCount) {
if (!player || positionSeconds <= 0) return
let targetPosition = Math.max(0, Math.floor(positionSeconds))
if (Number.isFinite(player.duration) && player.duration > 0) {
targetPosition = Math.min(targetPosition, Math.max(0, Math.floor(player.duration) - 2))
}
if (targetPosition <= 0) return
this.restoringMediaPosition = true
player.addEventListener("seeked", () => {
this.lastAllowedPositionSeconds = targetPosition
this.restoringMediaPosition = false
if (Math.abs(Number(player.currentTime || 0) - targetPosition) <= 1 || retryCount >= 1) return
setTimeout(() => this.restoreMediaPosition(player, targetPosition, retryCount + 1), 100)
}, {once: true})
player.currentTime = targetPosition
},
// 原生播放器点击进度条会在 seeking 后再次写入时间,因此开始定位和完成定位均需限制向前跳转;回退复习不受影响。
restoreBlockedMediaPosition() { const player = this.$refs.mediaPlayer; if (!player || this.selectedResource.allowDrag !== false || this.restoringMediaPosition) return false; const allowedPosition = Math.max(0, Number(this.lastAllowedPositionSeconds || 0)); if (Number(player.currentTime || 0) <= allowedPosition + 0.25) return false; player.currentTime = allowedPosition; setTimeout(() => { if (!this.restoringMediaPosition && this.selectedResource.allowDrag === false && Number(player.currentTime || 0) > allowedPosition + 0.25) player.currentTime = allowedPosition }, 0); return true },
handleMediaSeeking() { this.restoreBlockedMediaPosition() },
handleMediaSeeked() { this.restoreBlockedMediaPosition() },
saveProgress() { if (!["video", "audio"].includes(this.selectedResource.resourceType)) return; const player = this.$refs.mediaPlayer; if (!player || player.currentTime <= 0) return; const position = Math.floor(player.currentTime); if (this.selectedResource.allowDrag === false && !this.restoringMediaPosition && position > Number(this.lastAllowedPositionSeconds || 0) + 2) { this.restoreBlockedMediaPosition(); return } this.lastAllowedPositionSeconds = Math.max(Number(this.lastAllowedPositionSeconds || 0), position); localStorage.setItem(this.progressKey(), String(position)); this.$set(this.selectedResource, "lastPositionSeconds", position); if (Date.now() - this.lastPositionSyncAt > 5000) this.saveServerPosition(position) },
// position 为当前播放秒数;页面隐藏或退出时优先使用 Beacon,避免请求被浏览器中断。
saveServerPosition(position, useBeacon) { if (!this.selectedResource.id || !["video", "audio"].includes(this.selectedResource.resourceType)) return; const seconds = Math.max(0, Math.floor(Number(position || 0))); this.lastPositionSyncAt = Date.now(); if (useBeacon && navigator.sendBeacon) { const body = new URLSearchParams(); body.append("courseId", this.courseId); body.append("resourceId", this.selectedResource.id); body.append("positionSeconds", String(seconds)); navigator.sendBeacon(this.recordApi + "/position", new Blob([body.toString()], {type: "application/x-www-form-urlencoded;charset=UTF-8"})); return } this.$axios.post(this.recordApi + "/position", {courseId: this.courseId, resourceId: this.selectedResource.id, positionSeconds: seconds}).then(() => {}).finally(() => {}) },
// options 支持 autoPlay、seekTo 与 fromMedia;返回的 segmentId 用于后续心跳和结束学习请求。
startStudy(options) { const config = options && !options.target ? options : {}; if (!this.selectedResource.id) { this.$message.warning("请选择课程资料"); return } if (this.studying) { if (config.autoPlay) this.playSelectedMedia(config.seekTo); return } this.startingStudy = true; this.$axios.post(this.recordApi + "/start", {courseId: this.courseId, resourceId: this.selectedResource.id, positionSeconds: typeof config.seekTo === "number" ? Math.floor(config.seekTo) : this.getMediaPosition()}).then((res) => { if (res.code !== 0) { this.$message.warning(res.msg); return } this.currentSegmentId = res.data.segmentId; this.studying = true; this.pendingSeconds = 0; this.lastActiveAt = Date.now(); this.$set(this.course, "viewCount", Number(res.data.viewCount || 0)); this.applyRecordState(res.data); this.startHeartbeatTimer(); if (config.autoPlay !== false && !config.fromMedia) this.playSelectedMedia(config.seekTo); this.$message.success("已开始学习") }).finally(() => { this.startingStudy = false }) },
heartbeat() { if (!this.studying || !this.currentSegmentId || this.pendingSeconds <= 0) return; const seconds = this.pendingSeconds; this.pendingSeconds = 0; this.$axios.post(this.recordApi + "/heartbeat", {segmentId: this.currentSegmentId, activeSeconds: seconds, positionSeconds: this.getMediaPosition()}).then((res) => { if (res.code === 0) { this.applyRecordState(res.data); return } this.stopHeartbeatTimer(); this.studying = false; this.currentSegmentId = ""; this.$message.warning(res.msg) }).finally(() => {}) },
finishStudy(force, options) { const config = options || {}; if (!this.currentSegmentId) return Promise.resolve(); const segmentId = this.currentSegmentId; const seconds = this.pendingSeconds; this.pendingSeconds = 0; this.stopHeartbeatTimer(); this.studying = false; this.currentSegmentId = ""; if (!force) this.pauseSelectedMedia(); if (force && navigator.sendBeacon) { this.saveServerPosition(this.getMediaPosition(), true); const formData = new FormData(); formData.append("segmentId", segmentId); formData.append("activeSeconds", String(seconds)); formData.append("positionSeconds", String(this.getMediaPosition())); navigator.sendBeacon(this.recordApi + "/finish", formData); return Promise.resolve() } return this.$axios.post(this.recordApi + "/finish", {segmentId: segmentId, activeSeconds: seconds, positionSeconds: this.getMediaPosition(), completed: !!config.completed}).then((res) => { if (res.code === 0 && res.data) { this.applyRecordState(res.data); if (!config.silent) this.$message.success("学习已结束") } }).finally(() => {}) },
pauseSelectedMedia() { if (!["video", "audio"].includes(this.selectedResource.resourceType)) return; const player = this.$refs.mediaPlayer; if (player && !player.paused) player.pause() },
startHeartbeatTimer() { this.stopHeartbeatTimer(); this.heartbeatTimer = setInterval(() => { if (!this.studying) return; if (this.isEffectiveLearning()) this.pendingSeconds += 1; if (this.pendingSeconds >= 15) this.heartbeat() }, 1000) },
stopHeartbeatTimer() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null } },
isEffectiveLearning() { if (document.hidden || Date.now() - this.lastActiveAt > this.inactiveLimit) return false; if (["video", "audio"].includes(this.selectedResource.resourceType)) { const player = this.$refs.mediaPlayer; return !!player && !player.paused && !player.ended } return true },
markActive() { this.lastActiveAt = Date.now() },
applyRecordState(data) { this.recordProgress = Number(data.progressPercent || 0); this.recordStudyTime = data.studyTimeText || this.formatSeconds(Number(data.studySeconds || 0)); if (this.selectedResource.id && data.lastPositionSeconds !== undefined) this.$set(this.selectedResource, "lastPositionSeconds", Number(data.lastPositionSeconds || 0)); if (this.selectedResource.id && data.maxPositionSeconds !== undefined) { const maxPositionSeconds = Number(data.maxPositionSeconds || 0); this.$set(this.selectedResource, "maxPositionSeconds", maxPositionSeconds); this.lastAllowedPositionSeconds = Math.max(Number(this.lastAllowedPositionSeconds || 0), maxPositionSeconds) } const resource = this.resourceList.find(item => item.id === this.selectedResource.id); if (resource) { this.$set(resource, "progressPercent", this.recordProgress); this.$set(resource, "studySeconds", Number(data.studySeconds || 0)); this.$set(resource, "completeStatus", data.completeStatus || "studying"); if (data.maxPositionSeconds !== undefined) this.$set(resource, "maxPositionSeconds", Number(data.maxPositionSeconds || 0)) } },
getMediaPosition() { if (!["video", "audio"].includes(this.selectedResource.resourceType)) return 0; const player = this.$refs.mediaPlayer; return player ? Math.floor(player.currentTime || 0) : Number(this.selectedResource.lastPositionSeconds || 0) },
formatSeconds(seconds) { const hour = Math.floor(seconds / 3600); const minute = Math.floor(seconds % 3600 / 60); const second = Math.floor(seconds % 60); if (hour > 0) return hour + "小时" + minute + "分" + second + "秒"; if (minute > 0) return minute + "分" + second + "秒"; return second + "秒" },
formatShortDuration(seconds) { const total = Number(seconds) || 0; if (!total) return ""; const minute = Math.floor(total / 60); const second = total % 60; return String(minute).padStart(2, "0") + ":" + String(second).padStart(2, "0") },
formatCourseDuration(seconds) { const total = Number(seconds) || 0; if (total < 3600) return Math.max(1, Math.ceil(total / 60)) + "分钟"; const hour = Math.floor(total / 3600); const minute = Math.ceil(total % 3600 / 60); return minute ? hour + "小时" + minute + "分钟" : hour + "小时" },
nextResource() { if (!this.hasNextResource) return; const next = this.resourceList[this.currentResourceIndex + 1]; const changeResource = () => { this.selectResource(next); this.$nextTick(() => { if (this.$refs.studyTree) this.$refs.studyTree.setCurrentKey(next.id) }) }; if (this.studying) { this.finishStudy(false, {silent: true}).then(changeResource) } else { changeResource() } },
openFile() { if (this.fileUrl) window.open(this.fileUrl) },
collapseAll() { const nodesMap = this.$refs.studyTree && this.$refs.studyTree.store.nodesMap; Object.keys(nodesMap || {}).forEach(key => { nodesMap[key].expanded = false }) },
goBack() { this.saveServerPosition(this.getMediaPosition()); this.finishStudy(false, {silent: true}).then(() => { window.location.href = this.apiBase }) }
},
created() { this.courseId = this.getQuery("id"); this.preferredResourceId = this.getQuery("resourceId"); if (!this.courseId) { this.pageLoading = false; this.$message.warning("请选择课程"); return } window.addEventListener("mousemove", this.markActive); window.addEventListener("keydown", this.markActive); window.addEventListener("click", this.markActive); window.addEventListener("scroll", this.markActive, true); window.addEventListener("beforeunload", () => { this.finishStudy(true) }); document.addEventListener("visibilitychange", () => { if (document.hidden) this.saveServerPosition(this.getMediaPosition(), true) }); this.loadCourse(); this.loadTree() },
beforeDestroy() { this.finishStudy(true); this.stopHeartbeatTimer(); window.removeEventListener("mousemove", this.markActive); window.removeEventListener("keydown", this.markActive); window.removeEventListener("click", this.markActive); window.removeEventListener("scroll", this.markActive, true) }
})
</script>
<!--#
}
#-->