commit
This commit is contained in:
@@ -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">
|
||||
|
||||
+9
@@ -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: '更新',
|
||||
|
||||
+7
-1
@@ -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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+68
-63
@@ -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 {
|
||||
|
||||
+45
-6
@@ -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>
|
||||
|
||||
+1
-1
@@ -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)">
|
||||
|
||||
</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
|
||||
|
||||
Reference in New Issue
Block a user