first commit
This commit is contained in:
@@ -0,0 +1,617 @@
|
||||
<!--#
|
||||
layout("/layouts/platform_h5.html"){
|
||||
#-->
|
||||
|
||||
<style>
|
||||
.learning-course-page {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f4f7fb;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.learning-course-page .van-nav-bar {
|
||||
background: #f4f7fb;
|
||||
}
|
||||
.learning-banner-wrap {
|
||||
flex-shrink: 0;
|
||||
padding: 10px 14px 0;
|
||||
}
|
||||
.learning-banner {
|
||||
height: 128px;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
background: #dce7f5;
|
||||
}
|
||||
.learning-banner-item {
|
||||
position: relative;
|
||||
height: 128px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #0b75bd, #33b3a6);
|
||||
}
|
||||
.learning-banner-item img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.learning-banner-fallback {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 0 22px;
|
||||
color: #ffffff;
|
||||
box-sizing: border-box;
|
||||
background:
|
||||
radial-gradient(circle at 14% 20%, rgba(255,255,255,.26), transparent 22%),
|
||||
linear-gradient(135deg, #0b75bd, #35b6a8 55%, #f6b24d);
|
||||
}
|
||||
.learning-banner-title {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 28px 14px 12px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
background: linear-gradient(to top, rgba(15, 23, 42, .64), transparent);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.learning-banner-fallback .learning-banner-title {
|
||||
position: static;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.learning-filter-card {
|
||||
flex-shrink: 0;
|
||||
margin: 10px 10px 0;
|
||||
padding: 4px 10px 8px;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 3px 12px rgba(31, 72, 122, .08);
|
||||
}
|
||||
.learning-list-scroll {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.learning-filter-group {
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid #edf1f7;
|
||||
}
|
||||
.learning-filter-group + .learning-filter-group {
|
||||
margin-top: 0;
|
||||
}
|
||||
.learning-filter-group:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.learning-filter-category {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 40px;
|
||||
padding: 0 2px;
|
||||
color: #26354c;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.learning-filter-category-main,
|
||||
.learning-filter-category-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.learning-filter-category-title {
|
||||
margin-left: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.learning-filter-category-main .van-icon {
|
||||
color: #1677ff;
|
||||
font-size: 17px;
|
||||
}
|
||||
.learning-filter-selected-count {
|
||||
color: #97a4b7;
|
||||
font-size: 11px;
|
||||
}
|
||||
.learning-filter-reset,
|
||||
.learning-filter-collapse {
|
||||
margin-left: 12px;
|
||||
color: #4d92ec;
|
||||
font-size: 11px;
|
||||
}
|
||||
.learning-filter-collapse .van-icon {
|
||||
margin-left: 2px;
|
||||
font-size: 11px;
|
||||
vertical-align: -1px;
|
||||
}
|
||||
.learning-filter-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px 9px;
|
||||
padding: 0 1px 10px;
|
||||
}
|
||||
.learning-filter-item {
|
||||
flex: 1 0 calc((100% - 18px) / 3);
|
||||
min-width: 0;
|
||||
height: 22px;
|
||||
padding: 0 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #dce5f1;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
color: #718096;
|
||||
font-size: 11px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.learning-filter-item.active {
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
background: #287ff0;
|
||||
border-color: #287ff0;
|
||||
box-shadow: 0 2px 5px rgba(40, 127, 240, .2);
|
||||
}
|
||||
.learning-filter-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.learning-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 16px 10px;
|
||||
}
|
||||
.learning-section-title {
|
||||
color: #202733;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.learning-section-total {
|
||||
color: #8b96a6;
|
||||
font-size: 13px;
|
||||
}
|
||||
.learning-course-list {
|
||||
padding: 0 12px 18px;
|
||||
}
|
||||
.learning-course-card {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
min-height: 104px;
|
||||
margin-bottom: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.learning-course-cover {
|
||||
position: relative;
|
||||
flex: 0 0 142px;
|
||||
width: 142px;
|
||||
height: 82px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: #e6edf6;
|
||||
}
|
||||
.learning-course-cover img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.learning-course-cover-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(135deg, #3a7bd5, #8b5cf6);
|
||||
}
|
||||
.learning-course-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.learning-course-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
.learning-course-title-text {
|
||||
min-width: 0;
|
||||
color: #202733;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.learning-course-badge {
|
||||
flex-shrink: 0;
|
||||
max-width: 72px;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid #ffd1a8;
|
||||
border-radius: 4px;
|
||||
color: #f28a35;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
background: #fff7ee;
|
||||
}
|
||||
.learning-course-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
.learning-course-intro {
|
||||
margin-top: 8px;
|
||||
color: #7a8798;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.learning-empty {
|
||||
padding: 22px 0 36px;
|
||||
}
|
||||
@media (max-width: 360px) {
|
||||
.learning-filter-card { margin-left: 8px; margin-right: 8px; padding-left: 8px; padding-right: 8px; }
|
||||
.learning-filter-reset, .learning-filter-collapse { margin-left: 8px; }
|
||||
.learning-filter-item { padding-left: 4px; padding-right: 4px; }
|
||||
.learning-course-cover {
|
||||
flex-basis: 120px;
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="app" class="learning-course-page" v-cloak>
|
||||
<van-nav-bar title="学习教育" left-text="返回" left-arrow @click-left="historyBack" fixed placeholder></van-nav-bar>
|
||||
|
||||
<div class="learning-banner-wrap">
|
||||
<van-swipe class="learning-banner" :autoplay="3500" indicator-color="#ffffff">
|
||||
<van-swipe-item v-for="course in bannerCourses" :key="course.id" @click="openCourse(course)">
|
||||
<div class="learning-banner-item">
|
||||
<img v-if="getCoverUrl(course.cover)" :src="getCoverUrl(course.cover)" :alt="course.courseName">
|
||||
<div v-else class="learning-banner-fallback">
|
||||
<div class="learning-banner-title">{{ course.courseName || '学习教育' }}</div>
|
||||
</div>
|
||||
<div v-if="getCoverUrl(course.cover)" class="learning-banner-title">{{ course.courseName || '学习教育' }}</div>
|
||||
</div>
|
||||
</van-swipe-item>
|
||||
<van-swipe-item v-if="!bannerCourses.length">
|
||||
<div class="learning-banner-item learning-banner-fallback">
|
||||
<div class="learning-banner-title">学习教育</div>
|
||||
</div>
|
||||
</van-swipe-item>
|
||||
</van-swipe>
|
||||
</div>
|
||||
|
||||
<div class="learning-filter-card">
|
||||
<div class="learning-filter-group" v-for="group in filterGroups" :key="group.key">
|
||||
<div class="learning-filter-category">
|
||||
<div class="learning-filter-category-main">
|
||||
<van-icon :name="group.icon"></van-icon>
|
||||
<div class="learning-filter-category-title">{{ group.name }}</div>
|
||||
</div>
|
||||
<div class="learning-filter-category-actions">
|
||||
<span class="learning-filter-selected-count">已选{{ selectedFilterCount(group) }}项</span>
|
||||
<span class="learning-filter-reset" @click.stop="resetGroupFilter(group)">重置</span>
|
||||
<span class="learning-filter-collapse" @click.stop="toggleFilterGroup(group.key)">{{ filterCollapsed[group.key] ? '展开' : '收起' }}<van-icon :name="filterCollapsed[group.key] ? 'arrow-down' : 'arrow-up'"></van-icon></span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="!filterCollapsed[group.key]" class="learning-filter-options">
|
||||
<div
|
||||
v-for="item in group.items"
|
||||
:key="item.key"
|
||||
class="learning-filter-item"
|
||||
:class="{active: isFilterActive(item)}"
|
||||
@click="selectFilter(item)">
|
||||
<div class="learning-filter-name">{{ item.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="learning-list-scroll">
|
||||
<div class="learning-section-head">
|
||||
<div class="learning-section-title">我的课堂</div>
|
||||
<div class="learning-section-total">共 {{ pageForm.totalCount || 0 }} 门</div>
|
||||
</div>
|
||||
|
||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||
<van-list
|
||||
v-model="loading"
|
||||
:finished="finished"
|
||||
:immediate-check="false"
|
||||
finished-text="没有更多了"
|
||||
@load="onLoad">
|
||||
<div class="learning-course-list">
|
||||
<div
|
||||
class="learning-course-card"
|
||||
v-for="course in courseList"
|
||||
:key="course.id"
|
||||
@click="openCourse(course)">
|
||||
<div class="learning-course-cover">
|
||||
<img v-if="getCoverUrl(course.cover)" :src="getCoverUrl(course.cover)" :alt="course.courseName">
|
||||
<div v-else class="learning-course-cover-empty">{{ course.courseTypeName || '课程' }}</div>
|
||||
</div>
|
||||
<div class="learning-course-info">
|
||||
<div class="learning-course-title">
|
||||
<div class="learning-course-title-text">{{ course.courseName || '未命名课程' }}</div>
|
||||
<div v-if="firstRecommendName(course.recommendFlags)" class="learning-course-badge">{{ firstRecommendName(course.recommendFlags) }}</div>
|
||||
</div>
|
||||
<div class="learning-course-meta">
|
||||
<van-tag v-if="course.courseTypeName" plain type="primary">{{ course.courseTypeName }}</van-tag>
|
||||
<van-tag v-if="course.lecturerName" plain type="success">{{ course.lecturerName }}</van-tag>
|
||||
</div>
|
||||
<div class="learning-course-intro">{{ course.courseIntro || '暂无课程介绍' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
|
||||
<van-empty v-if="!loading && !courseList.length" class="learning-empty" description="暂无相关课程"></van-empty>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="${cspNonce!}">
|
||||
new Vue({
|
||||
el: "#app",
|
||||
store,
|
||||
data() {
|
||||
return {
|
||||
apiBase: "/platform/learning/course/display",
|
||||
query: {
|
||||
keyword: "",
|
||||
courseTypeId: "",
|
||||
recommendFlag: ""
|
||||
},
|
||||
pageForm: {
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
totalCount: 0
|
||||
},
|
||||
courseList: [],
|
||||
bannerCourses: [],
|
||||
courseTypeOptions: [],
|
||||
recommendOptions: [],
|
||||
loading: false,
|
||||
finished: false,
|
||||
refreshing: false,
|
||||
requestSeq: 0,
|
||||
filterCollapsed: { recommend: false, type: false }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filterGroups() {
|
||||
const recommendItems = [{ key: "recommend_all", type: "recommend", value: "", name: "全部" }].concat(this.recommendOptions.map((item) => ({
|
||||
key: "recommend_" + item.code,
|
||||
type: "recommend",
|
||||
value: item.code,
|
||||
name: item.name
|
||||
})))
|
||||
const typeItems = [{ key: "type_all", type: "type", value: "", name: "全部" }].concat(this.courseTypeOptions.map((item) => ({
|
||||
key: "type_" + item.id,
|
||||
type: "type",
|
||||
value: item.id,
|
||||
name: item.typeName
|
||||
})))
|
||||
return [
|
||||
{
|
||||
key: "recommend",
|
||||
name: "推荐标识",
|
||||
icon: "label-o",
|
||||
items: recommendItems
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
name: "课程类型",
|
||||
icon: "apps-o",
|
||||
items: typeItems
|
||||
}
|
||||
]
|
||||
},
|
||||
listTitle() {
|
||||
const recommend = this.recommendOptions.find(item => item.code === this.query.recommendFlag)
|
||||
const type = this.courseTypeOptions.find(item => item.id === this.query.courseTypeId)
|
||||
if (recommend && type) {
|
||||
return recommend.name + " · " + type.typeName
|
||||
}
|
||||
if (recommend) {
|
||||
return recommend.name
|
||||
}
|
||||
if (type) {
|
||||
return type.typeName
|
||||
}
|
||||
return "我的课堂"
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
historyBack,
|
||||
async loadOptions() {
|
||||
const [typeResp, recommendResp] = await Promise.all([
|
||||
this.$axios.post(this.apiBase + "/courseTypes"),
|
||||
this.$axios.post(this.apiBase + "/recommendOptions")
|
||||
])
|
||||
if (typeResp.code === 0) {
|
||||
this.courseTypeOptions = typeResp.data || []
|
||||
}
|
||||
if (recommendResp.code === 0) {
|
||||
this.recommendOptions = recommendResp.data || []
|
||||
}
|
||||
},
|
||||
onRefresh() {
|
||||
this.finished = false
|
||||
this.pageForm.pageNumber = 1
|
||||
this.courseList = []
|
||||
this.onLoad()
|
||||
},
|
||||
onLoad() {
|
||||
this.fetchCourses()
|
||||
},
|
||||
fetchCourses() {
|
||||
this.loading = true
|
||||
const requestSeq = ++this.requestSeq
|
||||
this.$axios.post(this.apiBase + "/pageData", {
|
||||
keyword: this.query.keyword,
|
||||
courseTypeId: this.query.courseTypeId,
|
||||
recommendFlag: this.query.recommendFlag,
|
||||
pageNumber: this.pageForm.pageNumber,
|
||||
pageSize: this.pageForm.pageSize
|
||||
}).then((res) => {
|
||||
if (requestSeq !== this.requestSeq) return
|
||||
if (res.code === 0) {
|
||||
const data = res.data || {}
|
||||
const list = data.list || []
|
||||
if (this.refreshing || this.pageForm.pageNumber === 1) {
|
||||
this.courseList = []
|
||||
}
|
||||
this.courseList = this.courseList.concat(list)
|
||||
this.pageForm.totalCount = data.totalCount || 0
|
||||
this.pageForm.pageNumber += 1
|
||||
this.finished = this.courseList.length >= this.pageForm.totalCount
|
||||
this.syncBannerCourses()
|
||||
} else {
|
||||
this.finished = true
|
||||
this.$toast(res.msg || "查询失败")
|
||||
}
|
||||
}).finally(() => {
|
||||
if (requestSeq === this.requestSeq) {
|
||||
this.loading = false
|
||||
this.refreshing = false
|
||||
}
|
||||
})
|
||||
},
|
||||
doSearch() {
|
||||
this.pageForm.pageNumber = 1
|
||||
this.pageForm.totalCount = 0
|
||||
this.courseList = []
|
||||
this.finished = false
|
||||
this.loading = true
|
||||
this.onLoad()
|
||||
},
|
||||
selectFilter(item) {
|
||||
if (!item) return
|
||||
if (item.type === "recommend") {
|
||||
this.query.recommendFlag = item.value || ""
|
||||
} else if (item.type === "type") {
|
||||
this.query.courseTypeId = item.value || ""
|
||||
}
|
||||
this.doSearch()
|
||||
},
|
||||
// 切换指定筛选组的展开状态,不改变该组已选择的查询条件。
|
||||
toggleFilterGroup(groupKey) {
|
||||
this.$set(this.filterCollapsed, groupKey, !this.filterCollapsed[groupKey])
|
||||
},
|
||||
// 重置指定筛选组并重新查询;推荐标识和课程类型可独立恢复为全部。
|
||||
resetGroupFilter(group) {
|
||||
if (!group) return
|
||||
if (group.key === "recommend") {
|
||||
this.query.recommendFlag = ""
|
||||
} else if (group.key === "type") {
|
||||
this.query.courseTypeId = ""
|
||||
}
|
||||
this.doSearch()
|
||||
},
|
||||
selectedFilterCount(group) {
|
||||
if (!group) return 0
|
||||
if (group.key === "recommend") return this.query.recommendFlag ? 1 : 0
|
||||
if (group.key === "type") return this.query.courseTypeId ? 1 : 0
|
||||
return 0
|
||||
},
|
||||
isFilterActive(item) {
|
||||
if (!item) return false
|
||||
if (item.type === "recommend") {
|
||||
return (this.query.recommendFlag || "") === (item.value || "")
|
||||
}
|
||||
if (item.type === "type") {
|
||||
return (this.query.courseTypeId || "") === (item.value || "")
|
||||
}
|
||||
return false
|
||||
},
|
||||
syncBannerCourses() {
|
||||
const withCover = this.courseList.filter(item => this.getCoverUrl(item.cover)).slice(0, 5)
|
||||
this.bannerCourses = (withCover.length ? withCover : this.courseList.slice(0, 5))
|
||||
},
|
||||
getCoverUrl(cover) {
|
||||
if (!cover) return ""
|
||||
if (Array.isArray(cover)) {
|
||||
return cover.length ? (cover[0].url || (cover[0].response && cover[0].response.data) || cover[0].data || "") : ""
|
||||
}
|
||||
if (typeof cover === "string") {
|
||||
const text = cover.trim()
|
||||
if (!text) return ""
|
||||
if (text.startsWith("[")) {
|
||||
try {
|
||||
const files = JSON.parse(text)
|
||||
return files.length ? (files[0].url || (files[0].response && files[0].response.data) || files[0].data || "") : ""
|
||||
} catch (e) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
return ""
|
||||
},
|
||||
openCourse(course) {
|
||||
if (!course || !course.id) return
|
||||
pjaxReplace("/platform/learning/course/h5/study?id=" + course.id)
|
||||
},
|
||||
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]) : ""
|
||||
},
|
||||
recommendIcon(index) {
|
||||
const icons = ["fire-o", "star-o", "award-o", "gem-o", "flag-o", "good-job-o"]
|
||||
return icons[index % icons.length]
|
||||
},
|
||||
typeIcon(index) {
|
||||
const icons = ["video-o", "records-o", "description-o", "bookmark-o", "cluster-o", "desktop-o"]
|
||||
return icons[index % icons.length]
|
||||
},
|
||||
palette(index) {
|
||||
const colors = ["#28a6df", "#f2b84b", "#f46d5f", "#7b8fda", "#59bbb2", "#2d7db8", "#ee755b", "#f3c256", "#ef9461", "#8ba4dc"]
|
||||
return colors[index % colors.length]
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.loadOptions()
|
||||
this.onLoad()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<!--#
|
||||
}
|
||||
#-->
|
||||
@@ -0,0 +1,890 @@
|
||||
<!--#
|
||||
layout("/layouts/platform_h5.html"){
|
||||
#-->
|
||||
|
||||
<style>
|
||||
.learning-study-page {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f5f6f8;
|
||||
}
|
||||
.study-hero {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
height: 195px;
|
||||
background: linear-gradient(135deg, #7554e8, #48b7f0);
|
||||
overflow: hidden;
|
||||
}
|
||||
.study-hero img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.study-hero-empty {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 28px;
|
||||
color: #ffffff;
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.study-back {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 8px;
|
||||
z-index: 2;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
color: #ffffff;
|
||||
font-size: 24px;
|
||||
background: rgba(15, 23, 42, .22);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.study-title-block {
|
||||
flex-shrink: 0;
|
||||
padding: 16px 18px 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.study-title {
|
||||
color: #202733;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.study-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
margin-top: 14px;
|
||||
color: #8a93a3;
|
||||
font-size: 14px;
|
||||
}
|
||||
.study-meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.study-tabs {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
}
|
||||
.study-tabs .van-tabs__content {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.study-tabs .van-tabs__wrap {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.study-tabs .van-tabs__track {
|
||||
height: 100%;
|
||||
}
|
||||
.study-tabs .van-tab__pane {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.study-intro-scroll,
|
||||
.study-catalog-scroll {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
background: #f5f6f8;
|
||||
}
|
||||
.study-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 18px;
|
||||
color: #8b94a3;
|
||||
font-size: 16px;
|
||||
}
|
||||
.study-summary strong {
|
||||
color: #202733;
|
||||
font-size: 20px;
|
||||
}
|
||||
.study-card {
|
||||
margin: 18px;
|
||||
padding: 14px 0;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, .04);
|
||||
overflow: hidden;
|
||||
}
|
||||
.study-chapter {
|
||||
padding: 0 18px;
|
||||
}
|
||||
.study-chapter-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0 16px;
|
||||
color: #202733;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.study-chapter-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid #ff9b2f;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.study-resource {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 14px 0 12px 18px;
|
||||
border-top: 1px solid #f0f2f5;
|
||||
}
|
||||
.study-resource-icon {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
margin-top: 2px;
|
||||
border-radius: 3px;
|
||||
color: #ffffff;
|
||||
font-size: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.study-resource-icon.video {
|
||||
background: #ffbd2e;
|
||||
}
|
||||
.study-resource-icon.audio {
|
||||
background: #33b3a6;
|
||||
}
|
||||
.study-resource-icon.image {
|
||||
background: #5a94f2;
|
||||
}
|
||||
.study-resource-icon.doc {
|
||||
background: #ff6868;
|
||||
}
|
||||
.study-resource-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.study-resource-title {
|
||||
color: #5c6470;
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.study-resource-status {
|
||||
margin-top: 8px;
|
||||
color: #a2a9b5;
|
||||
font-size: 15px;
|
||||
}
|
||||
.study-intro {
|
||||
padding: 18px;
|
||||
color: #4e5969;
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
background: #ffffff;
|
||||
min-height: 180px;
|
||||
}
|
||||
.study-player-popup {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0;
|
||||
background: #000000;
|
||||
}
|
||||
.study-player-head {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
color: #ffffff;
|
||||
background: #101828;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, .08);
|
||||
}
|
||||
.study-player-title {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.study-player-body {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
background: #000000;
|
||||
}
|
||||
.study-media {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
background: #000000;
|
||||
border-radius: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
.study-audio-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 220px;
|
||||
border-radius: 0;
|
||||
background: linear-gradient(135deg, #10233f, #0f766e);
|
||||
}
|
||||
.study-audio-wrap audio {
|
||||
width: 88%;
|
||||
}
|
||||
.study-image-preview {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
.study-doc-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
.study-file-fallback {
|
||||
padding: 46px 18px;
|
||||
color: #7a8798;
|
||||
font-size: 15px;
|
||||
text-align: center;
|
||||
background: #ffffff;
|
||||
border-radius: 0;
|
||||
}
|
||||
.study-player-exit {
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
}
|
||||
.study-player-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-left: 12px;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="app" class="learning-study-page" v-cloak>
|
||||
<div class="study-hero">
|
||||
<div class="study-back" @click="goBack"><van-icon name="arrow-left"></van-icon></div>
|
||||
<img v-if="coverUrl" :src="coverUrl" :alt="course.courseName">
|
||||
<div v-else class="study-hero-empty">{{ course.courseName || '课程学习' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="study-title-block">
|
||||
<div class="study-title">{{ course.courseName || '课程学习' }}</div>
|
||||
<div class="study-meta">
|
||||
<span class="study-meta-item"><van-icon name="eye-o"></van-icon>{{ studyCountText }}</span>
|
||||
<span class="study-meta-item"><van-icon name="bookmark-o"></van-icon>收藏</span>
|
||||
<span class="study-meta-item"><van-icon name="share-o"></van-icon>分享</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<van-tabs v-model="activeTab" class="study-tabs" color="#2d9cff" line-width="34px" title-active-color="#202733">
|
||||
<van-tab title="课程介绍" name="intro">
|
||||
<div class="study-intro-scroll">
|
||||
<div class="study-intro">{{ course.courseIntro || '暂无课程介绍' }}</div>
|
||||
</div>
|
||||
</van-tab>
|
||||
<van-tab title="课程目录" name="catalog">
|
||||
<div class="study-catalog-scroll">
|
||||
<div class="study-summary">
|
||||
<div>共 <strong>{{ chapterCount }}</strong> 个章节,<strong>{{ totalMinutes }}</strong> 分钟</div>
|
||||
<div>总学习进度 <strong>{{ totalProgress }}%</strong></div>
|
||||
</div>
|
||||
|
||||
<div v-for="chapter in catalogList" :key="chapter.id" class="study-card">
|
||||
<div class="study-chapter">
|
||||
<div class="study-chapter-head" @click="selectChapter(chapter)">
|
||||
<span class="study-chapter-dot"></span>
|
||||
<span>{{ chapter.title }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="resource in chapter.resources"
|
||||
:key="resource.id"
|
||||
class="study-resource"
|
||||
@click="openResource(resource)">
|
||||
<div class="study-resource-icon" :class="resourceIconClass(resource)">
|
||||
<van-icon :name="resourceIcon(resource)"></van-icon>
|
||||
</div>
|
||||
<div class="study-resource-main">
|
||||
<div class="study-resource-title">{{ resource.title }}</div>
|
||||
<div class="study-resource-status">{{ statusText(resource) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<van-empty v-if="!catalogList.length" description="暂无课程目录"></van-empty>
|
||||
</div>
|
||||
</van-tab>
|
||||
</van-tabs>
|
||||
|
||||
<van-popup v-model="playerVisible" position="bottom" class="study-player-popup" :overlay="false">
|
||||
<div class="study-player-head">
|
||||
<div class="study-player-title">{{ selectedResource.title || '学习资料' }}</div>
|
||||
<div class="study-player-actions">
|
||||
<span v-if="selectedResource.resourceType === 'video'" @click="enterVideoFullscreen">横屏</span>
|
||||
<span @click="closePlayer">退出</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="study-player-body">
|
||||
<video
|
||||
v-if="selectedResource.resourceType === 'video'"
|
||||
ref="mediaPlayer"
|
||||
class="study-media"
|
||||
:src="mediaUrl"
|
||||
controls
|
||||
playsinline
|
||||
webkit-playsinline
|
||||
x5-video-player-type="h5"
|
||||
x5-video-orientation="landscape|portrait"
|
||||
x-webkit-airplay="allow"
|
||||
@loadedmetadata="restoreMediaPosition"
|
||||
@seeked="handleMediaSeeked"
|
||||
@seeking="handleMediaSeeking"
|
||||
@play="startStudy"
|
||||
@pause="saveMediaPosition(false)"
|
||||
@timeupdate="saveProgress"
|
||||
@ended="finishStudy(false, true)">
|
||||
</video>
|
||||
<div v-else-if="selectedResource.resourceType === 'audio'" class="study-audio-wrap">
|
||||
<audio
|
||||
ref="mediaPlayer"
|
||||
:src="fileUrl"
|
||||
controls
|
||||
@loadedmetadata="restoreMediaPosition"
|
||||
@seeked="handleMediaSeeked"
|
||||
@seeking="handleMediaSeeking"
|
||||
@play="startStudy"
|
||||
@pause="saveMediaPosition(false)"
|
||||
@timeupdate="saveProgress"
|
||||
@ended="finishStudy(false, true)">
|
||||
</audio>
|
||||
</div>
|
||||
<img v-else-if="selectedResource.resourceType === 'image'" class="study-image-preview" :src="fileUrl" :alt="selectedResource.title">
|
||||
<iframe v-else-if="canInlinePreview(selectedResource)" class="study-doc-frame" :src="inlinePreviewUrl"></iframe>
|
||||
<div v-else class="study-file-fallback">
|
||||
<p>该资料暂不支持内嵌预览</p>
|
||||
<van-button type="info" size="small" @click="openFile">打开资料</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
|
||||
<script nonce="${cspNonce!}">
|
||||
new Vue({
|
||||
el: "#app",
|
||||
data() {
|
||||
return {
|
||||
courseId: "",
|
||||
apiBase: "/platform/learning/course/display",
|
||||
recordApi: "/platform/learning/study/record",
|
||||
activeTab: "catalog",
|
||||
course: {},
|
||||
treeData: [],
|
||||
selectedResource: {},
|
||||
playerVisible: false,
|
||||
currentSegmentId: "",
|
||||
studying: false,
|
||||
pendingSeconds: 0,
|
||||
heartbeatTimer: null,
|
||||
resumePositionSeconds: 0,
|
||||
resumeSeekRetryCount: 0,
|
||||
resumePositionRestored: false,
|
||||
lastSavedPositionSeconds: 0,
|
||||
lastPositionSyncAt: 0,
|
||||
lastAllowedPositionSeconds: 0,
|
||||
restoringMediaPosition: false,
|
||||
pageVisibilityHandler: null,
|
||||
navigatingBack: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
coverUrl() {
|
||||
return this.getFileUrl(this.course.cover)
|
||||
},
|
||||
fileUrl() {
|
||||
return this.getFileUrl(this.selectedResource.fileData)
|
||||
},
|
||||
mediaUrl() {
|
||||
if (this.selectedResource.resourceType !== "video") return this.fileUrl
|
||||
const fileId = this.getFileId(this.selectedResource.fileData)
|
||||
// 视频必须使用支持 Range 的播放接口,否则手动拖动和历史位置恢复都会失效。
|
||||
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
|
||||
},
|
||||
catalogList() {
|
||||
const result = []
|
||||
;(this.treeData || []).forEach(node => this.appendCatalogNode(node, result))
|
||||
return result
|
||||
},
|
||||
chapterCount() {
|
||||
return this.catalogList.length
|
||||
},
|
||||
totalMinutes() {
|
||||
const seconds = this.catalogList.reduce((sum, chapter) => {
|
||||
return sum + chapter.resources.reduce((value, item) => value + Number(item.durationSeconds || 0), 0)
|
||||
}, 0)
|
||||
return Math.max(0, Math.ceil(seconds / 60))
|
||||
},
|
||||
totalProgress() {
|
||||
const resources = []
|
||||
this.catalogList.forEach(chapter => resources.push.apply(resources, chapter.resources))
|
||||
if (!resources.length) return 0
|
||||
const total = resources.reduce((sum, item) => sum + Number(item.progressPercent || 0), 0)
|
||||
return Math.floor(total / resources.length)
|
||||
},
|
||||
studyCountText() {
|
||||
return this.course.lecturerName ? this.course.lecturerName : "开始学习"
|
||||
}
|
||||
},
|
||||
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.$toast(resp.msg || "课程不存在")
|
||||
}
|
||||
},
|
||||
async loadTree() {
|
||||
const resp = await this.$axios.post(this.apiBase + "/studyTree", { courseId: this.courseId })
|
||||
if (resp.code === 0) {
|
||||
this.treeData = resp.data || []
|
||||
}
|
||||
},
|
||||
appendCatalogNode(node, result) {
|
||||
if (!node) return
|
||||
if (node.type === "resource") return
|
||||
const resources = this.collectResources(node.children || [])
|
||||
if (node.nodeType === "chapter" || resources.length) {
|
||||
result.push({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
resources: resources
|
||||
})
|
||||
}
|
||||
;(node.children || []).forEach(child => {
|
||||
if (child.type !== "resource" && child.nodeType === "chapter") {
|
||||
this.appendCatalogNode(child, result)
|
||||
}
|
||||
})
|
||||
},
|
||||
collectResources(nodes) {
|
||||
const resources = []
|
||||
;(nodes || []).forEach(node => {
|
||||
if (node.type === "resource") {
|
||||
resources.push(node)
|
||||
} else {
|
||||
resources.push.apply(resources, this.collectResources(node.children || []))
|
||||
}
|
||||
})
|
||||
return resources
|
||||
},
|
||||
selectChapter(chapter) {
|
||||
if (chapter && chapter.resources && chapter.resources.length) {
|
||||
this.openResource(chapter.resources[0])
|
||||
}
|
||||
},
|
||||
progressKey(resource) {
|
||||
const target = resource || this.selectedResource
|
||||
return "learning-progress-" + this.courseId + "-" + (target.id || "")
|
||||
},
|
||||
getResumePosition(resource) {
|
||||
// 仅以当前登录人的服务端学习记录续播,避免共用设备中的本地缓存导致首次学习被错误快进。
|
||||
const serverPosition = Number(resource.lastPositionSeconds || 0)
|
||||
return Math.max(0, serverPosition)
|
||||
},
|
||||
openResource(resource) {
|
||||
if (!resource || !resource.id) return
|
||||
this.pauseMedia()
|
||||
this.finishStudy(false)
|
||||
this.selectedResource = resource
|
||||
this.resumePositionSeconds = this.getResumePosition(resource)
|
||||
this.resumeSeekRetryCount = 0
|
||||
this.resumePositionRestored = this.resumePositionSeconds <= 0
|
||||
this.lastSavedPositionSeconds = 0
|
||||
this.lastPositionSyncAt = 0
|
||||
this.lastAllowedPositionSeconds = Number(resource.maxPositionSeconds || 0)
|
||||
this.restoringMediaPosition = false
|
||||
this.playerVisible = true
|
||||
if (!["video", "audio"].includes(resource.resourceType)) {
|
||||
this.$nextTick(() => this.startStudy())
|
||||
} else {
|
||||
this.$nextTick(() => this.restoreMediaPosition())
|
||||
}
|
||||
},
|
||||
restoreMediaPosition() {
|
||||
const player = this.$refs.mediaPlayer
|
||||
if (!player || !this.selectedResource.id || this.resumePositionRestored || this.resumePositionSeconds <= 0 || player.readyState < 1) return
|
||||
let targetPosition = this.resumePositionSeconds
|
||||
if (Number.isFinite(player.duration) && player.duration > 0) {
|
||||
// 已完成或接近结束的记录回退两秒,防止恢复后立即触发播放结束。
|
||||
targetPosition = Math.min(this.resumePositionSeconds, Math.max(0, Math.floor(player.duration) - 2))
|
||||
}
|
||||
if (targetPosition <= 0) {
|
||||
this.lastAllowedPositionSeconds = 0
|
||||
this.restoringMediaPosition = false
|
||||
this.resumePositionRestored = true
|
||||
return
|
||||
}
|
||||
this.resumePositionSeconds = targetPosition
|
||||
this.restoringMediaPosition = true
|
||||
player.currentTime = targetPosition
|
||||
},
|
||||
verifyMediaPosition() {
|
||||
const player = this.$refs.mediaPlayer
|
||||
if (!player || this.resumePositionRestored || this.resumePositionSeconds <= 0) return
|
||||
const actualPosition = Number(player.currentTime || 0)
|
||||
if (Math.abs(actualPosition - this.resumePositionSeconds) <= 1) {
|
||||
this.lastAllowedPositionSeconds = actualPosition
|
||||
this.restoringMediaPosition = false
|
||||
this.resumePositionRestored = true
|
||||
return
|
||||
}
|
||||
if (this.resumeSeekRetryCount < 1) {
|
||||
this.resumeSeekRetryCount += 1
|
||||
// 部分移动端容器首次定位会被播放器重置,收到 seeked 后再校验并重试一次。
|
||||
setTimeout(() => this.restoreMediaPosition(), 100)
|
||||
} else {
|
||||
this.lastAllowedPositionSeconds = actualPosition
|
||||
this.restoringMediaPosition = false
|
||||
this.resumePositionRestored = true
|
||||
}
|
||||
},
|
||||
// 原生播放器点击进度条会在 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()
|
||||
this.verifyMediaPosition()
|
||||
},
|
||||
async startStudy() {
|
||||
if (!this.selectedResource.id || this.studying) return
|
||||
const resp = await this.$axios.post(this.recordApi + "/start", {
|
||||
courseId: this.courseId,
|
||||
resourceId: this.selectedResource.id,
|
||||
positionSeconds: this.resumePositionRestored ? this.getMediaPosition() : this.resumePositionSeconds
|
||||
})
|
||||
if (resp.code !== 0) {
|
||||
this.$toast(resp.msg || "开始学习失败")
|
||||
return
|
||||
}
|
||||
this.currentSegmentId = resp.data.segmentId
|
||||
this.studying = true
|
||||
this.pendingSeconds = 0
|
||||
this.applyRecordState(resp.data)
|
||||
this.startHeartbeatTimer()
|
||||
},
|
||||
async heartbeat() {
|
||||
if (!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)
|
||||
}
|
||||
},
|
||||
// force 用于页面销毁时静默结束;completed 仅在视频或音频自然播放结束时传 true。
|
||||
async finishStudy(force, completed) {
|
||||
this.pauseMedia()
|
||||
if (!this.currentSegmentId) return
|
||||
const segmentId = this.currentSegmentId
|
||||
const seconds = this.pendingSeconds
|
||||
this.pendingSeconds = 0
|
||||
this.stopHeartbeatTimer()
|
||||
this.studying = false
|
||||
this.currentSegmentId = ""
|
||||
await this.$axios.post(this.recordApi + "/finish", {
|
||||
segmentId: segmentId,
|
||||
activeSeconds: seconds,
|
||||
positionSeconds: this.getMediaPosition(),
|
||||
completed: !!completed
|
||||
})
|
||||
if (force) return
|
||||
this.loadTree()
|
||||
},
|
||||
startHeartbeatTimer() {
|
||||
this.stopHeartbeatTimer()
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (!this.studying || document.hidden) return
|
||||
if (["video", "audio"].includes(this.selectedResource.resourceType)) {
|
||||
const player = this.$refs.mediaPlayer
|
||||
if (!player || player.paused || player.ended) return
|
||||
}
|
||||
this.pendingSeconds += 1
|
||||
if (this.pendingSeconds >= 15) {
|
||||
this.heartbeat()
|
||||
}
|
||||
}, 1000)
|
||||
},
|
||||
stopHeartbeatTimer() {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
},
|
||||
saveProgress() {
|
||||
const position = this.getMediaPosition()
|
||||
if (!position || !this.selectedResource.id) return
|
||||
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)
|
||||
this.$set(this.selectedResource, "lastPositionSeconds", position)
|
||||
try {
|
||||
localStorage.setItem(this.progressKey(), String(position))
|
||||
} catch (e) {
|
||||
// 无痕模式或受限容器可能禁用本地存储,继续使用服务端记录。
|
||||
}
|
||||
if (Date.now() - this.lastPositionSyncAt >= 5000) {
|
||||
this.saveServerPosition(position, false)
|
||||
}
|
||||
},
|
||||
saveMediaPosition(useBeacon) {
|
||||
const position = this.getMediaPosition()
|
||||
if (!this.selectedResource.id || position <= 0) return
|
||||
try {
|
||||
localStorage.setItem(this.progressKey(), String(position))
|
||||
} catch (e) {
|
||||
// 无痕模式或受限容器可能禁用本地存储,继续使用服务端记录。
|
||||
}
|
||||
this.saveServerPosition(position, useBeacon)
|
||||
},
|
||||
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()
|
||||
this.lastSavedPositionSeconds = seconds
|
||||
if (useBeacon && navigator.sendBeacon) {
|
||||
const formData = new URLSearchParams()
|
||||
formData.append("courseId", this.courseId)
|
||||
formData.append("resourceId", this.selectedResource.id)
|
||||
formData.append("positionSeconds", String(seconds))
|
||||
const body = new Blob([formData.toString()], { type: "application/x-www-form-urlencoded;charset=UTF-8" })
|
||||
navigator.sendBeacon(this.recordApi + "/position", body)
|
||||
return
|
||||
}
|
||||
// 播放中每五秒、暂停时立即同步,确保服务端续播位置不会只依赖学习心跳。
|
||||
this.$axios.post(this.recordApi + "/position", {
|
||||
courseId: this.courseId,
|
||||
resourceId: this.selectedResource.id,
|
||||
positionSeconds: seconds
|
||||
}).then((resp) => {
|
||||
if (resp.code !== 0) {
|
||||
this.lastSavedPositionSeconds = 0
|
||||
}
|
||||
})
|
||||
},
|
||||
applyRecordState(data) {
|
||||
if (!this.selectedResource.id || !data) return
|
||||
this.$set(this.selectedResource, "progressPercent", Number(data.progressPercent || 0))
|
||||
this.$set(this.selectedResource, "studySeconds", Number(data.studySeconds || 0))
|
||||
this.$set(this.selectedResource, "completeStatus", data.completeStatus || "studying")
|
||||
if (data.lastPositionSeconds !== undefined) {
|
||||
this.$set(this.selectedResource, "lastPositionSeconds", Number(data.lastPositionSeconds || 0))
|
||||
}
|
||||
if (data.maxPositionSeconds !== undefined) {
|
||||
const maxPositionSeconds = Number(data.maxPositionSeconds || 0)
|
||||
this.$set(this.selectedResource, "maxPositionSeconds", maxPositionSeconds)
|
||||
this.lastAllowedPositionSeconds = Math.max(Number(this.lastAllowedPositionSeconds || 0), maxPositionSeconds)
|
||||
}
|
||||
},
|
||||
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)
|
||||
},
|
||||
statusText(resource) {
|
||||
if (!resource || resource.completeStatus === "not_started") return "未学习"
|
||||
if (resource.completeStatus === "completed") return "已完成"
|
||||
return "学习中 " + Number(resource.progressPercent || 0) + "%"
|
||||
},
|
||||
resourceIcon(resource) {
|
||||
if (resource.resourceType === "video") return "video"
|
||||
if (resource.resourceType === "audio") return "music-o"
|
||||
if (resource.resourceType === "image") return "photo-o"
|
||||
return "description"
|
||||
},
|
||||
resourceIconClass(resource) {
|
||||
if (resource.resourceType === "video") return "video"
|
||||
if (resource.resourceType === "audio") return "audio"
|
||||
if (resource.resourceType === "image") return "image"
|
||||
return "doc"
|
||||
},
|
||||
getFileUrl(value) {
|
||||
if (!value) return ""
|
||||
if (Array.isArray(value)) {
|
||||
return value.length ? (value[0].url || (value[0].response && value[0].response.data) || value[0].data || "") : ""
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const text = value.trim()
|
||||
if (!text) return ""
|
||||
if (text.startsWith("[")) {
|
||||
try {
|
||||
const files = JSON.parse(text)
|
||||
return files.length ? (files[0].url || (files[0].response && files[0].response.data) || files[0].data || "") : ""
|
||||
} catch (e) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
return ""
|
||||
},
|
||||
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)
|
||||
},
|
||||
openFile() {
|
||||
if (this.fileUrl) {
|
||||
window.open(this.fileUrl)
|
||||
}
|
||||
},
|
||||
pauseMedia() {
|
||||
const player = this.$refs.mediaPlayer
|
||||
if (player && player.pause) {
|
||||
player.pause()
|
||||
}
|
||||
},
|
||||
enterVideoFullscreen() {
|
||||
const player = this.$refs.mediaPlayer
|
||||
if (!player) return
|
||||
const requestFullscreen = player.requestFullscreen || player.webkitRequestFullscreen || player.mozRequestFullScreen || player.msRequestFullscreen
|
||||
if (requestFullscreen) {
|
||||
requestFullscreen.call(player)
|
||||
} else if (player.webkitEnterFullscreen) {
|
||||
player.webkitEnterFullscreen()
|
||||
}
|
||||
if (screen.orientation && screen.orientation.lock) {
|
||||
screen.orientation.lock("landscape").catch(() => {})
|
||||
}
|
||||
},
|
||||
closePlayer() {
|
||||
this.pauseMedia()
|
||||
this.finishStudy(false)
|
||||
this.playerVisible = false
|
||||
},
|
||||
async goBack() {
|
||||
if (this.navigatingBack) return
|
||||
this.navigatingBack = true
|
||||
try {
|
||||
await this.finishStudy(true)
|
||||
} finally {
|
||||
this.navigateBack()
|
||||
}
|
||||
},
|
||||
navigateBack() {
|
||||
if (window.history.length > 1) {
|
||||
window.history.back()
|
||||
return
|
||||
}
|
||||
$.pjax({
|
||||
url: "/platform/learning/course/h5",
|
||||
container: "#container",
|
||||
maxCacheLength: 0,
|
||||
push: false,
|
||||
replace: true,
|
||||
fragment: "#container",
|
||||
timeout: 8000
|
||||
})
|
||||
},
|
||||
handlePageVisibility(event) {
|
||||
if (document.hidden || (event && event.type === "pagehide")) {
|
||||
this.saveMediaPosition(true)
|
||||
}
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
this.courseId = this.getQuery("id")
|
||||
if (!this.courseId) {
|
||||
this.$toast("请选择课程")
|
||||
return
|
||||
}
|
||||
await this.loadCourse()
|
||||
await this.loadTree()
|
||||
this.pageVisibilityHandler = (event) => this.handlePageVisibility(event)
|
||||
document.addEventListener("visibilitychange", this.pageVisibilityHandler)
|
||||
window.addEventListener("pagehide", this.pageVisibilityHandler)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.saveMediaPosition(true)
|
||||
this.finishStudy(true)
|
||||
this.stopHeartbeatTimer()
|
||||
if (this.pageVisibilityHandler) {
|
||||
document.removeEventListener("visibilitychange", this.pageVisibilityHandler)
|
||||
window.removeEventListener("pagehide", this.pageVisibilityHandler)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<!--#
|
||||
}
|
||||
#-->
|
||||
Reference in New Issue
Block a user