This commit is contained in:
2026-07-23 18:30:30 +08:00
parent 86ab0142da
commit 785b9a4b80
57 changed files with 1795 additions and 340 deletions
@@ -22,25 +22,29 @@ const flowMixins = {
background: 'rgba(0, 0, 0, 0.7)'
});
console.log(this.$refs)
console.log(this.$refs.guava)
// this.oepnAudit({})
//
//
// this.$axios.post(location.pathname + '/queryTask', {taskId}).then(res => {
// if (res.code === 0) {
// // 延迟1.5秒 显示loading动画 解决Guava异步加载 获取不到实例的问题
// setTimeout(() => {
// if (res.data.taskState === 10) {
// this.openAudit(res.data);
// } else {
// this.openView(res.data);
// }
// loading.close();
// }, 1500)
// }
// })
let delayCloseLoading = false
this.$axios.post(location.pathname + "/queryTask", {taskId}).then((res) => {
if (res.code === 0 && res.data) {
delayCloseLoading = true
// Guava 为异步组件,等待组件实例完成挂载后再打开任务弹窗。
setTimeout(() => {
try {
if (res.data.taskState === 10) {
this.openAudit(res.data)
} else {
this.openView(res.data)
}
} finally {
loading.close()
}
}, 1500)
}
}).finally(() => {
// 查询失败或未返回任务数据时,必须关闭全屏 Loading。
if (!delayCloseLoading) {
loading.close()
}
})
},
}
}
@@ -71,11 +71,13 @@ const initTableMixins = {
const address = this.pageDataUrl ? this.pageDataUrl : loc() + "/pageData"
this.tableLoading = true
this.$axios.post(address, data ? data : this.pageForm).then((res) => {
this.tableLoading = false
if (res.code === 0) {
this.tableData = res.data.list
this.pageForm.totalCount = res.data.totalCount
}
}).finally(() => {
// 接口异常时同样结束加载状态,避免表格持续显示加载动画。
this.tableLoading = false
})
},
notifySuccess(msg) {
@@ -35,8 +35,9 @@
:accept="fileAccept"
:multiple="true"
list-type="picture-card"
:class="{ 'image-upload-entry-hidden': !showImageUploadEntry }"
>
<i class="el-icon-plus"></i>
<i v-if="showImageUploadEntry" class="el-icon-plus"></i>
<!-- <div class="el-upload__tip" v-if="fileList.length < upload_number">-->
<!-- <div style="margin-top: 8px">{{ upload_text }}</div>-->
<!-- </div>-->
@@ -208,6 +209,10 @@ module.exports = {
const number = Number(this.upload_number)
return Number.isFinite(number) && number > 0 ? number : 1
},
// 单图上传完成后隐藏上传入口;删除图片后 fileList 清空,入口会自动恢复。
showImageUploadEntry() {
return this.uploadLimitNumber !== 1 || this.fileList.length < this.uploadLimitNumber
},
upload_tips() {
const uploadNumber = this.uploadLimitNumber
const fileAccept = this.fileAccept
@@ -655,4 +660,10 @@ module.exports = {
height: var(--upload-height, 148px);
}
/* 单图已上传时只隐藏图片卡片上传入口,保留图片预览列表与删除按钮。 */
.image-upload-entry-hidden.el-upload--picture-card,
.image-upload-entry-hidden .el-upload--picture-card {
display: none !important;
}
</style>
@@ -93,6 +93,22 @@ layout("/layouts/v4/baseLayout.html"){
font-weight: 500;
}
/* 推荐入口的 scoped 样式会在组件挂载后注入,此处提前限制图片尺寸,避免刷新时原图短暂撑开页面。 */
#v4-home-app .entry-card .app-icon {
width: 56px;
height: 56px;
overflow: hidden;
}
#v4-home-app .entry-card .app-icon img {
display: block;
width: 56px;
height: 56px;
max-width: 56px;
max-height: 56px;
object-fit: contain;
}
</style>
<div class="v4-container" id="v4-home-app">
@@ -140,8 +140,8 @@ layout("/layouts/platform.html"){
<template slot-scope="{row}">{{getResourceTypeName(row.resourceType)}}</template>
</el-table-column>
<el-table-column label="文件格式" prop="fileExt" sortable="custom" width="110"></el-table-column>
<el-table-column label="学习时长(分钟)" prop="durationSeconds" sortable="custom" width="140">
<template slot-scope="{row}">{{formatDurationMinutes(row.durationSeconds)}}</template>
<el-table-column label="学习时长()" prop="durationSeconds" sortable="custom" width="140">
<template slot-scope="{row}">{{formatDurationSeconds(row.durationSeconds)}}</template>
</el-table-column>
<el-table-column label="排序" prop="sortOrder" sortable="custom" width="90"></el-table-column>
<el-table-column label="必学" prop="required" sortable="custom" width="90">
@@ -331,8 +331,8 @@ layout("/layouts/platform.html"){
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="学习时长(分钟)">
<el-input-number v-model="resourceForm.durationMinutes" :min="0" :precision="0" style="width: 100%"></el-input-number>
<el-form-item label="学习时长()">
<el-input-number v-model="resourceForm.durationSeconds" :min="0" :precision="0" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
@@ -708,8 +708,7 @@ layout("/layouts/platform.html"){
if (row) {
this.resourceForm = Object.assign({}, row, {
outlineName: target.title,
fileData: this.parseFiles(row.fileData),
durationMinutes: this.formatDurationMinutes(row.durationSeconds)
fileData: this.parseFiles(row.fileData)
})
} else {
this.resourceForm = {
@@ -721,7 +720,6 @@ layout("/layouts/platform.html"){
fileExt: "",
fileData: [],
durationSeconds: 0,
durationMinutes: 0,
sortOrder: 1,
required: false,
allowPreview: true,
@@ -742,14 +740,12 @@ layout("/layouts/platform.html"){
this.$refs.resourceForm.validate(async valid => {
if (!valid) return
const data = Object.assign({}, this.resourceForm)
data.durationSeconds = Math.round((data.durationMinutes || 0) * 60)
data.fileExt = this.getFileExt(data.fileData)
if (!this.validResourceExt(data.resourceType, data.fileExt)) {
this.$message.warning("当前资料类型不支持上传 ." + data.fileExt + " 格式文件")
return
}
delete data.outlineName
delete data.durationMinutes
data.fileData = JSON.stringify(data.fileData || [])
const resp = await this.$axios.post(loc() + "/saveResource", data)
if (resp.code === 0) {
@@ -826,6 +822,10 @@ layout("/layouts/platform.html"){
const seconds = Number(durationSeconds || 0)
return seconds > 0 ? Math.ceil(seconds / 60) : 0
},
formatDurationSeconds(durationSeconds) {
const seconds = Math.ceil(Number(durationSeconds || 0))
return seconds > 0 ? seconds + "秒" : "0秒"
},
autoFillMediaDuration() {
if (!["video", "audio"].includes(this.resourceForm.resourceType)) return
const url = this.getFileUrl(this.resourceForm.fileData)
@@ -835,7 +835,8 @@ layout("/layouts/platform.html"){
media.onloadedmetadata = () => {
window.URL.revokeObjectURL(media.src)
if (isFinite(media.duration) && media.duration > 0) {
this.$set(this.resourceForm, "durationMinutes", Math.ceil(media.duration / 60))
// 媒体时长按秒保存,避免先取整到分钟再换算导致实际时长被放大。
this.$set(this.resourceForm, "durationSeconds", Math.ceil(media.duration))
}
}
media.onerror = () => {
@@ -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,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>
<!--#
}
#-->
@@ -85,6 +85,7 @@ layout("/layouts/platform.html"){
</el-tag>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="观看次数" prop="viewCount" sortable="custom" width="120"></el-table-column>
<el-table-column align="center" header-align="center" label="排序编码" prop="sortNum" sortable="custom" width="120"></el-table-column>
<el-table-column align="center" header-align="center" label="操作" width="260">
<template slot-scope="{row}">
@@ -159,11 +160,16 @@ layout("/layouts/platform.html"){
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-col :span="6">
<el-form-item label="排序编码" prop="sortNum">
<el-input-number v-model="formData.sortNum" :controls="false" :min="0" :precision="0" style="width: 100%" placeholder="请输入排序编码"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="观看次数" prop="viewCount">
<el-input-number v-model="formData.viewCount" :controls="false" :min="0" :precision="0" style="width: 100%" placeholder="请输入观看次数"></el-input-number>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="课程介绍" prop="courseIntro">
<el-input v-model="formData.courseIntro" type="textarea" maxlength="2000" :rows="4" show-word-limit placeholder="请输入课程简介"></el-input>
@@ -224,6 +230,7 @@ layout("/layouts/platform.html"){
lecturerName: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
status: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
sortNum: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
viewCount: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
cover: [{ required: true, message: "请上传课程封面", trigger: ["blur", "change"] }]
}
}
@@ -239,11 +246,12 @@ layout("/layouts/platform.html"){
const address = this.pageDataUrl ? this.pageDataUrl : loc() + "/pageData"
this.tableLoading = true
this.$axios.post(address, data ? data : this.buildPageParams()).then((res) => {
this.tableLoading = false
if (res.code === 0) {
this.tableData = res.data.list
this.pageForm.totalCount = res.data.totalCount
}
}).finally(() => {
this.tableLoading = false
})
},
resetSearch() {
@@ -269,6 +277,7 @@ layout("/layouts/platform.html"){
status: "draft",
recommendFlagList: [],
sortNum: 0,
viewCount: 0,
cover: ""
}
this.dialogVisible = true
@@ -280,7 +289,8 @@ layout("/layouts/platform.html"){
this.title = "编辑课程"
this.formData = Object.assign({}, row, {
openTime: row.startTime && row.endTime ? [this.formatTime(row.startTime), this.formatTime(row.endTime)] : [],
recommendFlagList: this.splitValue(row.recommendFlags)
recommendFlagList: this.splitValue(row.recommendFlags),
viewCount: row.viewCount == null ? 0 : row.viewCount
})
this.dialogVisible = true
this.$nextTick(() => {
@@ -296,7 +306,7 @@ layout("/layouts/platform.html"){
}
},
operation() {
this.$refs.courseForm.validate(async (valid) => {
this.$refs.courseForm.validate((valid) => {
if (!valid) return
if (this.formData.openType !== "long_term" && (!this.formData.openTime || this.formData.openTime.length !== 2)) {
this.$message.warning("请选择开课时间")
@@ -310,15 +320,17 @@ layout("/layouts/platform.html"){
delete data.recommendFlagList
const method = data.id ? "/doEdit" : "/doAdd"
this.subDis = true
const resp = await this.$axios.post(loc() + method, data)
this.subDis = false
if (resp.code === 0) {
this.dialogVisible = false
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.warning(resp.msg)
}
this.$axios.post(loc() + method, data).then((resp) => {
if (resp.code === 0) {
this.dialogVisible = false
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.warning(resp.msg)
}
}).finally(() => {
this.subDis = false
})
})
},
doDelete(row) {
@@ -326,25 +338,28 @@ layout("/layouts/platform.html"){
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(async () => {
const resp = await this.$axios.post(loc() + "/doDelete", { id: row.id })
}).then(() => {
this.$axios.post(loc() + "/doDelete", { id: row.id }).then((resp) => {
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.error(resp.msg)
}
})
})
},
getCourseTypes() {
return this.$axios.post(loc() + "/courseTypes").then((resp) => {
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.error(resp.msg)
this.courseTypeOptions = resp.data
}
})
},
async getCourseTypes() {
const resp = await this.$axios.post(loc() + "/courseTypes")
if (resp.code === 0) {
this.courseTypeOptions = resp.data
}
},
async getLearningDict(name) {
const resp = await this.$axios.post(loc() + "/learningDictOptions", { name })
return resp.code === 0 ? resp.data : []
getLearningDict(name) {
return this.$axios.post(loc() + "/learningDictOptions", { name }).then((resp) => {
return resp.code === 0 ? resp.data : []
})
},
splitValue(value) {
return value ? value.split(",").filter(Boolean) : []
@@ -383,11 +398,12 @@ layout("/layouts/platform.html"){
return time ? this.$moment(time).format("YYYY-MM-DD HH:mm:ss") : ""
}
},
async created() {
created() {
this.pageForm.courseTime = []
await this.getCourseTypes()
this.recommendOptions = await this.getLearningDict("推荐标识")
this.pageData()
Promise.all([this.getCourseTypes(), this.getLearningDict("推荐标识")]).then((values) => {
this.recommendOptions = values[1]
this.pageData()
})
}
})
</script>
@@ -44,7 +44,7 @@ layout("/layouts/platform.html"){
border
@sort-change="pageOrder">
<el-table-column :index="indexMethod" align="center" header-align="center" label="序号" type="index" width="70"></el-table-column>
<el-table-column align="center" header-align="center" label="课程名称" prop="courseName" sortable="custom" show-overflow-tooltip min-width="220" width="360"></el-table-column>
<el-table-column align="center" header-align="center" label="课程名称" prop="courseName" sortable="custom" show-overflow-tooltip min-width="220" ></el-table-column>
<el-table-column align="center" header-align="center" label="学习人数" prop="learnerCount" sortable="custom" width="130"></el-table-column>
<el-table-column align="center" header-align="center" label="完成人数" prop="completedCount" sortable="custom" width="130"></el-table-column>
<el-table-column align="center" header-align="center" label="平均学习时长" prop="avgStudySeconds" sortable="custom" width="170">
@@ -32,6 +32,9 @@ layout("/layouts/platform.html"){
<el-card shadow="never" class="mt10">
<table-tool label="温馨提示:请先更新拨付金额后再点击核对按钮">
<template v-if="$auth.hasRoleOr(['SYSADMIN', 'SCHOOL_UNION_CLUB_ADMIN'])">
<el-button @click="exportSummary" size="mini" type="primary">
导出
</el-button>
<el-button @click="closeAudit(false)" size="mini" type="primary">
开启核对入口
</el-button>
@@ -169,6 +172,12 @@ layout("/layouts/platform.html"){
})
})
},
exportSummary() {
this.$downLoad("/platform/outlay/outlayManage/query/exportSummary", {
year: this.pageForm.year,
clubId: this.pageForm.clubId
})
},
doMoney(row) {
this.$confirm('更新前请前往【缴费管理】菜单,核对是否缴费。核对准确后,再点击更新拨付金额,确定要更新吗?', '提示', {
confirmButtonText: '更新',
@@ -37,7 +37,7 @@ layout("/layouts/platform.html"){
</el-card>
<el-card shadow="never" class="mt10">
<table-tool label="预算使用情况">
<el-button size="mini" type="primary" @click="doExport">导出</el-button>
</table-tool>
<el-table :data="tableData" style="width: 100%" row-key="id"
v-loading="tableLoading" :size="tableSize" class="vi-table">
@@ -110,6 +110,12 @@ layout("/layouts/platform.html"){
this.$refs.detailInfo.open(row)
})
},
doExport() {
this.$downLoad("/platform/outlay/outlayManage/clubUseDetail/doExport", {
year: this.pageForm.year,
clubId: this.pageForm.clubId
})
},
},
async created() {
@@ -109,10 +109,10 @@ const PROPOSAL_INFO = {
</div>
</el-dialog>
<!--审核信息采用手风琴展示同一时间最多展开条记录避免流程较长时页面内容一次性全部铺开-->
<!--审核信息支持按需同时展开条记录初次加载和切换提案时仍保持全部折叠-->
<template v-if="doneTasks.length">
<div class="process-title">审核信息</div>
<el-collapse v-model="activeTaskId" accordion>
<el-collapse v-model="activeTaskIds">
<el-collapse-item v-for="task in doneTasks" :key="task.id" :name="task.id">
<template slot="title">
<!--折叠标题使用独立的单行布局避免复用 process-title 后产生外边距和高度冲突-->
@@ -121,7 +121,7 @@ const PROPOSAL_INFO = {
{{ task.displayName }}
</span>
<span style="flex-shrink: 0;margin-right: 8px;color: var(--color-primary);font-weight: 400">
{{ activeTaskId === task.id ? '收起' : '展开' }}
{{ activeTaskIds.includes(task.id) ? '收起' : '展开' }}
</span>
</div>
</template>
@@ -232,7 +232,7 @@ const PROPOSAL_INFO = {
return {
viewData: {},
doneTasks: [],
activeTaskId: null,
activeTaskIds: [],
row: null,
viewDialogVisible: false
}
@@ -244,7 +244,7 @@ const PROPOSAL_INFO = {
this.visible = true
// 切换提案时先清空上一条提案的审核记录和展开状态,避免异步加载期间展示旧数据。
this.doneTasks = []
this.activeTaskId = null
this.activeTaskIds = []
this.getInfo()
this.getDoneTasks()
},
@@ -273,7 +273,7 @@ const PROPOSAL_INFO = {
const tasks = res.data || []
this.doneTasks = tasks
// 审核记录加载完成后默认全部折叠,由用户按需展开查看。
this.activeTaskId = null
this.activeTaskIds = []
// 通知外层页面已办节点数据已加载完成,便于当前环节做表单回显。
this.$emit("done-tasks", tasks)
}
@@ -82,7 +82,7 @@ layout("/layouts/platform.html"){
</el-form-item>
</el-form>
<el-row type="flex" justify="end" v-if="row.curTaskCode ==='second'">
<el-button @click="$refs.guava.index()" size="small">返回返回</el-button>
<el-button @click="$refs.guava.index()" size="small">返回</el-button>
<el-button @click="handleTaskAction(20)" size="small" type="danger">不同意</el-button>
<el-button @click="handleTaskAction(1)" size="small" type="primary">同意</el-button>
</el-row>
@@ -5,15 +5,13 @@ layout("/layouts/platform.html"){
<style></style>
<div id="app" v-cloak>
<template>
<el-card shadow="never">
<snaker-start slot="header" label="撰写提案" define_key="JDHTA_NC">
<template slot="header-right-label">
<el-link type="primary" @click="openImport" v-if="!formData.id" style="margin-right: 15px">导入提案</el-link>
</template>
</snaker-start>
<template>
<el-form :model="formData" :rules="formRules" label-width="120px" ref="addForm">
<custom-card>
<snaker-start slot="header" label="撰写提案" define_key="JDHTA_NC">
<template slot="header-right-label">
<el-link type="primary" @click="openImport" v-if="!formData.id" style="margin-right: 15px">导入提案</el-link>
</template>
</snaker-start>
<el-form :model="formData" :rules="formRules" label-width="120px" ref="addForm">
<el-row :gutter="20" type="flex">
<el-col :span="12">
<el-form-item label="代表姓名" prop="createUserName">
@@ -162,15 +160,13 @@ layout("/layouts/platform.html"){
<!-- <el-form-item label="电子签名" prop="signature">-->
<!-- <pc-signature v-model="formData.signature"></pc-signature>-->
<!-- </el-form-item>-->
</el-form>
<el-row justify="end" type="flex" v-if="isWriteTime">
<el-button type="primary" @click="onSave">保存</el-button>
<el-button type="primary" @click="onSubmit" v-if="!taskId">提交</el-button>
<el-button type="primary" @click="onSubmitAgain" v-else>提交</el-button>
</el-row>
</template>
</el-card>
</template>
</el-form>
<template slot="footer" v-if="isWriteTime">
<el-button type="primary" @click="onSave" :loading="formLoading">保存</el-button>
<el-button type="primary" @click="onSubmit" v-if="!taskId" :loading="formLoading">提交</el-button>
<el-button type="primary" @click="onSubmitAgain" v-else :loading="formLoading">提交</el-button>
</template>
</custom-card>
<el-dialog title="提案撰写须知" :visible.sync="noticeDialogVisible" :show-close="false" width="50%">
<div v-html="proposalConfig.writeRemind" style="max-height: 50vh; overflow-y: auto"></div>
@@ -291,29 +287,36 @@ layout("/layouts/platform.html"){
}
},
methods: {
async onSave() {
const valid = await this.$refs["addForm"].validate()
if (valid) {
onSave() {
this.$refs["addForm"].validate((valid) => {
if (!valid) {
return
}
if (!this.formData.name || this.formData.name.trim().length < 1) {
this.$message.warning("请填写提案名称")
return
}
this.formLoading = true
this.$axios.post("/platform/proposal/write/save", {info: JSON.stringify(this.formData)}).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg)
this.formData = res.data
commonUtil.pjaxPush('/platform/proposal/mine')
}
}).finally(() => {
this.formLoading = false
})
}
})
},
// 提交
async onSubmit() {
const valid = await this.$refs["addForm"].validate()
if (valid) {
onSubmit() {
this.$refs["addForm"].validate((valid) => {
if (!valid) {
return
}
if (!this.formData.name || this.formData.name.trim().length < 1) {
this.$message.warning("请填写提案名称")
return
@@ -330,9 +333,8 @@ layout("/layouts/platform.html"){
cancelButtonText: "取消",
type: "warning"
}).then(() => {
const loading = createLoading('正在提交中')
this.$axios.post('/platform/proposal/write/submit', {info: JSON.stringify(this.formData)}).then(res => {
loading.close()
this.formLoading = true
this.$axios.post('/platform/proposal/write/submit', {info: JSON.stringify(this.formData)}).then((res) => {
if (res.code === 0) {
this.$message.success("提交成功")
if (this.formData.source==="PERSONAL"){
@@ -343,7 +345,6 @@ layout("/layouts/platform.html"){
}).then(() => {
commonUtil.pjaxPush('/platform/proposal/mine?bizId=' + res.data.id + "&operation=invite")
}).catch(() => {
loading.close()
this.$message.info("请到我的提案界面邀请附议人")
commonUtil.pjaxPush('/platform/proposal/mine')
})
@@ -352,15 +353,15 @@ layout("/layouts/platform.html"){
}
}
}).catch(() => {
loading.close()
}).finally(() => {
this.formLoading = false
})
})
}
})
},
// 打开导入窗口
async openImport() {
openImport() {
this.importData = {
fileList: []
}
@@ -368,7 +369,7 @@ layout("/layouts/platform.html"){
},
// 下载导入模板
async downloadTemplate() {
downloadTemplate() {
let templateType = '.xlsx'
if(this.importTemplateType === 2) {
templateType = '.docx'
@@ -377,7 +378,7 @@ layout("/layouts/platform.html"){
},
// 开始导入数据
async doImport() {
doImport() {
if (!this.importData.fileList.length) {
this.notifyWarning("请选择文件!")
return
@@ -386,7 +387,7 @@ layout("/layouts/platform.html"){
this.importData.fileList.forEach((val) => {
data.append("file", val.raw, val.raw.name);
});
this.importLoading = false
this.importLoading = true
$.ajax({
url: loc() + "/importProposal?templateType=" + this.importTemplateType,
type: "post",
@@ -403,20 +404,21 @@ layout("/layouts/platform.html"){
} else {
this.notifyWarning(data.msg)
}
this.importLoading = false
},
error: (data) => {
error: () => {
this.notifyWarning("导入失败")
this.importLoading = false
}
});
}).always(() => {
this.importLoading = false
})
},
// 重新提交
async onSubmitAgain() {
const valid = await this.$refs["addForm"].validate()
if (valid) {
onSubmitAgain() {
this.$refs["addForm"].validate((valid) => {
if (!valid) {
return
}
if (!this.formData.name || this.formData.name.trim().length < 1) {
this.$message.warning("请填写提案名称")
return
@@ -433,17 +435,20 @@ layout("/layouts/platform.html"){
cancelButtonText: "取消",
type: "warning"
}).then(() => {
this.formLoading = true
this.$axios.post('/platform/proposal/write/submitAgain', {
info: JSON.stringify(this.formData),
taskId: GetQueryString("taskId")
}).then(res => {
}).then((res) => {
if (res.code === 0) {
this.$message.success("提交成功")
commonUtil.pjaxPush('/platform/proposal/mine')
}
}).finally(() => {
this.formLoading = false
})
})
}
})
},
// 根据当前教代会届次获取允许撰写的提案类型
@@ -471,7 +476,7 @@ layout("/layouts/platform.html"){
},
//教代会change
async meetingChange(val) {
meetingChange(val) {
// 切换届次后,原提案类型不再有效,需按新届次重新选择
this.$set(this.formData, "typeId", null)
this.typeOptions = []
@@ -480,8 +485,6 @@ layout("/layouts/platform.html"){
this.formData.committeeId = null
this.listDelegation()
this.searchMineDelegation()
// this.delegationOptions = await proposal.getDelegation(val)
// this.committeeOptions = await this.getInstitutions(val)
},
@@ -505,19 +508,19 @@ layout("/layouts/platform.html"){
// 查询开启的教代会
listOpenSession(isModify = false) {
this.$axios.post("/platform/proposal/common/listOpenSession").then(async (res) => {
return this.$axios.post("/platform/proposal/common/listOpenSession").then((res) => {
if (res.code === 0) {
this.sessionOptions = res.data
let delegationPromise = Promise.resolve()
if (!isModify && this.sessionOptions) {
this.$set(this.formData, "sessionId", this.sessionOptions[0].id)
// 获取代表团
await this.searchMineDelegation()
// 检查是否在撰写时间内
this.checkWriteTime()
delegationPromise = this.searchMineDelegation().then(() => {
// 检查是否在撰写时间内
this.checkWriteTime()
})
}
await this.listDelegation()
this.listProposalType()
// await this.listSource()
return delegationPromise.then(() => this.listDelegation()).then(() => this.listProposalType())
}
})
},
@@ -532,10 +535,10 @@ layout("/layouts/platform.html"){
},
// 查询字典 撰写方式
async getDictByCode(code) {
const res = await $.get("/platform/proposal/write/listSourceByCode", {code: code})
console.log('getDictByCode', res)
return res.data;
getDictByCode(code) {
return this.$axios.post("/platform/proposal/write/listSourceByCode", {code: code}).then((res) => {
return res.data
})
},
//查询自己有权限的代表团
@@ -561,7 +564,7 @@ layout("/layouts/platform.html"){
})
},
async init() {
init() {
if (this.bizId) {
this.$axios.post("/platform/proposal/write/detail", {id: this.bizId}).then((res) => {
if (res.code === 0) {
@@ -582,8 +585,10 @@ layout("/layouts/platform.html"){
}
// 撰写方式
this.allSourceOptions = await this.getDictByCode("PROPOSAL_SOURCE")
this.sourceOptions = this.allSourceOptions
this.getDictByCode("PROPOSAL_SOURCE").then((data) => {
this.allSourceOptions = data
this.sourceOptions = data
})
}
},
created() {
@@ -23,13 +23,13 @@ layout("/layouts/platform.html"){
<el-table-column prop="userName" label="姓名"></el-table-column>
<el-table-column prop="unionName" label="所属工会"></el-table-column>
<el-table-column prop="unitName" label="所属单位"></el-table-column>
<el-table-column prop="taskName" label="当前节点"></el-table-column>
<!--<el-table-column prop="taskName" label="当前节点"></el-table-column>
<el-table-column prop="instanceState" label="流程状态">
<template slot-scope="{row}">
<enum-tag :value="row.instanceState" name="ProcessInstanceStateEnum" label_key="message"
size="small"></enum-tag>
</template>
</el-table-column>
</el-table-column>-->
<el-table-column label="操作" fixed="right" width="300px">
<template slot-scope="{row}">
<el-button @click="openView(row)" size="mini" type="primary">查看</el-button>
@@ -108,6 +108,11 @@ layout("/layouts/platform.html"){
data() {
return {
pageForm: {
pageNumber: 1,
pageSize: 10,
totalCount: 0,
pageOrderName: "",
pageOrderBy: "",
approval: false
},
unionOptions: [],
@@ -21,9 +21,10 @@ const INFO = {
<el-descriptions-item label="学位">{{ viewData.academicDegree }}</el-descriptions-item>
<el-descriptions-item label="党政职务">{{ viewData.position }}</el-descriptions-item>
<el-descriptions-item label="工作单位">{{ viewData.unitname }}</el-descriptions-item>
<el-descriptions-item label="所属工会">{{ viewData.unionname }}</el-descriptions-item>
<el-descriptions-item label="所属校区">{{ viewData.campus }}</el-descriptions-item>
<el-descriptions-item label="工作单位">{{ viewData.unitName }}</el-descriptions-item>
<el-descriptions-item label="所属工会">{{ viewData.unionName }}</el-descriptions-item>
<el-descriptions-item v-if="showThreeUnit" label="三级单位">{{ viewData.threeUnitName || '暂无' }}</el-descriptions-item>
<el-descriptions-item v-else label="所属校区">{{ viewData.campus }}</el-descriptions-item>
<el-descriptions-item label="身份证号码">{{ viewData.idCard }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ viewData.mobile }}</el-descriptions-item>
@@ -44,11 +45,7 @@ const INFO = {
<el-descriptions-item label="会员状态">{{ viewData.member ? '会员' : '非会员' }}
</el-descriptions-item>
<el-descriptions-item label="福利会员状态">{{ viewData.welfareMember ? '福利会员' : '非福利会员'
}}
</el-descriptions-item>
<el-descriptions-item></el-descriptions-item>
<!-- <template v-if="viewData.loginname == $store.state.user.loginname">-->
<template>
<el-descriptions-item label="家庭主要成员" :span="3">
@@ -134,6 +131,9 @@ const INFO = {
`,
store,
mixins: [initTableMixins],
props: {
showThreeUnit: { type: Boolean, default: false }
},
dicts: ["PROCESS_TASK_SUBMIT_TYPE"],
data(){
return {
@@ -101,12 +101,21 @@ const MEMBER_CHANGE = {
</el-form-item>
</el-descriptions-item>
</template>
<el-descriptions-item label="所属校区">
<el-form-item prop="campus">
<dict-select style="width: 100%" placeholder="请选择所属校区" v-model="formData.campus"
:disabled="allowFields('campus')" code="USER_CAMPUS"></dict-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item v-if="showThreeUnit" label="三级单位">
<el-form-item prop="threeUnitId">
<el-select v-model="formData.threeUnitId" clearable filterable
placeholder="请选择三级单位" style="width: 100%">
<el-option v-for="item in threeUnits" :key="item.id" :label="item.name"
:value="item.id"></el-option>
</el-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item v-else label="所属校区">
<el-form-item prop="campus">
<dict-select style="width: 100%" placeholder="请选择所属校区" v-model="formData.campus"
:disabled="allowFields('campus')" code="USER_CAMPUS"></dict-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="身份证号码">
<el-form-item prop="idCard">
@@ -278,6 +287,7 @@ const MEMBER_CHANGE = {
`,
props: {
id: { type: String, default: '' },
showThreeUnit: { type: Boolean, default: false },
},
mixins: [initTableMixins],
store,
@@ -287,6 +297,7 @@ const MEMBER_CHANGE = {
user: {},
units: [],
unions: [],
threeUnits: [],
// 允许变更的字段
allowChangeFields: [],
formData: {
@@ -381,6 +392,31 @@ const MEMBER_CHANGE = {
allowFields(prop) {
return !this.allowChangeFields.map(v => v.code).includes(prop)
},
// 管理页仅查询当前被编辑人员所属二级单位的直属三级单位,避免越级选择其他单位数据。
initThreeUnits() {
this.threeUnits = []
if (!this.showThreeUnit) {
return Promise.resolve()
}
if (!this.formData.unitId) {
this.$message.warning("当前人员未配置所属单位,无法查询三级单位")
return Promise.resolve()
}
return this.$axios.post("/platform/sys/unit/child", {pid: this.formData.unitId})
.then((resp) => {
if (resp.code === 0) {
this.threeUnits = resp.data || []
if (this.threeUnits.length === 0) {
this.$message.warning("当前所属单位未配置三级单位")
}
} else {
this.$message.error(resp.msg || "三级单位查询失败")
}
})
.catch(() => {
this.$message.error("三级单位查询失败,请稍后重试")
})
},
doSave(){
this.$confirm("保存后可在我的申请里面再次编辑,您确定要保存吗?", "提示", { type: "warning" })
.then(() => {
@@ -424,6 +460,7 @@ const MEMBER_CHANGE = {
this.units = await this.$businessTool.listUnit()
}
this.normalizeUnitField()
this.initThreeUnits()
},
async getUserById(userId){
const resp = await $.post("/platform/member/change/apply/getUserByIdForMemberChange", { userId })
@@ -461,6 +498,7 @@ const MEMBER_CHANGE = {
unit,
union,
campus,
threeUnitId,
userState,
personType,
preparedBy,
@@ -488,6 +526,7 @@ const MEMBER_CHANGE = {
this.$set(this.formData, "education", education)
this.$set(this.formData, "academicDegree", academicDegree)
this.$set(this.formData, "campus", campus)
this.$set(this.formData, "threeUnitId", threeUnitId)
this.$set(this.formData, "userState", userState)
this.$set(this.formData, "personType", personType)
this.$set(this.formData, "preparedBy", preparedBy)
@@ -16,11 +16,18 @@ layout("/layouts/platform.html"){
</el-select>
</search-item>
<search-item label="所属单位">
<el-select clearable filterable placeholder="请选择所属单位" style="width: 100%"
v-model="pageForm.unitId">
<el-option :key="item.id" :label="item.name" :value="item.id"
v-for="item in units"></el-option>
</el-select>
<el-select @change="flushThreeUnits" @clear="flushThreeUnits" clearable filterable
placeholder="请选择所属单位" style="width: 100%" v-model="pageForm.unitId">
<el-option :key="item.id" :label="item.name" :value="item.id"
v-for="item in units"></el-option>
</el-select>
</search-item>
<search-item label="三级单位">
<el-select clearable filterable placeholder="请选择三级单位" style="width: 100%"
v-model="pageForm.threeUnitId">
<el-option :key="item.id" :label="item.name" :value="item.id"
v-for="item in threeUnits"></el-option>
</el-select>
</search-item>
<search-item label="性别">
<dict-select v-model="pageForm.sex" placeholder="性别" @change="doSearch"
@@ -224,7 +231,7 @@ layout("/layouts/platform.html"){
</template>
<template #public>
<member-change ref="memberChangeRef" @do-back="$refs.guava.index()" @do-submit="doSubmit"></member-change>
<member-change ref="memberChangeRef" :show-three-unit="true" @do-back="$refs.guava.index()" @do-submit="doSubmit"></member-change>
</template>
<template #edit>
@@ -509,6 +516,7 @@ layout("/layouts/platform.html"){
pageForm: {
unionId: "",
unitId: "",
threeUnitId: "",
sex: "",
searchKeyword: "",
changed: 2,
@@ -519,6 +527,7 @@ layout("/layouts/platform.html"){
},
unions: [],
units: [],
threeUnits: [],
id: "",
checkUsers: [],
batchEditLoading: false,
@@ -675,14 +684,35 @@ layout("/layouts/platform.html"){
this.changeTypeData = await this.$businessTool.getEnumOptions("MemberChangeType")
this.allowChangeFields = await this.$businessTool.getDictOptions("ALLOW_CHANGE_FIELDS")
},
async flushUnits() {
flushUnits() {
this.$set(this.pageForm, "unitId", null)
this.$set(this.pageForm, "threeUnitId", null)
this.units = []
this.threeUnits = []
if (this.pageForm.unionId) {
this.$businessTool.listUnit(this.pageForm.unionId).then(data => {
this.units = data
})
}
},
// 所属单位变化时只加载其直属三级单位,避免跨单位筛选。
flushThreeUnits(unitId) {
this.$set(this.pageForm, "threeUnitId", null)
this.threeUnits = []
if (!unitId) {
return
}
this.$axios.post("/platform/sys/unit/child", {pid: unitId})
.then((res) => {
if (res.code === 0) {
this.threeUnits = res.data || []
} else {
this.$message.error(res.msg || "三级单位查询失败")
}
})
.catch(() => {
this.$message.error("三级单位查询失败,请稍后重试")
})
},
checkAll() {
this.checkedFields = this.tableColumns.map(c => c.prop);
@@ -50,7 +50,7 @@ layout("/layouts/platform.html"){
</el-card>
<template #view>
<info ref="infoRef"></info>
<info ref="infoRef" :show-three-unit="true"></info>
</template>
</guava>
</div>
@@ -216,7 +216,7 @@ layout("/layouts/platform.html"){
personTypeOptions: [],
popoverConfigs: [
{text: '设置会员', func: this.onMember, visible: false},
{text: '设置福利会员', func: this.onWelfare, visible: false},
// {text: '设置福利会员', func: this.onWelfare, visible: false},
],
}
},
@@ -138,8 +138,8 @@ layout("/layouts/platform.html"){
show-overflow-tooltip
align="center"
header-align="center"
v-for="column in tableColumns"
v-if="checkedFields.includes(column.prop)"
v-for="column in showColumns"
:key="column.prop"
:label="column.label"
:fixed="column.fixed"
:prop="column.prop"
@@ -482,6 +482,14 @@ layout("/layouts/platform.html"){
watch: {
filterText(val) {
this.$refs.treeRef.filter(val)
},
checkedFields() {
// 列设置变更后,重新计算固定列与普通列的宽度,避免表头、表体出现错位。
this.$nextTick(() => {
if (this.$refs.memberTableRef) {
this.$refs.memberTableRef.doLayout()
}
})
}
},
components: {
@@ -62,11 +62,9 @@ const filterUser = {
</search-item>
<search-item label="所属单位">
<el-select clearable filterable
multiple
collapse-tags
<el-select @change="unitChange" clearable filterable
placeholder="请选择所属单位" style="width: 100%"
v-model="pageForm.unitIds">
v-model="pageForm.unitId">
<el-option
:key="item.id"
:label="item.name"
@@ -76,6 +74,18 @@ const filterUser = {
</el-select>
</search-item>
<search-item label="三级单位">
<el-select clearable filterable placeholder="请先选择所属单位" style="width: 100%"
v-model="pageForm.threeUnitId">
<el-option
:key="item.id"
:label="item.name"
:value="item.id"
v-for="item in threeUnitOptions">
</el-option>
</el-select>
</search-item>
<search-item label="人员类型:">
<dict-select clearable code="USER_PERSON_TYPE"
multiple
@@ -160,11 +170,13 @@ const filterUser = {
personTypes: [],
preparedBys: [],
userAttributes: [],
unitIds: []
unitId: null,
threeUnitId: null
},
sexOptions: ["男", "女"],
unionOptions: [],
unitOptions: [],
threeUnitOptions: [],
tableColumns: [
{ prop: "loginname", label: "工号" },
{ prop: "username", label: "姓名" },
@@ -195,7 +207,9 @@ const filterUser = {
this.$set(this.pageForm, "birthday", null)
this.$set(this.pageForm, "birthMonths", [])
this.$set(this.pageForm, "unionId", null)
this.$set(this.pageForm, "unitIds", [])
this.$set(this.pageForm, "unitId", null)
this.$set(this.pageForm, "threeUnitId", null)
this.threeUnitOptions = []
this.$set(this.pageForm, "personTypes", [])
this.$set(this.pageForm, "preparedBys", [])
this.$set(this.pageForm, "userAttributes", [])
@@ -203,6 +217,26 @@ const filterUser = {
this.$set(this.pageForm, "isMember", null)
},
// 所属单位改变后,仅加载该单位直属的三级单位,避免跨单位筛选。
unitChange(unitId) {
this.$set(this.pageForm, "threeUnitId", null)
this.threeUnitOptions = []
if (!unitId) {
return
}
this.$axios.post("/platform/sys/unit/child", { pid: unitId })
.then((res) => {
if (res.code === 0) {
this.threeUnitOptions = res.data || []
} else {
this.$message.error(res.msg || "三级单位查询失败")
}
})
.catch(() => {
this.$message.error("三级单位查询失败,请稍后重试")
})
},
pageData() {
this.tableLoading = true
this.$axios
@@ -13,20 +13,22 @@ const welfareOption = {
</el-table-column>
<el-table-column align="center" header-align="center" label="系统默认选择" width="140">
<template slot-scope="{$index}">
<template slot-scope="{row}">
<el-radio
v-model="systemDefaultPlaceholderIndex"
:label="$index">
v-model="systemDefaultOption"
:label="row"
@change="setSystemDefault(row)">
&nbsp;
</el-radio>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="图片" width="200">
<el-table-column align="center" header-align="center" label="图片" width="100">
<template slot-scope="{row}">
<file-upload :upload_number="1" :value.sync="row.imgUrl"
accept=".jpg,.jpeg,.png"
class="imgUrl"
style="--upload-width: 80px; --upload-height: 80px;"
upload_result_type="url"
complete_result upload_mode="image"
upload_result_category="interval"></file-upload>
@@ -83,7 +85,7 @@ const welfareOption = {
data() {
return {
welfareList: [],
systemDefaultPlaceholderIndex: null,
systemDefaultOption: null,
descriptionDialog: {
visible: false,
content: '',
@@ -94,6 +96,10 @@ const welfareOption = {
created() {
this.welfareList = this.value ? JSON.parse(JSON.stringify(this.value)) : [];
this.systemDefaultOption = this.welfareList.find((item) => item.isSystemDefault) || null;
if (this.systemDefaultOption) {
this.setSystemDefault(this.systemDefaultOption);
}
},
watch: {
@@ -112,7 +118,15 @@ const welfareOption = {
optionName: '福利' + newSort,
optionSort: newSort,
imgUrl: '',
description: ''
description: '',
isSystemDefault: false
});
},
// 每个福利项目只允许配置一个系统默认选项,保存时会随福利选项一并提交。
setSystemDefault(defaultOption) {
this.welfareList.forEach((item) => {
this.$set(item, 'isSystemDefault', item === defaultOption);
});
},
@@ -120,11 +134,10 @@ const welfareOption = {
this.$confirm('确认删除该福利选项?', '提示', {
type: 'warning'
}).then(() => {
const deletedOption = this.welfareList[index];
this.welfareList.splice(index, 1);
if (this.systemDefaultPlaceholderIndex === index) {
this.systemDefaultPlaceholderIndex = null;
} else if (this.systemDefaultPlaceholderIndex > index) {
this.systemDefaultPlaceholderIndex--;
if (this.systemDefaultOption === deletedOption) {
this.systemDefaultOption = null;
}
this.updateSortNumbers();
});
@@ -180,16 +193,6 @@ const welfareOption = {
padding: 0;
}
/deep/ .welfare-option .el-upload-list__item{
width: 100px;
height: 100px;
}
/deep/ .welfare-option .imgUrl .el-upload--picture-card {
width: 100px;
height: 100px;
}
/deep/ .welfare-option .imgUrl .el-upload--picture-card i {
position: absolute;
top: 50%;
@@ -90,7 +90,8 @@ const optionSelect = {
<!-- 单选模式使用单选按钮 -->
<div class="option-radio" v-if="projectInfo.isCheckBox === 'radio'">
<el-radio v-model="selectedRadioId" :label="option.id">{{ null }}</el-radio>
<el-radio v-model="selectedRadioId" :label="option.id"
:disabled="systemDefaultOption && systemDefaultOption.id !== option.id">{{ null }}</el-radio>
</div>
<!-- 多选模式使用步进器 -->
@@ -98,7 +99,7 @@ const optionSelect = {
<el-input-number
:key="'input-number-'+index+'-'+option.selectNumKey|| 0"
v-model="option.selectNum"
:min="0"
:min="option.isSystemDefault ? 1 : 0"
:max="projectInfo.multiSelectNum || 99"
size="small"
@change="selectNumChange(index,option.selectNum)"
@@ -288,6 +289,12 @@ const optionSelect = {
"address-dialog": ADDRESS_DIALOG
},
computed: {
// 系统默认福利必须被保留,管理端限制每个项目最多配置一个。
systemDefaultOption() {
if (!this.projectInfo.options) return null
return this.projectInfo.options.find((option) => option.isSystemDefault) || null
},
// 是否有选择
hasSelection() {
if (this.projectInfo.isCheckBox === "radio") {
@@ -486,6 +493,11 @@ const optionSelect = {
}
}
// 首次进入且没有历史选择时,自动带出系统默认福利。
if (this.projectInfo.options && this.userSelection.length === 0) {
this.applySystemDefaultOption()
}
// 设置已选择的选项
if (this.projectInfo.options && this.userSelection.length > 0) {
// 单选模式
@@ -608,6 +620,11 @@ const optionSelect = {
// 单选选项选择
selectRadioOption(optionId) {
if (this.systemDefaultOption && this.systemDefaultOption.id !== optionId) {
this.selectedRadioId = this.systemDefaultOption.id
this.$message.warning("系统默认福利不可取消")
return
}
this.selectedRadioId = optionId
// 重置所有选项的selectNum
@@ -660,6 +677,11 @@ const optionSelect = {
},
selectNumChange(index, newValue) {
const option = this.projectInfo.options[index];
if (option.isSystemDefault && Number(newValue) < 1) {
this.$set(option, "selectNum", 1)
this.$message.warning("系统默认福利不可取消")
return
}
const maxSelect = this.projectInfo.multiSelectNum || this.projectInfo.options.length;
// 计算其他所有选项的总和(不包括当前修改的选项)
@@ -690,6 +712,15 @@ const optionSelect = {
// 无论是否需要调整值,都需要触发 key 更新以确保 el-input-number 重新渲染
this.$set(option, 'selectNumKey', (option.selectNumKey || 0) + 1);
}
},
// 默认项只在首次选择时自动带入,已有历史选择不自动覆盖。
applySystemDefaultOption() {
if (!this.systemDefaultOption) return
if (this.projectInfo.isCheckBox === "radio") {
this.selectRadioOption(this.systemDefaultOption.id)
} else {
this.$set(this.systemDefaultOption, "selectNum", 1)
}
}
},
watch: {
@@ -20,7 +20,7 @@ layout("/layouts/platform_h5.html"){
<template v-slot="{index,row}">
<table-column label="申请人">{{row.userName}}</table-column>
<table-column label="申请时间">{{row.submitTime}}</table-column>
<table-column label="当前节点">{{row.taskName}}</table-column>
<!-- <table-column label="当前节点">{{row.taskName}}</table-column>-->
</template>
<template #actions="{index,row}">
<div class="action-btn" @click="onView(row)">
@@ -69,10 +69,11 @@ layout("/layouts/platform_h5.html"){
}
.learning-filter-card {
flex-shrink: 0;
margin: 14px 14px 0;
padding: 10px;
border-radius: 8px;
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;
@@ -82,80 +83,81 @@ layout("/layouts/platform_h5.html"){
box-sizing: border-box;
}
.learning-filter-group {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
min-height: 72px;
overflow: hidden;
border-radius: 6px;
background: #eef6ff;
border-bottom: 1px solid #edf1f7;
}
.learning-filter-group + .learning-filter-group {
margin-top: 8px;
margin-top: 0;
}
.learning-filter-group:last-child {
border-bottom: 0;
}
.learning-filter-category {
display: flex;
align-items: center;
padding: 12px;
color: #ffffff;
justify-content: space-between;
min-height: 40px;
padding: 0 2px;
color: #26354c;
box-sizing: border-box;
}
.learning-filter-category-text {
min-width: 0;
.learning-filter-category-main,
.learning-filter-category-actions {
display: flex;
align-items: center;
}
.learning-filter-category-title {
font-size: 15px;
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;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
background: rgba(255, 255, 255, .55);
}
.learning-filter-options::-webkit-scrollbar {
display: none;
flex-wrap: wrap;
gap: 7px 9px;
padding: 0 1px 10px;
}
.learning-filter-item {
flex: 0 0 72px;
min-width: 72px;
padding: 8px 2px 7px;
border-left: 1px solid rgba(255, 255, 255, .72);
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: #5f6b7a;
color: #718096;
font-size: 11px;
box-sizing: border-box;
}
.learning-filter-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin: 0 auto 5px;
border-radius: 50%;
color: #4f87c8;
font-size: 18px;
background: rgba(255, 255, 255, .78);
}
.learning-filter-item.no-icon {
display: flex;
align-items: center;
justify-content: center;
min-height: 58px;
color: #0b75bd;
font-weight: 700;
}
.learning-filter-item.active {
color: #0b75bd;
font-weight: 700;
background: rgba(255, 255, 255, .86);
}
.learning-filter-item.active .learning-filter-icon {
color: #ffffff;
background: #0b75bd;
font-weight: 700;
background: #287ff0;
border-color: #287ff0;
box-shadow: 0 2px 5px rgba(40, 127, 240, .2);
}
.learning-filter-name {
overflow: hidden;
@@ -275,28 +277,9 @@ layout("/layouts/platform_h5.html"){
padding: 22px 0 36px;
}
@media (max-width: 360px) {
.learning-filter-card {
margin-left: 10px;
margin-right: 10px;
padding: 8px;
}
.learning-filter-group {
grid-template-columns: 94px minmax(0, 1fr);
}
.learning-filter-category {
padding: 10px 8px;
}
.learning-filter-category-title {
font-size: 13px;
}
.learning-filter-icon {
width: 26px;
height: 26px;
font-size: 17px;
}
.learning-filter-item {
font-size: 11px;
}
.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;
@@ -328,21 +311,24 @@ layout("/layouts/platform_h5.html"){
<div class="learning-filter-card">
<div class="learning-filter-group" v-for="group in filterGroups" :key="group.key">
<div class="learning-filter-category" :style="{background: group.color}">
<div class="learning-filter-category-text">
<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 class="learning-filter-options">
<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), 'no-icon': !item.icon}"
:class="{active: isFilterActive(item)}"
@click="selectFilter(item)">
<div v-if="item.icon" class="learning-filter-icon">
<van-icon :name="item.icon"></van-icon>
</div>
<div class="learning-filter-name">{{ item.name }}</div>
</div>
</div>
@@ -351,7 +337,7 @@ layout("/layouts/platform_h5.html"){
<div class="learning-list-scroll">
<div class="learning-section-head">
<div class="learning-section-title">{{ listTitle }}</div>
<div class="learning-section-title">我的课堂</div>
<div class="learning-section-total">共 {{ pageForm.totalCount || 0 }} 门</div>
</div>
@@ -416,36 +402,35 @@ layout("/layouts/platform_h5.html"){
loading: false,
finished: false,
refreshing: false,
requestSeq: 0
requestSeq: 0,
filterCollapsed: { recommend: false, type: false }
}
},
computed: {
filterGroups() {
const recommendItems = this.recommendOptions.map((item, index) => ({
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,
icon: this.recommendIcon(index)
}))
const typeItems = this.courseTypeOptions.map((item, index) => ({
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,
icon: this.typeIcon(index)
}))
name: item.typeName
})))
return [
{
key: "recommend",
name: "推荐标识",
color: "linear-gradient(135deg, #ff8a3d, #ffbf5e)",
icon: "label-o",
items: recommendItems
},
{
key: "type",
name: "课程类型",
color: "linear-gradient(135deg, #2878d7, #64a8f4)",
icon: "apps-o",
items: typeItems
}
]
@@ -538,6 +523,26 @@ layout("/layouts/platform_h5.html"){
}
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") {
@@ -364,25 +364,33 @@ layout("/layouts/platform_h5.html"){
v-if="selectedResource.resourceType === 'video'"
ref="mediaPlayer"
class="study-media"
:src="fileUrl"
: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)">
@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)">
@ended="finishStudy(false, true)">
</audio>
</div>
<img v-else-if="selectedResource.resourceType === 'image'" class="study-image-preview" :src="fileUrl" :alt="selectedResource.title">
@@ -412,6 +420,14 @@ layout("/layouts/platform_h5.html"){
studying: false,
pendingSeconds: 0,
heartbeatTimer: null,
resumePositionSeconds: 0,
resumeSeekRetryCount: 0,
resumePositionRestored: false,
lastSavedPositionSeconds: 0,
lastPositionSyncAt: 0,
lastAllowedPositionSeconds: 0,
restoringMediaPosition: false,
pageVisibilityHandler: null,
navigatingBack: false
}
},
@@ -422,6 +438,12 @@ layout("/layouts/platform_h5.html"){
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
@@ -502,31 +524,99 @@ layout("/layouts/platform_h5.html"){
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.resumeMediaPosition())
this.$nextTick(() => this.restoreMediaPosition())
}
},
resumeMediaPosition() {
restoreMediaPosition() {
const player = this.$refs.mediaPlayer
const position = Number(this.selectedResource.lastPositionSeconds || 0)
if (player && position > 0) {
player.currentTime = position
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.getMediaPosition()
positionSeconds: this.resumePositionRestored ? this.getMediaPosition() : this.resumePositionSeconds
})
if (resp.code !== 0) {
this.$toast(resp.msg || "开始学习失败")
@@ -551,7 +641,8 @@ layout("/layouts/platform_h5.html"){
this.applyRecordState(resp.data)
}
},
async finishStudy(force) {
// force 用于页面销毁时静默结束;completed 仅在视频或音频自然播放结束时传 true。
async finishStudy(force, completed) {
this.pauseMedia()
if (!this.currentSegmentId) return
const segmentId = this.currentSegmentId
@@ -563,7 +654,8 @@ layout("/layouts/platform_h5.html"){
await this.$axios.post(this.recordApi + "/finish", {
segmentId: segmentId,
activeSeconds: seconds,
positionSeconds: this.getMediaPosition()
positionSeconds: this.getMediaPosition(),
completed: !!completed
})
if (force) return
this.loadTree()
@@ -591,7 +683,55 @@ layout("/layouts/platform_h5.html"){
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
@@ -601,6 +741,11 @@ layout("/layouts/platform_h5.html"){
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
@@ -709,6 +854,11 @@ layout("/layouts/platform_h5.html"){
fragment: "#container",
timeout: 8000
})
},
handlePageVisibility(event) {
if (document.hidden || (event && event.type === "pagehide")) {
this.saveMediaPosition(true)
}
}
},
async mounted() {
@@ -719,10 +869,18 @@ layout("/layouts/platform_h5.html"){
}
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>
@@ -698,7 +698,7 @@ layout("/layouts/platform_h5.html"){
:name="option.id"
v-model="selectedRadioId"
@click.stop="isDeadlinePassed ? $toast.fail('已过选择截止时间,无法修改') : selectRadioOption(option.id)"
:disabled="isDeadlinePassed"
:disabled="isDeadlinePassed || (systemDefaultOption && systemDefaultOption.id !== option.id)"
></van-radio>
</div>
@@ -717,7 +717,7 @@ layout("/layouts/platform_h5.html"){
integer
disable-input
:default-value="0"
:min="0"
:min="option.isSystemDefault ? 1 : 0"
:disabled="isDeadlinePassed"
input-width="40px"
button-size="22px"
@@ -899,6 +899,12 @@ layout("/layouts/platform_h5.html"){
computed: {
// 系统默认福利必须被保留,管理端限制每个项目最多配置一个。
systemDefaultOption() {
if (!this.projectInfo.options) return null
return this.projectInfo.options.find((option) => option.isSystemDefault) || null
},
hasSelection() {
if (this.projectInfo.isCheckBox === "radio") {
return !!this.selectedRadioId
@@ -961,6 +967,11 @@ layout("/layouts/platform_h5.html"){
},
selectNumChange(index, newValue) {
const option = this.projectInfo.options[index];
if (option.isSystemDefault && Number(newValue) < 1) {
this.$set(option, "selectNum", 1)
this.$toast.fail("系统默认福利不可取消")
return
}
const maxSelect = this.projectInfo.multiSelectNum || this.projectInfo.options.length;
// 计算其他所有选项的总和(不包括当前修改的选项)
@@ -1046,6 +1057,11 @@ layout("/layouts/platform_h5.html"){
}
}
// 首次进入且没有历史选择时,自动带出系统默认福利。
if (this.projectInfo.options && this.userSelection.length === 0) {
this.applySystemDefaultOption()
}
// 设置已选择的选项
if (this.projectInfo.options && this.userSelection.length > 0) {
// 单选模式
@@ -1270,6 +1286,11 @@ layout("/layouts/platform_h5.html"){
this.$toast.fail("已过选择截止时间,无法修改")
return
}
if (this.systemDefaultOption && this.systemDefaultOption.id !== optionId) {
this.selectedRadioId = this.systemDefaultOption.id
this.$toast.fail("系统默认福利不可取消")
return
}
this.selectedRadioId = optionId
// 重置所有选项的selectNum
@@ -1284,6 +1305,16 @@ layout("/layouts/platform_h5.html"){
}
},
// 默认项只在首次选择时自动带入,已有历史选择不自动覆盖。
applySystemDefaultOption() {
if (!this.systemDefaultOption) return
if (this.projectInfo.isCheckBox === "radio") {
this.selectRadioOption(this.systemDefaultOption.id)
} else {
this.$set(this.systemDefaultOption, "selectNum", 1)
}
},
// 显示选项详情
showOptionDetail(option) {
this.selectedOption = option