feat: 学习板块,线上课程,网课 完成

This commit is contained in:
2026-09-28 20:57:03 +08:00
parent 486934f3b6
commit d717330657
39 changed files with 8644 additions and 6 deletions
@@ -3,6 +3,7 @@ package com.budwk.app.base.utils;
import cn.hutool.core.io.FileUtil;
import com.aspose.cells.PdfSaveOptions;
import com.aspose.cells.Workbook;
import com.aspose.slides.Presentation;
import com.aspose.words.Document;
import com.aspose.words.SaveFormat;
import com.budwk.app.base.exception.BaseException;
@@ -30,11 +31,34 @@ public class OfficePlusUtil {
wordConvertPdf(sourcePath, targetPath);
} else if (suffix.equals("xls") || suffix.equals("xlsx")) {
excelConvertPdf(sourcePath, targetPath);
} else if (suffix.equalsIgnoreCase("ppt") || suffix.equalsIgnoreCase("pptx")) {
presentationConvertPdf(sourcePath, targetPath);
} else {
throw new BaseException("不支持的文档格式");
}
}
/**
* PPT演示文稿转PDF
*
* @param sourcePath 源文件路径
* @param targetPath 目标文件路径
*/
public static void presentationConvertPdf(String sourcePath, String targetPath) {
Presentation presentation = null;
try {
presentation = new Presentation(sourcePath);
presentation.save(targetPath, com.aspose.slides.SaveFormat.Pdf);
} catch (Exception e) {
log.info("转换pdf报错", e);
throw new BaseException("PPT转换PDF失败");
} finally {
if (presentation != null) {
presentation.dispose();
}
}
}
/**
* excel转pdf
*
@@ -2,6 +2,7 @@ package com.budwk.app.sys.controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.page.Pagination;
@@ -11,8 +12,12 @@ import com.budwk.app.sys.enums.SysFileEngineTypeEnum;
import com.budwk.app.sys.models.Sys_file;
import com.budwk.app.sys.services.SysConfigService;
import com.budwk.app.sys.services.SysFileService;
import com.budwk.app.sys.utils.SysFileMinIoUtil;
import com.google.common.net.HttpHeaders;
import com.budwk.app.zhgh.learning.service.LearningFileAccessService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.nutz.dao.Cnd;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
@@ -23,12 +28,14 @@ import org.nutz.mvc.upload.UploadAdaptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.OutputStream;
import java.util.List;
@IocBean
@At("/platform/sys/file")
@Ok("json:full")
@Api(tags = "文件管理")
@Slf4j
public class SysFileController {
@Inject
@@ -37,6 +44,9 @@ public class SysFileController {
@Inject
private SysConfigService sysConfigService;
@Inject
private LearningFileAccessService learningFileAccessService;
@At("")
@Ok("beetl:/platform/sys/file/index.html")
@SaCheckPermission("sys.manager.file")
@@ -95,6 +105,7 @@ public class SysFileController {
@Ok("void")
@SaCheckLogin
public void download(String id, HttpServletRequest request, HttpServletResponse response) throws IOException {
if (!learningFileAccessService.check(id, true, response)) return;
sysFileService.download(id, request, response);
}
@@ -103,6 +114,7 @@ public class SysFileController {
@SaCheckLogin
@ApiOperation("文件管理-转换PDF")
public void convertPDF(String id, HttpServletRequest request, HttpServletResponse response) throws IOException {
if (!learningFileAccessService.check(id, true, response)) return;
sysFileService.convertPDF(id, request, response);
}
@@ -142,4 +154,135 @@ public class SysFileController {
}
@At
@Ok("void")
@SaCheckLogin
public void preview(String id, HttpServletRequest request, HttpServletResponse response) throws IOException {
if (!learningFileAccessService.check(id, false, response)) return;
Sys_file file = sysFileService.detail(id);
String contentType = switch (StrUtil.blankToDefault(file.getSuffix(), "").toLowerCase()) {
case "jpg", "jpeg" -> "image/jpeg";
case "png" -> "image/png";
case "gif" -> "image/gif";
case "webp" -> "image/webp";
case "bmp" -> "image/bmp";
case "mp3" -> "audio/mpeg";
case "aac" -> "audio/aac";
case "wav" -> "audio/wav";
case "ogg" -> "audio/ogg";
case "m4a" -> "audio/mp4";
case "pdf" -> "application/pdf";
default -> null;
};
if (contentType == null) {
response.sendError(415, "该资料不支持在线预览");
return;
}
byte[] bytes = SysFileEngineTypeEnum.LOCAL.getValue().equals(file.getEngine())
? FileUtil.readBytes(file.getStoragePath())
: SysFileMinIoUtil.getFileBytes(file.getBucket(), file.getStoragePath());
response.setContentType(contentType);
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "inline");
if (contentType.startsWith("audio/")) {
response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
String range = request.getHeader(HttpHeaders.RANGE);
if (StrUtil.isNotBlank(range) && range.startsWith("bytes=") && !range.contains(",")) {
long start;
long end;
try {
String specification = range.substring(6).trim();
if (!specification.matches("[0-9]*-[0-9]*")) throw new NumberFormatException();
String[] bounds = specification.split("-", -1);
if (bounds[0].isEmpty()) {
long length = Long.parseLong(bounds[1]);
if (length <= 0) throw new NumberFormatException();
start = Math.max(0, bytes.length - length);
end = bytes.length - 1;
} else {
start = Long.parseLong(bounds[0]);
end = bounds[1].isEmpty() ? bytes.length - 1 : Math.min(Long.parseLong(bounds[1]), bytes.length - 1);
}
if (start >= bytes.length || end < start) throw new NumberFormatException();
} catch (NumberFormatException e) {
response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes */" + bytes.length);
response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
return;
}
int length = (int) (end - start + 1);
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes " + start + "-" + end + "/" + bytes.length);
response.setContentLength(length);
try (OutputStream out = response.getOutputStream()) {
out.write(bytes, (int) start, length);
}
return;
}
}
response.setContentLength(bytes.length);
try (OutputStream out = response.getOutputStream()) {
out.write(bytes);
}
}
@At
@SaCheckLogin
@ApiOperation("文件管理-视频播放")
@Ok("void")
public void videoPlay(String id, HttpServletRequest request, HttpServletResponse response) throws IOException {
if (!learningFileAccessService.check(id, false, response)) return;
Sys_file sys_file = sysFileService.detail(id);
byte[] bytes = SysFileMinIoUtil.getFileBytes(sys_file.getBucket(), sys_file.getStoragePath());
// 设置响应头
String fileName = sys_file.getName();
String contentType = null;
switch (FileUtil.extName(fileName).toLowerCase()) {
case "mp4" -> contentType = "video/mp4";
case "avi" -> contentType = "video/x-msvideo";
case "mkv" -> contentType = "video/x-matroska";
case "mov" -> contentType = "video/quicktime";
case "wmv" -> contentType = "video/x-ms-wmv";
default -> contentType = "application/octet-stream";
}
response.setContentType(contentType);
response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
String rangeHeader = request.getHeader(HttpHeaders.RANGE);
if (StrUtil.isNotBlank(rangeHeader) && rangeHeader.startsWith("bytes=")) {
// 处理Range请求
String[] ranges = rangeHeader.substring(6).split("-");
long start = StrUtil.isNotBlank(ranges[0]) ? Long.parseLong(ranges[0]) : 0;
long end = (ranges.length > 1 && StrUtil.isNotBlank(ranges[1])) ?
Long.parseLong(ranges[1]) : bytes.length - 1;
if (start >= bytes.length || end >= bytes.length) {
response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes */" + bytes.length);
response.setStatus(416);
return;
}
int contentLength = (int) (end - start + 1);
response.setStatus(206);
response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(contentLength));
response.setHeader(HttpHeaders.CONTENT_RANGE, StrUtil.format("bytes {}-{}/{}", start, end, bytes.length));
try (OutputStream out = response.getOutputStream()) {
out.write(bytes, (int) start, contentLength);
} catch (IOException e) {
log.error("视频播放失败", e);
}
} else {
// 非Range请求
response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(bytes.length));
try (OutputStream out = response.getOutputStream()) {
out.write(bytes);
} catch (IOException e) {
log.error("视频播放失败", e);
}
}
}
}
@@ -147,7 +147,11 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
@Override
public Sys_file detail(String id) {
return null;
Sys_file file = this.fetch(id);
if (file == null) {
throw new BaseException("文件记录不存在");
}
return file;
}
@Override
@@ -0,0 +1,410 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.learning.models.LearningCourse;
import com.budwk.app.zhgh.learning.models.LearningCourseOutline;
import com.budwk.app.zhgh.learning.models.LearningCourseType;
import com.budwk.app.zhgh.learning.models.LearningOutlineResource;
import com.budwk.app.zhgh.learning.models.LearningStudyRule;
import com.budwk.app.zhgh.learning.service.LearningResourceProgressService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.pager.Pager;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@IocBean
@Ok("json:full")
@Api("学习教育平台-章节内容管理")
@At("/platform/learning/chapter/content")
public class LearningChapterContentController {
@Inject
private Dao dao;
@Inject
private LearningResourceProgressService resourceProgressService;
@At("")
@Ok("beetl:/platform/zhgh/Learning/chapterContent/index.html")
@SaCheckPermission("learning.chapter.content")
public void index() {
}
@At
@ApiOperation("课程列表")
@SaCheckPermission("learning.chapter.content")
public Result courses(String courseId, String courseName, String courseTypeId, String lecturerName, String status) {
Cnd cnd = Cnd.NEW();
cnd.andEX("id", "=", courseId);
cnd.and(Cnd.likeEX("courseName", courseName));
cnd.andEX("courseTypeId", "=", courseTypeId);
cnd.and(Cnd.likeEX("lecturerName", lecturerName));
cnd.andEX("status", "=", status);
cnd.desc("createdAt");
return Result.success(dao.query(LearningCourse.class, cnd, dao.createPager(1, 50)));
}
@At
@ApiOperation("课程类型")
@SaCheckPermission("learning.chapter.content")
public Result courseTypes() {
return Result.success(dao.query(LearningCourseType.class, Cnd.where("enabled", "=", true).asc("sortNum").asc("createdAt")));
}
@At
@ApiOperation("课程大纲树")
@SaCheckPermission("learning.chapter.content")
public Result tree(String courseId) {
if (StrUtil.isBlank(courseId)) {
return Result.success(List.of());
}
List<LearningCourseOutline> outlines = dao.query(LearningCourseOutline.class, Cnd.where("courseId", "=", courseId).asc("sortOrder").asc("createdAt"));
Map<String, List<NutMap>> childrenMap = new HashMap<>();
List<NutMap> roots = new ArrayList<>();
for (LearningCourseOutline outline : outlines) {
NutMap map = NutMap.NEW()
.addv("id", outline.getId())
.addv("courseId", outline.getCourseId())
.addv("courseName", outline.getCourseName())
.addv("parentId", outline.getParentId())
.addv("nodeType", outline.getNodeType())
.addv("title", outline.getTitle())
.addv("subtitle", outline.getSubtitle())
.addv("description", outline.getDescription())
.addv("sortOrder", outline.getSortOrder())
.addv("required", outline.getRequired())
.addv("status", outline.getStatus())
.addv("children", new ArrayList<>());
String parentId = StrUtil.blankToDefault(outline.getParentId(), "");
childrenMap.computeIfAbsent(parentId, k -> new ArrayList<>()).add(map);
}
roots.addAll(childrenMap.getOrDefault("", new ArrayList<>()));
appendChildren(roots, childrenMap);
return Result.success(roots);
}
private void appendChildren(List<NutMap> nodes, Map<String, List<NutMap>> childrenMap) {
for (NutMap node : nodes) {
List<NutMap> children = childrenMap.getOrDefault(node.getString("id"), new ArrayList<>());
node.put("children", children);
appendChildren(children, childrenMap);
}
}
@At
@ApiOperation("新增/编辑大纲节点")
@SaCheckPermission("learning.chapter.content")
@SLog(tag = "保存大纲节点", msg = "节点标题:${args[0].title}")
public Result saveNode(LearningCourseOutline outline) {
Result checkResult = checkNode(outline);
if (checkResult != null) {
return checkResult;
}
LearningCourse course = dao.fetch(LearningCourse.class, outline.getCourseId());
outline.setCourseName(course == null ? outline.getCourseName() : course.getCourseName());
if ("chapter".equals(outline.getNodeType())) {
outline.setParentId("");
}
if (outline.getRequired() == null) {
outline.setRequired(false);
}
if (StrUtil.isBlank(outline.getStatus())) {
outline.setStatus("enabled");
}
if (outline.getSortOrder() == null) {
outline.setSortOrder(nextNodeSort(outline.getCourseId(), outline.getParentId()));
}
if (StrUtil.isBlank(outline.getId())) {
dao.insert(outline);
} else {
dao.updateIgnoreNull(outline);
}
ensureRule(outline.getCourseId(), "outline", outline.getId(), outline.getRequired(), outline.getStatus());
resourceProgressService.refreshOutline(outline.getId());
return Result.success();
}
@At
@ApiOperation("删除大纲节点")
@SaCheckPermission("learning.chapter.content")
@SLog(tag = "删除大纲节点", msg = "节点ID:${args[0]}")
public Result deleteNode(String id) {
if (StrUtil.isBlank(id)) {
return Result.error("请选择要删除的数据");
}
int childCount = dao.count(LearningCourseOutline.class, Cnd.where("parentId", "=", id));
int resourceCount = dao.count(LearningOutlineResource.class, Cnd.where("outlineId", "=", id));
if (childCount > 0 || resourceCount > 0) {
return Result.error("该节点下存在子节点或学习资料,请先删除后再操作");
}
dao.clear(LearningStudyRule.class, Cnd.where("targetId", "=", id).and("targetType", "=", "outline"));
dao.clear(LearningCourseOutline.class, Cnd.where("id", "=", id));
return Result.success();
}
@At
@ApiOperation("启用/禁用大纲节点")
@SaCheckPermission("learning.chapter.content")
public Result toggleNode(String id, String status) {
if (StrUtil.isBlank(id)) {
return Result.error("请选择要操作的数据");
}
dao.update(LearningCourseOutline.class, Chain.make("status", status), Cnd.where("id", "=", id));
dao.update(LearningStudyRule.class, Chain.make("status", status), Cnd.where("targetId", "=", id).and("targetType", "=", "outline"));
resourceProgressService.refreshOutline(id);
return Result.success();
}
@At
@ApiOperation("移动大纲节点")
@SaCheckPermission("learning.chapter.content")
public Result moveNode(String id, String direction) {
LearningCourseOutline current = dao.fetch(LearningCourseOutline.class, id);
if (current == null) {
return Result.error("节点不存在");
}
Cnd cnd = Cnd.where("courseId", "=", current.getCourseId()).and("parentId", "=", StrUtil.blankToDefault(current.getParentId(), ""));
if ("up".equals(direction)) {
cnd.and("sortOrder", "<", current.getSortOrder()).desc("sortOrder");
} else {
cnd.and("sortOrder", ">", current.getSortOrder()).asc("sortOrder");
}
LearningCourseOutline target = dao.fetch(LearningCourseOutline.class, cnd);
if (target == null) {
return Result.success();
}
Integer currentSort = current.getSortOrder();
dao.update(LearningCourseOutline.class, Chain.make("sortOrder", target.getSortOrder()), Cnd.where("id", "=", current.getId()));
dao.update(LearningCourseOutline.class, Chain.make("sortOrder", currentSort), Cnd.where("id", "=", target.getId()));
return Result.success();
}
@At
@ApiOperation("学习资料分页")
@SaCheckPermission("learning.chapter.content")
public Result resourcePage(PageForm pageForm, String outlineId) {
if (StrUtil.isBlank(outlineId)) {
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), 0, List.of()));
}
Cnd cnd = Cnd.where("outlineId", "=", outlineId);
String orderColumn = getResourceOrderColumn(pageForm.getPageOrderName());
if (StrUtil.isBlank(orderColumn)) {
cnd.asc("sortOrder").asc("createdAt");
} else if ("descending".equals(pageForm.getPageOrderBy())) {
cnd.desc(orderColumn);
} else {
cnd.asc(orderColumn);
}
int count = dao.count(LearningOutlineResource.class, Cnd.where("outlineId", "=", outlineId));
Pager pager = dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize());
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), count, dao.query(LearningOutlineResource.class, cnd, pager)));
}
@At
@ApiOperation("新增/编辑学习资料")
@SaCheckPermission("learning.chapter.content")
@SLog(tag = "保存学习资料", msg = "资料标题:${args[0].resourceTitle}")
public Result saveResource(LearningOutlineResource resource) {
Result checkResult = checkResource(resource);
if (checkResult != null) {
return checkResult;
}
if (resource.getRequired() == null) {
resource.setRequired(false);
}
if (Boolean.TRUE.equals(resource.getRequired()) || resource.getAllowPreview() == null) {
resource.setAllowPreview(true);
}
if (resource.getAllowDownload() == null) {
resource.setAllowDownload(false);
}
if (StrUtil.isBlank(resource.getStatus())) {
resource.setStatus("enabled");
}
if (resource.getSortOrder() == null) {
resource.setSortOrder(nextResourceSort(resource.getOutlineId()));
}
if (StrUtil.isBlank(resource.getId())) {
dao.insert(resource);
} else {
dao.updateIgnoreNull(resource);
}
ensureRule(resource.getCourseId(), "resource", resource.getId(), resource.getRequired(), resource.getStatus());
resourceProgressService.refreshOutline(resource.getOutlineId());
return Result.success();
}
@At
@ApiOperation("删除学习资料")
@SaCheckPermission("learning.chapter.content")
@SLog(tag = "删除学习资料", msg = "资料ID:${args[0]}")
public Result deleteResource(String id) {
if (StrUtil.isBlank(id)) {
return Result.error("请选择要删除的数据");
}
LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, id);
dao.clear(LearningStudyRule.class, Cnd.where("targetId", "=", id).and("targetType", "=", "resource"));
dao.clear(LearningOutlineResource.class, Cnd.where("id", "=", id));
if (resource != null) resourceProgressService.refreshOutline(resource.getOutlineId());
return Result.success();
}
@At
@ApiOperation("查询学习规则")
@SaCheckPermission("learning.chapter.content")
public Result getRule(String courseId, String targetType, String targetId) {
LearningStudyRule rule = dao.fetch(LearningStudyRule.class, Cnd.where("courseId", "=", courseId).and("targetType", "=", targetType).and("targetId", "=", targetId));
if (rule == null) {
rule = defaultRule(courseId, targetType, targetId, false, "enabled");
}
if ("manual".equals(rule.getCompletionRule())) rule.setCompletionRule("all_required_resource");
return Result.success(rule);
}
@At
@ApiOperation("保存学习规则")
@SaCheckPermission("learning.chapter.content")
public Result saveRule(LearningStudyRule rule) {
if (StrUtil.isBlank(rule.getCourseId()) || StrUtil.isBlank(rule.getTargetType()) || StrUtil.isBlank(rule.getTargetId())) {
return Result.error("规则对象不能为空");
}
if (StrUtil.isBlank(rule.getStatus())) {
rule.setStatus("enabled");
}
if (!List.of("all_required_resource", "resource_progress", "min_study_time").contains(
rule.getCompletionRule() == null ? "" : rule.getCompletionRule())) {
return Result.error("??????????");
}
if ("min_study_time".equals(rule.getCompletionRule())
&& (rule.getMinStudySeconds() == null || rule.getMinStudySeconds() <= 0)) {
return Result.error("??????????0");
}
if ("resource_progress".equals(rule.getCompletionRule())
&& (rule.getCompletePercent() == null || rule.getCompletePercent() <= 0 || rule.getCompletePercent() > 100)) {
return Result.error("????????0????100");
}
LearningStudyRule old = dao.fetch(LearningStudyRule.class, Cnd.where("courseId", "=", rule.getCourseId()).and("targetType", "=", rule.getTargetType()).and("targetId", "=", rule.getTargetId()));
if (old == null) {
dao.insert(rule);
} else {
rule.setId(old.getId());
dao.updateIgnoreNull(rule);
}
if ("outline".equals(rule.getTargetType())) {
dao.update(LearningCourseOutline.class, Chain.make("required", rule.getRequired()).add("status", rule.getStatus()), Cnd.where("id", "=", rule.getTargetId()));
resourceProgressService.refreshOutline(rule.getTargetId());
}
if ("resource".equals(rule.getTargetType())) {
Chain resourceChain = Chain.make("required", rule.getRequired()).add("status", rule.getStatus());
if (Boolean.TRUE.equals(rule.getRequired())) resourceChain.add("allowPreview", true);
dao.update(LearningOutlineResource.class, resourceChain, Cnd.where("id", "=", rule.getTargetId()));
LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, rule.getTargetId());
if (resource != null) resourceProgressService.refreshOutline(resource.getOutlineId());
}
return Result.success();
}
private Result checkNode(LearningCourseOutline outline) {
if (outline == null || StrUtil.isBlank(outline.getCourseId())) {
return Result.error("请选择课程");
}
if (StrUtil.isBlank(outline.getNodeType())) {
return Result.error("请选择节点类型");
}
if (StrUtil.isBlank(outline.getTitle())) {
return Result.error("标题不能为空");
}
if ("section".equals(outline.getNodeType()) && StrUtil.isBlank(outline.getParentId())) {
return Result.error("新增节需要选择所属章");
}
return null;
}
private Result checkResource(LearningOutlineResource resource) {
if (resource == null || StrUtil.isBlank(resource.getCourseId()) || StrUtil.isBlank(resource.getOutlineId())) {
return Result.error("请选择章/节节点");
}
if (StrUtil.isBlank(resource.getResourceTitle())) {
return Result.error("资料标题不能为空");
}
if (StrUtil.isBlank(resource.getResourceType())) {
return Result.error("请选择资料类型");
}
if (StrUtil.isBlank(resource.getFileData())) {
return Result.error("请上传附件");
}
return null;
}
private Integer nextNodeSort(String courseId, String parentId) {
LearningCourseOutline outline = dao.fetch(LearningCourseOutline.class, Cnd.where("courseId", "=", courseId).and("parentId", "=", StrUtil.blankToDefault(parentId, "")).desc("sortOrder"));
return outline == null || outline.getSortOrder() == null ? 1 : outline.getSortOrder() + 1;
}
private Integer nextResourceSort(String outlineId) {
LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, Cnd.where("outlineId", "=", outlineId).desc("sortOrder"));
return resource == null || resource.getSortOrder() == null ? 1 : resource.getSortOrder() + 1;
}
private void ensureRule(String courseId, String targetType, String targetId, Boolean required, String status) {
LearningStudyRule rule = dao.fetch(LearningStudyRule.class, Cnd.where("courseId", "=", courseId).and("targetType", "=", targetType).and("targetId", "=", targetId));
if (rule == null) {
dao.insert(defaultRule(courseId, targetType, targetId, required, status));
} else {
dao.update(LearningStudyRule.class,
Chain.make("required", required != null && required).add("status", StrUtil.blankToDefault(status, "enabled")),
Cnd.where("id", "=", rule.getId()));
}
}
private LearningStudyRule defaultRule(String courseId, String targetType, String targetId, Boolean required, String status) {
LearningStudyRule rule = new LearningStudyRule();
rule.setCourseId(courseId);
rule.setTargetType(targetType);
rule.setTargetId(targetId);
rule.setRequired(required != null && required);
rule.setStudyMode("mixed");
rule.setCompletionRule("all_required_resource");
rule.setCompletePercent(90);
rule.setMinStudySeconds(0);
rule.setUnlockRule("free");
rule.setAllowSkip(false);
rule.setAllowDrag(true);
rule.setPauseCountTime(false);
rule.setHiddenCountTime(false);
rule.setInactiveCountTime(false);
rule.setStatus(StrUtil.blankToDefault(status, "enabled"));
return rule;
}
private String getResourceOrderColumn(String prop) {
Map<String, String> columns = new HashMap<>();
columns.put("resourceTitle", "resourceTitle");
columns.put("resourceType", "resourceType");
columns.put("fileExt", "fileExt");
columns.put("durationSeconds", "durationSeconds");
columns.put("sortOrder", "sortOrder");
columns.put("required", "required");
columns.put("allowPreview", "allowPreview");
columns.put("allowDownload", "allowDownload");
columns.put("status", "status");
return columns.get(prop);
}
}
@@ -0,0 +1,380 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.CharsetUtil;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.OfficePlusUtil;
import com.budwk.app.sys.enums.SysFileEngineTypeEnum;
import com.budwk.app.sys.models.Sys_file;
import com.budwk.app.sys.models.Sys_dict;
import com.budwk.app.sys.services.SysDictService;
import com.budwk.app.sys.utils.SysFileMinIoUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.learning.models.LearningCourseOutline;
import com.budwk.app.zhgh.learning.models.LearningCourseType;
import com.budwk.app.zhgh.learning.models.LearningOutlineResource;
import com.budwk.app.zhgh.learning.models.LearningStudyRecord;
import com.budwk.app.zhgh.learning.models.LearningStudyRule;
import com.budwk.app.zhgh.learning.service.LearningFileAccessService;
import com.budwk.app.zhgh.learning.service.LearningResourceProgressService;
import com.google.common.net.HttpHeaders;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json;
import org.nutz.lang.Strings;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.util.List;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
@IocBean
@Ok("json:full")
@Api("学习教育平台-课程展示")
@At("/platform/learning/course/display")
public class LearningCourseDisplayController {
@Inject
private Dao dao;
@Inject
private SysDictService sysDictService;
@Inject
private LearningFileAccessService learningFileAccessService;
@Inject
private LearningResourceProgressService resourceProgressService;
@At("")
@Ok("beetl:/platform/zhgh/Learning/courseDisplay/indexV2.html")
@SaCheckPermission("learning.course.display")
public void index() {
}
@At("/detail")
@Ok("beetl:/platform/zhgh/Learning/courseDisplay/detail.html")
@SaCheckPermission("learning.course.display")
public void detail() {
}
@At("/study")
@Ok("beetl:/platform/zhgh/Learning/courseDisplay/studyV2.html")
@SaCheckPermission("learning.course.display")
public void study() {
}
@At
@ApiOperation("课程展示列表")
@SaCheckPermission("learning.course.display")
/**
* 查询课程展示列表。
* pageForm 传分页页码和每页条数;keyword、courseTypeId、recommendFlag 分别用于关键词、分类和推荐标识筛选;
* sortType 支持 default(综合排序)和 view_count(观看次数倒序)。返回值为 Pagination,list 中包含课程基础字段、课节数、总时长、观看次数和当前用户学习进度。
*/
public Result pageData(PageForm pageForm, String keyword, String courseTypeId, String recommendFlag, String sortType, String excludeCourseId) {
Cnd cnd = Cnd.where("c.status", "=", "published");
if (StrUtil.isNotBlank(keyword)) {
cnd.and(Cnd.exps("c.courseName", "like", "%" + keyword + "%")
.or("c.courseIntro", "like", "%" + keyword + "%"));
}
cnd.andEX("c.courseTypeId", "=", courseTypeId);
cnd.andEX("c.id", "!=", excludeCourseId);
if (StrUtil.isNotBlank(recommendFlag)) {
cnd.and(Cnd.exps("c.recommendFlags", "=", recommendFlag)
.or("c.recommendFlags", "like", recommendFlag + ",%")
.or("c.recommendFlags", "like", "%," + recommendFlag)
.or("c.recommendFlags", "like", "%," + recommendFlag + ",%"));
}
Sql countSql = Sqls.create("""
SELECT COUNT(1)
FROM learning_course c
LEFT JOIN learning_course_type t ON t.id = c.courseTypeId
$condition
""");
countSql.setCondition(cnd);
countSql.setCallback(Sqls.callback.integer());
dao.execute(countSql);
int count = countSql.getInt();
Sql listSql = Sqls.create("""
SELECT c.id, c.courseName, c.courseIntro, c.startTime, c.endTime, c.openType, c.cover,
c.recommendFlags, c.courseTypeId, c.lecturerName, c.sortNum,
COALESCE(c.viewCount, 0) AS viewCount, t.typeName AS courseTypeName,
COALESCE((SELECT ROUND(AVG(IFNULL(sr.progressPercent, 0)))
FROM learning_study_record sr
WHERE sr.courseId = c.id AND sr.userId = @userId
AND EXISTS (SELECT 1 FROM learning_outline_resource pr WHERE pr.outlineId = sr.outlineId
AND pr.status = 'enabled' AND COALESCE(pr.allowPreview, 1) = 1)), 0) AS progressPercent,
(SELECT COUNT(1) FROM learning_outline_resource r
WHERE r.courseId = c.id AND r.status = 'enabled' AND COALESCE(r.allowPreview, 1) = 1) AS resourceCount,
COALESCE((SELECT SUM(r.durationSeconds) FROM learning_outline_resource r
WHERE r.courseId = c.id AND r.status = 'enabled' AND COALESCE(r.allowPreview, 1) = 1), 0) AS totalDurationSeconds
FROM learning_course c
LEFT JOIN learning_course_type t ON t.id = c.courseTypeId
$condition
ORDER BY $orderBy
""");
listSql.setCondition(cnd);
listSql.params().set("userId", SecurityUtil.getUserId());
// 排序字段由固定选项映射,禁止将前端参数直接拼接进SQL。
listSql.setVar("orderBy", "random".equals(sortType) ? "RAND()" : "view_count".equals(sortType)
? "COALESCE(c.viewCount, 0) DESC, c.sortNum ASC, c.createdAt DESC"
: "c.sortNum ASC, c.createdAt DESC");
listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
dao.execute(listSql);
List<NutMap> rows = listSql.getList(NutMap.class);
rows.forEach(row -> {
row.putAll(resourceProgressService.courseState(row.getString("id"), SecurityUtil.getUserId()));
Object startTime = row.get("startTime");
Object endTime = row.get("endTime");
row.put("startTimeText", startTime == null ? "" : DateUtil.formatDateTime(DateUtil.date((java.util.Date) startTime)));
row.put("endTimeText", endTime == null ? "" : DateUtil.formatDateTime(DateUtil.date((java.util.Date) endTime)));
});
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), count, rows));
}
@At
@ApiOperation("课程学习基础信息")
@SaCheckPermission("learning.course.display")
/**
* 查询学习页课程信息。
* id 传课程主键;返回 NutMap,包含课程介绍、讲师、分类、课节数、总时长和后台配置的观看次数。
*/
public Result courseInfo(String id) {
if (StrUtil.isBlank(id)) {
return Result.error("请选择课程");
}
// 学习页复用课程配置和资源统计数据,观看次数由课程管理后台配置,不在访问时自动累加。
Sql sql = Sqls.create("""
SELECT c.id, c.courseName, c.courseIntro, c.suitablePeople, c.learningGoal, c.coursePeriod,
c.courseTags, c.startTime, c.endTime, c.openType, c.cover, c.recommendFlags,
c.lecturerName, c.lecturerInfo, COALESCE(c.viewCount, 0) AS viewCount,
t.typeName AS courseTypeName,
(SELECT COUNT(1) FROM learning_outline_resource r
WHERE r.courseId = c.id AND r.status = 'enabled' AND COALESCE(r.allowPreview, 1) = 1) AS resourceCount,
COALESCE((SELECT SUM(r.durationSeconds) FROM learning_outline_resource r
WHERE r.courseId = c.id AND r.status = 'enabled' AND COALESCE(r.allowPreview, 1) = 1), 0) AS totalDurationSeconds
FROM learning_course c
LEFT JOIN learning_course_type t ON t.id = c.courseTypeId
WHERE c.id = @id
""");
sql.params().set("id", id);
sql.setCallback(Sqls.callback.map());
dao.execute(sql);
NutMap course = sql.getObject(NutMap.class);
if (course == null) {
return Result.error("课程不存在");
}
Object startTime = course.get("startTime");
Object endTime = course.get("endTime");
course.put("startTimeText", startTime == null ? "" : DateUtil.formatDateTime(DateUtil.date((java.util.Date) startTime)));
course.put("endTimeText", endTime == null ? "" : DateUtil.formatDateTime(DateUtil.date((java.util.Date) endTime)));
course.putAll(resourceProgressService.courseState(id, SecurityUtil.getUserId()));
return Result.success(course);
}
@At
@ApiOperation("课程学习安排")
@SaCheckPermission("learning.course.display")
public Result studyTree(String courseId) {
if (StrUtil.isBlank(courseId)) {
return Result.success(List.of());
}
List<LearningCourseOutline> outlines = dao.query(LearningCourseOutline.class, Cnd.where("courseId", "=", courseId).and("status", "=", "enabled").asc("sortOrder").asc("createdAt"));
List<LearningOutlineResource> resources = dao.query(LearningOutlineResource.class, Cnd.where("courseId", "=", courseId).and("status", "=", "enabled").asc("sortOrder").asc("createdAt"));
List<LearningStudyRecord> records = dao.query(LearningStudyRecord.class, Cnd.where("courseId", "=", courseId).and("userId", "=", SecurityUtil.getUserId()));
List<LearningStudyRule> outlineRules = dao.query(LearningStudyRule.class, Cnd.where("courseId", "=", courseId).and("targetType", "=", "outline").and("status", "=", "enabled"));
Map<String, List<NutMap>> childrenMap = new HashMap<>();
List<NutMap> roots = new ArrayList<>();
Map<String, LearningStudyRecord> recordMap = new HashMap<>();
Map<String, LearningStudyRule> outlineRuleMap = new HashMap<>();
for (LearningStudyRecord record : records) {
recordMap.put(record.getOutlineId(), record);
}
for (LearningStudyRule outlineRule : outlineRules) {
outlineRuleMap.put(outlineRule.getTargetId(), outlineRule);
}
Map<String, NutMap> outlineNodeMap = new HashMap<>();
for (LearningCourseOutline outline : outlines) {
List<NutMap> children = new ArrayList<>();
NutMap node = NutMap.NEW()
.addv("id", outline.getId())
.addv("type", "outline")
.addv("nodeType", outline.getNodeType())
.addv("title", outline.getTitle())
.addv("description", outline.getDescription())
.addv("required", outline.getRequired())
.addv("progressPercent", recordMap.containsKey(outline.getId()) ? recordMap.get(outline.getId()).getProgressPercent() : 0)
.addv("studySeconds", recordMap.containsKey(outline.getId()) ? recordMap.get(outline.getId()).getStudySeconds() : 0)
.addv("lastPositionSeconds", recordMap.containsKey(outline.getId()) ? value(recordMap.get(outline.getId()).getLastPositionSeconds()) : 0)
.addv("completeStatus", recordMap.containsKey(outline.getId()) ? recordMap.get(outline.getId()).getCompleteStatus() : "not_started")
.addv("children", children);
node.putAll(resourceProgressService.outlineState(recordMap.get(outline.getId()), outline));
outlineNodeMap.put(outline.getId(), node);
childrenMap.put(outline.getId(), children);
}
for (LearningCourseOutline outline : outlines) {
NutMap node = outlineNodeMap.get(outline.getId());
if (StrUtil.isBlank(outline.getParentId())) {
roots.add(node);
} else {
childrenMap.computeIfAbsent(outline.getParentId(), key -> new ArrayList<>()).add(node);
}
}
for (LearningOutlineResource resource : resources) {
// 章节学习规则控制其下媒体播放行为;未配置规则时保持原有的可拖动行为。
LearningStudyRule outlineRule = outlineRuleMap.get(resource.getOutlineId());
boolean allowDrag = outlineRule == null || !Boolean.FALSE.equals(outlineRule.getAllowDrag());
NutMap resourceState = resourceProgressService.state(recordMap.get(resource.getOutlineId()), resource);
NutMap node = NutMap.NEW()
.addv("id", resource.getId())
.addv("type", "resource")
.addv("outlineId", resource.getOutlineId())
.addv("title", resource.getResourceTitle())
.addv("resourceType", resource.getResourceType())
.addv("studied", resourceState.getBoolean("studied", false))
.addv("fileExt", resource.getFileExt())
.addv("fileData", !Boolean.FALSE.equals(resource.getAllowPreview()) || Boolean.TRUE.equals(resource.getAllowDownload()) ? resource.getFileData() : null)
.addv("allowPreview", !Boolean.FALSE.equals(resource.getAllowPreview()))
.addv("allowDownload", Boolean.TRUE.equals(resource.getAllowDownload()))
.addv("durationSeconds", resource.getDurationSeconds())
.addv("required", resource.getRequired())
.addv("progressPercent", resourceState.getInt("progressPercent", 0))
.addv("studySeconds", resourceState.getInt("studySeconds", 0))
.addv("lastPositionSeconds", resourceState.getInt("lastPositionSeconds", 0))
.addv("maxPositionSeconds", resourceState.getInt("maxPositionSeconds", 0))
.addv("allowDrag", allowDrag)
.addv("completeStatus", resourceState.getString("completeStatus", "not_started"));
childrenMap.computeIfAbsent(resource.getOutlineId(), key -> new ArrayList<>()).add(node);
}
return Result.success(roots);
}
@At
@Ok("void")
@ApiOperation("PDF内联预览")
@SaCheckPermission("learning.course.display")
public void pdfPreview(String id, HttpServletResponse response) throws IOException {
if (!learningFileAccessService.check(id, false, response)) return;
if (StrUtil.isBlank(id)) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "文件ID不能为空");
return;
}
Sys_file file = dao.fetch(Sys_file.class, id);
if (file == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "文件不存在");
return;
}
byte[] bytes;
if (SysFileEngineTypeEnum.MINIO.getValue().equals(file.getEngine())) {
bytes = SysFileMinIoUtil.getFileBytes(file.getBucket(), file.getStoragePath());
} else {
File localFile = FileUtil.file(file.getStoragePath());
if (!FileUtil.exist(localFile)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "文件不存在");
return;
}
bytes = IoUtil.readBytes(FileUtil.getInputStream(localFile));
}
if (!"pdf".equalsIgnoreCase(file.getSuffix())) {
File sourceFile = File.createTempFile("learning_preview_origin", "." + file.getSuffix());
File pdfFile = File.createTempFile("learning_preview", ".pdf");
try {
Files.write(sourceFile.toPath(), bytes, StandardOpenOption.WRITE);
OfficePlusUtil.convert(sourceFile.getPath(), pdfFile.getPath());
if (!FileUtil.exist(pdfFile) || pdfFile.length() == 0) {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "文件转换PDF失败");
return;
}
bytes = IoUtil.readBytes(FileUtil.getInputStream(pdfFile));
} finally {
FileUtil.del(sourceFile);
FileUtil.del(pdfFile);
}
}
String fileName = StrUtil.blankToDefault(FileUtil.mainName(file.getName()), "preview") + ".pdf";
String encodedName = URLEncoder.encode(fileName, CharsetUtil.UTF_8).replace("+", "%20");
response.setContentType("application/pdf");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"preview.pdf\"; filename*=UTF-8''" + encodedName);
response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(bytes.length));
try (OutputStream out = response.getOutputStream()) {
out.write(bytes);
}
}
@At
@ApiOperation("启用课程类型")
@SaCheckPermission("learning.course.display")
public Result courseTypes() {
return Result.success(dao.query(LearningCourseType.class, Cnd.where("enabled", "=", true).asc("sortNum").asc("createdAt")));
}
@At
@ApiOperation("推荐标识")
@SaCheckPermission("learning.course.display")
public Result recommendOptions() {
Sys_dict root = sysDictService.fetch(Cnd.where("code", "=", "学习教育").or("name", "=", "学习教育"));
if (root == null) {
return Result.success(List.of());
}
List<Sys_dict> firstLevel = sysDictService.query(Cnd.where("parentId", "=", Strings.sNull(root.getId())).and("disabled", "=", false).asc("location"));
Sys_dict group = firstLevel.stream()
.filter(dict -> "推荐标识".equals(dict.getName()) || "推荐标识".equals(dict.getCode()))
.findFirst()
.orElse(null);
if (group == null) {
return Result.success(firstLevel);
}
List<Sys_dict> children = sysDictService.query(Cnd.where("parentId", "=", Strings.sNull(group.getId())).and("disabled", "=", false).asc("location"));
return Result.success(children.isEmpty() ? firstLevel : children);
}
private int value(Integer value) {
return value == null ? 0 : value;
}
/**
* 读取指定资源的最高已观看秒数。该值用于两端限制快进,最近播放位置仍单独用于续播。
*/
private int resourceMaxPosition(LearningStudyRecord record, String resourceId) {
if (record == null || StrUtil.isBlank(resourceId)) {
return 0;
}
if (StrUtil.isBlank(record.getResourceMaxPositionData())) {
return value(record.getLastPositionSeconds());
}
try {
NutMap positions = Json.fromJson(NutMap.class, record.getResourceMaxPositionData());
return Math.max(0, positions.getInt(resourceId, 0));
} catch (Exception ignored) {
// 历史记录未保存资源级位置时,从零开始建立最高观看位置。
return 0;
}
}
}
@@ -0,0 +1,247 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.sys.models.Sys_dict;
import com.budwk.app.sys.services.SysDictService;
import com.budwk.app.zhgh.learning.models.LearningCourse;
import com.budwk.app.zhgh.learning.models.LearningCourseOutline;
import com.budwk.app.zhgh.learning.models.LearningCourseType;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Strings;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@IocBean
@Ok("json:full")
@Api("学习教育平台-课程管理")
@At("/platform/learning/course/manage")
public class LearningCourseManageController {
@Inject
private Dao dao;
@Inject
private SysDictService sysDictService;
@At("")
@Ok("beetl:/platform/zhgh/Learning/courseManage/index.html")
@SaCheckPermission("learning.course.manage")
public void index() {
}
@At
@ApiOperation("课程列表")
@SaCheckPermission("learning.course.manage")
public Result pageData(PageForm pageForm,
String courseName,
String courseTypeId,
String startTime,
String endTime,
String lecturerName,
String status,
String recommendFlag) {
Cnd cnd = Cnd.NEW();
cnd.and(Cnd.likeEX("c.courseName", courseName));
cnd.andEX("c.courseTypeId", "=", courseTypeId);
cnd.and(Cnd.likeEX("c.lecturerName", lecturerName));
cnd.andEX("c.status", "=", status);
if (StrUtil.isNotBlank(recommendFlag)) {
cnd.and("c.recommendFlags", "like", "%" + recommendFlag + "%");
}
if (StrUtil.isNotBlank(startTime)) {
cnd.and("c.startTime", ">=", DateUtil.parse(startTime));
}
if (StrUtil.isNotBlank(endTime)) {
cnd.and("c.endTime", "<=", DateUtil.parse(endTime));
}
String orderColumn = getOrderColumn(pageForm.getPageOrderName());
String orderBy = "descending".equals(pageForm.getPageOrderBy()) ? "desc" : "asc";
if (StrUtil.isBlank(orderColumn)) {
orderColumn = "c.sortNum";
orderBy = "asc";
}
Sql countSql = Sqls.create("""
SELECT COUNT(1)
FROM learning_course c
LEFT JOIN learning_course_type t ON t.id = c.courseTypeId
$condition
""");
countSql.setCondition(cnd);
countSql.setCallback(Sqls.callback.integer());
dao.execute(countSql);
int count = countSql.getInt();
Sql listSql = Sqls.create("""
SELECT c.*, t.typeName AS courseTypeName
FROM learning_course c
LEFT JOIN learning_course_type t ON t.id = c.courseTypeId
$condition
ORDER BY $orderColumn $orderBy
""");
listSql.setCondition(cnd);
listSql.setVar("orderColumn", orderColumn);
listSql.setVar("orderBy", orderBy);
listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
dao.execute(listSql);
Pagination<NutMap> pagination = new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), count, listSql.getList(NutMap.class));
return Result.success(pagination);
}
@At
@ApiOperation("新增课程")
@SaCheckPermission("learning.course.manage")
@SLog(tag = "新增课程", msg = "课程名称:${args[0].courseName}")
@Aop(TransAop.READ_COMMITTED)
/** course 传课程表单字段,viewCount 为非负整数;返回 Result 表示新增是否成功。 */
public Result doAdd(LearningCourse course) {
Result checkResult = checkCourse(course, null);
if (checkResult != null) {
return checkResult;
}
if (StrUtil.isBlank(course.getStatus())) {
course.setStatus("draft");
}
if (course.getViewCount() == null) {
course.setViewCount(0);
}
dao.insert(course);
return Result.success();
}
@At
@ApiOperation("编辑课程")
@SaCheckPermission("learning.course.manage")
@SLog(tag = "编辑课程", msg = "课程ID:${args[0].id}")
@Aop(TransAop.READ_COMMITTED)
/** course 传课程主键及需要更新的表单字段;返回 Result 表示编辑是否成功。 */
public Result doEdit(LearningCourse course) {
if (StrUtil.isBlank(course.getId())) {
return Result.error("请选择要编辑的数据");
}
Result checkResult = checkCourse(course, course.getId());
if (checkResult != null) {
return checkResult;
}
dao.updateIgnoreNull(course);
return Result.success();
}
@At
@ApiOperation("删除课程")
@SaCheckPermission("learning.course.manage")
@SLog(tag = "删除课程", msg = "课程ID:${args[0]}")
@Aop(TransAop.READ_COMMITTED)
/** id 传课程主键;返回 Result,存在章节内容时返回不可删除提示。 */
public Result doDelete(String id) {
if (StrUtil.isBlank(id)) {
return Result.error("请选择要删除的数据");
}
int outlineCount = dao.count(LearningCourseOutline.class, Cnd.where("courseId", "=", id));
if (outlineCount > 0) {
return Result.error("该课程存在章节内容,请先删除章节内容后再删除课程");
}
dao.clear(LearningCourse.class, Cnd.where("id", "=", id));
return Result.success();
}
@At
@ApiOperation("启用课程类型")
@SaCheckPermission("learning.course.manage")
public Result courseTypes() {
return Result.success(dao.query(LearningCourseType.class, Cnd.where("enabled", "=", true).asc("sortNum").asc("createdAt")));
}
@At
@ApiOperation("学习教育数据字典")
@SaCheckPermission("learning.course.manage")
public Result learningDictOptions(String name) {
Sys_dict root = sysDictService.fetch(Cnd.where("code", "=", "学习教育").or("name", "=", "学习教育"));
if (root == null) {
return Result.success(List.of());
}
List<Sys_dict> firstLevel = sysDictService.query(Cnd.where("parentId", "=", Strings.sNull(root.getId())).and("disabled", "=", false).asc("location"));
if (StrUtil.isBlank(name)) {
return Result.success(firstLevel);
}
Sys_dict group = firstLevel.stream()
.filter(dict -> name.equals(dict.getName()) || name.equals(dict.getCode()))
.findFirst()
.orElse(null);
if (group == null) {
return Result.success(firstLevel);
}
List<Sys_dict> children = sysDictService.query(Cnd.where("parentId", "=", Strings.sNull(group.getId())).and("disabled", "=", false).asc("location"));
return Result.success(children.isEmpty() ? firstLevel : children);
}
private Result checkCourse(LearningCourse course, String excludeId) {
if (course == null || StrUtil.isBlank(course.getCourseName())) {
return Result.error("课程名称不能为空");
}
if (StrUtil.isBlank(course.getCourseTypeId())) {
return Result.error("请选择课程类型");
}
if (StrUtil.isBlank(course.getLecturerName())) {
return Result.error("授课讲师不能为空");
}
if (course.getSortNum() == null) {
return Result.error("排序编码不能为空");
}
if (course.getViewCount() != null && course.getViewCount() < 0) {
return Result.error("观看次数不能小于0");
}
if (!"long_term".equals(course.getOpenType()) && (course.getStartTime() == null || course.getEndTime() == null)) {
return Result.error("请选择开课时间");
}
Cnd cnd = Cnd.where("courseName", "=", course.getCourseName().trim());
if (StrUtil.isNotBlank(excludeId)) {
cnd.and("id", "!=", excludeId);
}
if (dao.count(LearningCourse.class, cnd) > 0) {
return Result.error("课程名称已存在");
}
course.setCourseName(course.getCourseName().trim());
if ("long_term".equals(course.getOpenType())) {
course.setStartTime(null);
course.setEndTime(null);
}
return null;
}
private String getOrderColumn(String prop) {
Map<String, String> columns = new HashMap<>();
columns.put("courseName", "c.courseName");
columns.put("courseTypeName", "t.typeName");
columns.put("lecturerName", "c.lecturerName");
columns.put("startTime", "c.startTime");
columns.put("status", "c.status");
columns.put("recommendFlags", "c.recommendFlags");
columns.put("sortNum", "c.sortNum");
columns.put("viewCount", "c.viewCount");
return columns.get(prop);
}
}
@@ -0,0 +1,110 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.learning.models.LearningCourseType;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.pager.Pager;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
@IocBean
@Ok("json:full")
@Api("学习教育平台-课程类型设置")
@At("/platform/learning/course/type")
public class LearningCourseTypeController {
@Inject
private Dao dao;
@At("")
@Ok("beetl:/platform/zhgh/Learning/courseType/index.html")
@SaCheckPermission("learning.course.type")
public void index() {
}
@At
@ApiOperation("课程类型列表")
@SaCheckPermission("learning.course.type")
public Result pageData(PageForm pageForm, @Param("typeName") String typeName) {
Cnd cnd = Cnd.NEW();
cnd.and(Cnd.likeEX("typeName", typeName));
cnd.asc("sortNum").asc("createdAt");
int count = dao.count(LearningCourseType.class, cnd);
Pager pager = dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize());
Pagination<LearningCourseType> pagination = new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), count, dao.query(LearningCourseType.class, cnd, pager));
return Result.success(pagination);
}
@At
@ApiOperation("新增课程类型")
@SaCheckPermission("learning.course.type")
@SLog(tag = "新增课程类型", msg = "课程类型名称:${args[0].typeName}")
public Result doAdd(LearningCourseType courseType) {
Result checkResult = checkCourseType(courseType, null);
if (checkResult != null) {
return checkResult;
}
if (courseType.getEnabled() == null) {
courseType.setEnabled(true);
}
dao.insert(courseType);
return Result.success();
}
@At
@ApiOperation("编辑课程类型")
@SaCheckPermission("learning.course.type")
@SLog(tag = "编辑课程类型", msg = "课程类型ID:${args[0].id}")
public Result doEdit(LearningCourseType courseType) {
if (StrUtil.isBlank(courseType.getId())) {
return Result.error("请选择要编辑的数据");
}
Result checkResult = checkCourseType(courseType, courseType.getId());
if (checkResult != null) {
return checkResult;
}
dao.updateIgnoreNull(courseType);
return Result.success();
}
@At
@ApiOperation("删除课程类型")
@SaCheckPermission("learning.course.type")
@SLog(tag = "删除课程类型", msg = "课程类型ID:${args[0]}")
public Result doDelete(String id) {
if (StrUtil.isBlank(id)) {
return Result.error("请选择要删除的数据");
}
dao.clear(LearningCourseType.class, Cnd.where("id", "=", id));
return Result.success();
}
private Result checkCourseType(LearningCourseType courseType, String excludeId) {
if (courseType == null || StrUtil.isBlank(courseType.getTypeName())) {
return Result.error("课程类型名称不能为空");
}
if (courseType.getSortNum() == null) {
return Result.error("排序编号不能为空");
}
Cnd cnd = Cnd.where("typeName", "=", courseType.getTypeName().trim());
if (StrUtil.isNotBlank(excludeId)) {
cnd.and("id", "!=", excludeId);
}
if (dao.count(LearningCourseType.class, cnd) > 0) {
return Result.error("课程类型名称已存在");
}
courseType.setTypeName(courseType.getTypeName().trim());
return null;
}
}
@@ -0,0 +1,47 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
@IocBean
@At("/platform/learning")
public class LearningPlatformController {
@At("/activity/manage")
@Ok("beetl:/platform/zhgh/Learning/activityManage/index.html")
@SaCheckPermission("learning.activity.manage")
public void activityManage() {
}
@At("/my/record")
@Ok("beetl:/platform/zhgh/Learning/myRecord/index.html")
@SaCheckPermission("learning.my.record")
public void myRecord() {
}
@At("/my/record/h5")
@Ok("beetl:/platform/zhghh5/learning/myRecord/index.html")
@SaCheckPermission("learning.my.record")
public void myRecordH5() {
}
@At("/statistics")
@Ok("beetl:/platform/zhgh/Learning/statistics/index.html")
@SaCheckPermission("learning.statistics")
public void statistics() {
}
@At("/course/h5")
@Ok("beetl:/platform/zhghh5/learning/course/index.html")
@SaCheckPermission("learning.course.display")
public void courseH5() {
}
@At("/course/h5/study")
@Ok("beetl:/platform/zhghh5/learning/course/study.html")
@SaCheckPermission("learning.course.display")
public void courseH5Study() {
}
}
@@ -0,0 +1,412 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.PageUtil;
import com.budwk.app.sys.models.Sys_union;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.learning.models.LearningCourse;
import com.budwk.app.zhgh.learning.service.LearningResourceProgressService;
import com.budwk.app.zhgh.learning.param.LearningStatisticsPageForm;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.dao.util.cri.Static;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@IocBean
@Ok("json:full")
@Api("学习教育平台-学习统计")
@At("/platform/learning/statistics")
public class LearningStatisticsController {
@Inject
private Dao dao;
@Inject
private LearningResourceProgressService resourceProgressService;
@At("")
@Ok("beetl:/platform/zhgh/Learning/statistics/index.html")
@SaCheckPermission("learning.statistics")
public void index() {
}
@At
@ApiOperation("课程统计")
@SaCheckPermission("learning.statistics")
public Result coursePageData(LearningStatisticsPageForm pageForm) {
refreshProgress(pageForm);
Cnd cnd = courseCondition(pageForm);
Sql countSql = Sqls.create("""
SELECT COUNT(1)
FROM learning_course c
$condition
""");
countSql.setCondition(cnd);
countSql.setCallback(Sqls.callback.integer());
dao.execute(countSql);
Sql listSql = Sqls.create("""
SELECT
c.id AS courseId,
c.courseName,
COUNT(DISTINCT uc.userId) AS learnerCount,
COUNT(DISTINCT CASE WHEN target.targetCount > 0 AND uc.completedOutlineCount >= target.targetCount THEN uc.userId END) AS completedCount,
IFNULL(ROUND(AVG(uc.studySeconds)), 0) AS avgStudySeconds
FROM learning_course c
LEFT JOIN (
SELECT
courseId,
COUNT(1) AS targetCount,
SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount
FROM learning_course_outline
WHERE status = 'enabled'
AND EXISTS (SELECT 1 FROM learning_outline_resource lr
WHERE lr.outlineId = learning_course_outline.id
AND lr.courseId = learning_course_outline.courseId
AND lr.status = 'enabled' AND COALESCE(lr.allowPreview, 1) = 1)
GROUP BY courseId
) target ON target.courseId = c.id
LEFT JOIN (
SELECT
r.courseId,
r.userId,
SUM(IFNULL(r.studySeconds, 0)) AS studySeconds,
COUNT(DISTINCT CASE
WHEN r.completeStatus = 'completed'
THEN r.outlineId
ELSE NULL
END) AS completedOutlineCount
FROM learning_study_record r
JOIN learning_course_outline o ON o.id = r.outlineId AND o.courseId = r.courseId AND o.status = 'enabled'
JOIN (
SELECT
courseId,
COUNT(1) AS targetCount,
SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount
FROM learning_course_outline
WHERE status = 'enabled'
AND EXISTS (SELECT 1 FROM learning_outline_resource lr
WHERE lr.outlineId = learning_course_outline.id
AND lr.courseId = learning_course_outline.courseId
AND lr.status = 'enabled' AND COALESCE(lr.allowPreview, 1) = 1)
GROUP BY courseId
) target ON target.courseId = r.courseId
$recordCondition
GROUP BY r.courseId, r.userId
) uc ON uc.courseId = c.id
$condition
GROUP BY c.id, c.courseName
ORDER BY $orderColumn $orderBy
""");
listSql.setCondition(cnd);
listSql.setVar("recordCondition", new Static(recordWhere(pageForm)));
setOrder(listSql, pageForm, "course");
listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
dao.execute(listSql);
List<NutMap> rows = listSql.getList(NutMap.class);
rows.forEach(row -> {
row.put("avgStudyTimeText", formatStudySeconds(row.getInt("avgStudySeconds", 0)));
row.put("completeRate", completeRate(row.getInt("completedCount", 0), row.getInt("learnerCount", 0)));
});
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), countSql.getInt(), rows));
}
@At
@ApiOperation("分工会统计")
@SaCheckPermission("learning.statistics")
public Result unionPageData(LearningStatisticsPageForm pageForm) {
refreshProgress(pageForm);
Cnd cnd = unionCondition(pageForm);
Sql countSql = Sqls.create("""
SELECT COUNT(1)
FROM learning_course c
JOIN (
SELECT r.courseId, r.unionId
FROM learning_study_record r
$recordCondition
GROUP BY r.courseId, r.unionId
) uc ON uc.courseId = c.id
LEFT JOIN sys_union un ON un.id = uc.unionId
$condition
""");
countSql.setCondition(cnd);
countSql.setVar("recordCondition", new Static(recordWhere(pageForm)));
countSql.setCallback(Sqls.callback.integer());
dao.execute(countSql);
Sql listSql = Sqls.create("""
SELECT
c.id AS courseId,
c.courseName,
un.id AS unionId,
IFNULL(un.name, uc.unionName) AS unionName,
COUNT(DISTINCT uc.userId) AS learnerCount,
IFNULL(SUM(uc.studySeconds), 0) AS studySeconds,
COUNT(DISTINCT CASE WHEN target.targetCount > 0 AND uc.completedOutlineCount >= target.targetCount THEN uc.userId END) AS completedCount
FROM learning_course c
JOIN (
SELECT
r.courseId,
r.userId,
r.unionId,
MAX(r.unionName) AS unionName,
SUM(IFNULL(r.studySeconds, 0)) AS studySeconds,
COUNT(DISTINCT CASE
WHEN r.completeStatus = 'completed'
THEN r.outlineId
ELSE NULL
END) AS completedOutlineCount
FROM learning_study_record r
JOIN learning_course_outline o ON o.id = r.outlineId AND o.courseId = r.courseId AND o.status = 'enabled'
JOIN (
SELECT
courseId,
COUNT(1) AS targetCount,
SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount
FROM learning_course_outline
WHERE status = 'enabled'
AND EXISTS (SELECT 1 FROM learning_outline_resource lr
WHERE lr.outlineId = learning_course_outline.id
AND lr.courseId = learning_course_outline.courseId
AND lr.status = 'enabled' AND COALESCE(lr.allowPreview, 1) = 1)
GROUP BY courseId
) target ON target.courseId = r.courseId
$recordCondition
GROUP BY r.courseId, r.userId, r.unionId
) uc ON uc.courseId = c.id
LEFT JOIN sys_union un ON un.id = uc.unionId
LEFT JOIN (
SELECT
courseId,
COUNT(1) AS targetCount,
SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount
FROM learning_course_outline
WHERE status = 'enabled'
AND EXISTS (SELECT 1 FROM learning_outline_resource lr
WHERE lr.outlineId = learning_course_outline.id
AND lr.courseId = learning_course_outline.courseId
AND lr.status = 'enabled' AND COALESCE(lr.allowPreview, 1) = 1)
GROUP BY courseId
) target ON target.courseId = c.id
$condition
GROUP BY c.id, c.courseName, uc.unionId, un.id, un.name
ORDER BY $orderColumn $orderBy
""");
listSql.setCondition(cnd);
listSql.setVar("recordCondition", new Static(recordWhere(pageForm)));
setOrder(listSql, pageForm, "union");
listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
dao.execute(listSql);
List<NutMap> rows = listSql.getList(NutMap.class);
rows.forEach(row -> {
int learnerCount = row.getInt("learnerCount", 0);
int completedCount = row.getInt("completedCount", 0);
row.put("studyTimeText", formatStudySeconds(row.getInt("studySeconds", 0)));
row.put("completeRate", completeRate(completedCount, learnerCount));
});
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), countSql.getInt(), rows));
}
@At
@ApiOperation("学习列表")
@SaCheckPermission("learning.statistics")
public Result recordPageData(LearningStatisticsPageForm pageForm) {
refreshProgress(pageForm);
Cnd cnd = Cnd.NEW();
cnd.andEX("r.courseId", "=", pageForm.getCourseId());
cnd.andEX("r.unionId", "=", pageForm.getUnionId());
cnd.and(Cnd.likeEX("r.loginName", pageForm.getLoginName()));
cnd.and(Cnd.likeEX("r.userName", pageForm.getUserName()));
if ("completed".equals(pageForm.getCompleteStatus())) {
cnd.and("r.completeStatus", "=", "completed");
} else if ("incomplete".equals(pageForm.getCompleteStatus())) {
cnd.and(Cnd.exps("r.completeStatus", "!=", "completed").or("r.completeStatus", "is", null));
}
if (!hasSchoolScope()) {
if (hasBranchScope()) {
cnd.and("r.unionId", "=", SecurityUtil.getUnionId());
} else {
cnd.and("r.userId", "=", SecurityUtil.getUserId());
}
}
String from = """
FROM learning_study_record r
JOIN learning_course_outline o ON o.id = r.outlineId AND o.courseId = r.courseId AND o.status = 'enabled'
JOIN (SELECT DISTINCT outlineId, courseId FROM learning_outline_resource
WHERE status = 'enabled' AND COALESCE(allowPreview, 1) = 1) lr
ON lr.outlineId = r.outlineId AND lr.courseId = r.courseId
""";
Sql countSql = Sqls.create("SELECT COUNT(1) " + from + " $condition");
countSql.setCondition(cnd);
countSql.setCallback(Sqls.callback.integer());
dao.execute(countSql);
Map<String, String> columns = new HashMap<>();
for (String column : List.of("loginName", "userName", "unionName", "courseName", "outlineName",
"firstStudyTime", "latestStudyTime", "studySeconds", "progressPercent", "completeStatus")) {
columns.put(column, "r." + column);
}
String orderColumn = columns.get(pageForm.getPageOrderName());
if (StrUtil.isNotBlank(orderColumn) && StrUtil.isNotBlank(pageForm.getPageOrderBy())) {
cnd.orderBy(orderColumn, PageUtil.getOrder(pageForm.getPageOrderBy()));
} else {
cnd.desc("r.latestStudyTime");
}
Sql listSql = Sqls.create("SELECT r.* " + from + " $condition");
listSql.setCondition(cnd);
listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
dao.execute(listSql);
List<NutMap> rows = listSql.getList(NutMap.class);
rows.forEach(row -> row.put("studyTimeText", formatStudySeconds(row.getInt("studySeconds", 0))));
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), countSql.getInt(), rows));
}
@At
@ApiOperation("可选课程")
@SaCheckPermission("learning.statistics")
public Result courseOptions(String courseName) {
Cnd cnd = Cnd.NEW();
cnd.and(Cnd.likeEX("courseName", courseName));
cnd.desc("createdAt").asc("sortNum");
return Result.success(dao.query(LearningCourse.class, cnd, dao.createPager(1, 50)));
}
@At
@ApiOperation("可选分工会")
@SaCheckPermission("learning.statistics")
public Result unionOptions() {
if (hasSchoolScope()) {
return Result.success(dao.query(Sys_union.class, Cnd.NEW().asc("unionCode").asc("name")));
}
if (hasBranchScope()) {
return Result.success(dao.query(Sys_union.class, Cnd.where("id", "=", SecurityUtil.getUnionId())));
}
return Result.success(List.of());
}
private void refreshProgress(LearningStatisticsPageForm pageForm) {
Cnd cnd = Cnd.NEW();
cnd.andEX("courseId", "=", pageForm.getCourseId());
cnd.andEX("unionId", "=", pageForm.getUnionId());
if (!hasSchoolScope()) {
if (hasBranchScope()) cnd.and("unionId", "=", SecurityUtil.getUnionId());
else cnd.and("userId", "=", SecurityUtil.getUserId());
}
resourceProgressService.refreshRecords(cnd);
}
private Cnd courseCondition(LearningStatisticsPageForm pageForm) {
Cnd cnd = Cnd.NEW();
cnd.andEX("c.id", "=", pageForm.getCourseId());
cnd.and(Cnd.likeEX("c.courseName", pageForm.getCourseName()));
return cnd;
}
private Cnd unionCondition(LearningStatisticsPageForm pageForm) {
Cnd cnd = courseCondition(pageForm);
cnd.andEX("uc.unionId", "=", pageForm.getUnionId());
return cnd;
}
private String recordWhere(LearningStatisticsPageForm pageForm) {
StringBuilder where = new StringBuilder("WHERE EXISTS (SELECT 1 FROM learning_course_outline so "
+ "JOIN learning_outline_resource sr ON sr.outlineId = so.id AND sr.courseId = so.courseId "
+ "WHERE so.id = r.outlineId AND so.courseId = r.courseId AND so.status = 'enabled' "
+ "AND sr.status = 'enabled' AND COALESCE(sr.allowPreview, 1) = 1)");
if (!hasSchoolScope() && hasBranchScope()) {
where.append(" AND r.unionId = '").append(escapeSql(SecurityUtil.getUnionId())).append("'");
} else if (!hasSchoolScope()) {
where.append(" AND r.userId = '").append(escapeSql(SecurityUtil.getUserId())).append("'");
}
if (StrUtil.isNotBlank(pageForm.getUnionId())) {
where.append(" AND r.unionId = '").append(escapeSql(pageForm.getUnionId())).append("'");
}
return where.toString();
}
private void setOrder(Sql sql, LearningStatisticsPageForm pageForm, String type) {
String orderColumn = "union".equals(type) ? getUnionOrderColumn(pageForm.getPageOrderName()) : getCourseOrderColumn(pageForm.getPageOrderName());
String orderBy = PageUtil.getOrder(pageForm.getPageOrderBy());
if (StrUtil.isBlank(orderColumn)) {
orderColumn = "union".equals(type) ? "c.courseName ASC, un.unionCode" : "c.sortNum ASC, c.createdAt";
orderBy = "DESC";
} else if (StrUtil.isBlank(orderBy)) {
orderBy = "ASC";
}
sql.setVar("orderColumn", new Static(orderColumn));
sql.setVar("orderBy", new Static(orderBy));
}
private String getCourseOrderColumn(String prop) {
Map<String, String> columns = new HashMap<>();
columns.put("courseName", "c.courseName");
columns.put("learnerCount", "learnerCount");
columns.put("completedCount", "completedCount");
columns.put("avgStudySeconds", "avgStudySeconds");
columns.put("completeRate", "completedCount / NULLIF(learnerCount, 0)");
return columns.get(prop);
}
private String getUnionOrderColumn(String prop) {
Map<String, String> columns = new HashMap<>();
columns.put("courseName", "c.courseName");
columns.put("unionName", "unionName");
columns.put("learnerCount", "learnerCount");
columns.put("studySeconds", "studySeconds");
columns.put("completeRate", "completedCount / NULLIF(learnerCount, 0)");
columns.put("completedCount", "completedCount");
return columns.get(prop);
}
private boolean hasSchoolScope() {
return AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name());
}
private boolean hasBranchScope() {
return AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_ADMIN.name());
}
private String escapeSql(String value) {
return value == null ? "" : value.replace("'", "''");
}
private String completeRate(int completedCount, int learnerCount) {
return learnerCount == 0 ? "0%" : BigDecimal.valueOf(completedCount).multiply(BigDecimal.valueOf(100))
.divide(BigDecimal.valueOf(learnerCount), 2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() + "%";
}
private String formatStudySeconds(Integer seconds) {
int value = seconds == null ? 0 : seconds;
int hour = value / 3600;
int minute = value % 3600 / 60;
int second = value % 60;
if (hour > 0) {
return hour + "小时" + minute + "分" + second + "秒";
}
if (minute > 0) {
return minute + "分" + second + "秒";
}
return second + "秒";
}
}
@@ -0,0 +1,615 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.PageUtil;
import com.budwk.app.sys.models.Sys_union;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.learning.models.LearningCourse;
import com.budwk.app.zhgh.learning.models.LearningCourseOutline;
import com.budwk.app.zhgh.learning.models.LearningOutlineResource;
import com.budwk.app.zhgh.learning.models.LearningStudyRecord;
import com.budwk.app.zhgh.learning.models.LearningStudyRule;
import com.budwk.app.zhgh.learning.models.LearningStudySegment;
import com.budwk.app.zhgh.learning.service.LearningResourceProgressService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.dao.util.cri.SqlExpressionGroup;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@IocBean
@Ok("json:full")
@Api("学习教育平台-学习记录")
@At("/platform/learning/study/record")
public class LearningStudyRecordController {
private static final int MAX_HEARTBEAT_SECONDS = 30;
@Inject
private Dao dao;
@Inject
private LearningResourceProgressService resourceProgressService;
@At
@ApiOperation("学习记录分页")
@SaCheckPermission("learning.my.record")
public Result pageData(PageForm pageForm,
@Param("keyword") String keyword,
@Param("unionId") String unionId,
@Param("courseId") String courseId,
@Param("completeStatus") String completeStatus) {
resourceProgressService.refreshRecords(Cnd.where("userId", "=", SecurityUtil.getUserId()));
Cnd cnd = Cnd.NEW();
if (StrUtil.isNotBlank(keyword)) {
SqlExpressionGroup seg = new SqlExpressionGroup();
seg.or("r.loginName", "like", "%" + keyword + "%");
seg.or("r.userName", "like", "%" + keyword + "%");
cnd.and(seg);
}
cnd.andEX("r.unionId", "=", unionId);
cnd.andEX("r.courseId", "=", courseId);
cnd.andEX("r.completeStatus", "=", completeStatus);
cnd.and("r.userId", "=", SecurityUtil.getUserId());
String orderColumn = getOrderColumn(pageForm.getPageOrderName());
if (StrUtil.isNotBlank(orderColumn) && StrUtil.isNotBlank(pageForm.getPageOrderBy())) {
cnd.orderBy(orderColumn, PageUtil.getOrder(pageForm.getPageOrderBy()));
} else {
cnd.desc("r.latestStudyTime");
}
Sql countSql = Sqls.create("SELECT COUNT(1) FROM learning_study_record r $condition");
countSql.setCondition(cnd);
countSql.setCallback(Sqls.callback.integer());
dao.execute(countSql);
Sql listSql = Sqls.create("""
SELECT r.*
FROM learning_study_record r
$condition
""");
listSql.setCondition(cnd);
listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
dao.execute(listSql);
List<NutMap> rows = listSql.getList(NutMap.class);
rows.forEach(row -> row.put("studyTimeText", formatStudySeconds(row.getInt("studySeconds", 0))));
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), countSql.getInt(), rows));
}
@At
@ApiOperation("移动端我的学习汇总")
@SaCheckPermission("learning.my.record")
public Result h5Summary() {
String userId = SecurityUtil.getUserId();
java.util.Set<String> courseIds = new java.util.HashSet<>();
for (LearningStudyRecord record : dao.query(LearningStudyRecord.class, Cnd.where("userId", "=", userId))) {
courseIds.add(record.getCourseId());
}
int seconds = 0, outlines = 0, completedOutlines = 0, completedCourses = 0;
for (String courseId : courseIds) {
NutMap state = resourceProgressService.courseState(courseId, userId);
seconds += actualStudySeconds(courseId, userId);
outlines += state.getInt("outlineCount", 0);
completedOutlines += state.getInt("completedOutlineCount", 0);
if (state.getInt("outlineCount", 0) > 0 && state.getInt("progressPercent", 0) == 100) completedCourses++;
}
NutMap data = NutMap.NEW().addv("studySeconds", seconds).addv("courseCount", courseIds.size())
.addv("completedCourseCount", completedCourses).addv("outlineCount", outlines)
.addv("completedOutlineCount", completedOutlines);
int studySeconds = data.getInt("studySeconds", 0);
data.put("studyHour", studySeconds / 3600);
data.put("studyMinute", studySeconds % 3600 / 60);
return Result.success(data);
}
@At
@ApiOperation("移动端我的学习课程")
@SaCheckPermission("learning.my.record")
public Result h5Courses() {
String userId = SecurityUtil.getUserId();
Sql sql = Sqls.create("""
SELECT r.courseId,
MAX(r.courseName) AS courseName,
MAX(c.cover) AS cover,
COUNT(1) AS outlineCount,
SUM(CASE WHEN r.completeStatus = 'completed' THEN 1 ELSE 0 END) AS completedOutlineCount,
IFNULL(SUM(IFNULL(r.studySeconds, 0)), 0) AS studySeconds,
IFNULL(ROUND(AVG(IFNULL(r.progressPercent, 0))), 0) AS progressPercent,
MAX(r.latestStudyTime) AS latestStudyTime
FROM learning_study_record r
LEFT JOIN learning_course c ON c.id = r.courseId
WHERE r.userId = @userId
AND EXISTS (SELECT 1 FROM learning_outline_resource pr WHERE pr.outlineId = r.outlineId
AND pr.status = 'enabled' AND COALESCE(pr.allowPreview, 1) = 1)
GROUP BY r.courseId
ORDER BY latestStudyTime DESC
""");
sql.params().set("userId", userId);
sql.setCallback(Sqls.callback.maps());
dao.execute(sql);
List<NutMap> rows = sql.getList(NutMap.class);
rows.forEach(row -> {
row.putAll(resourceProgressService.courseState(row.getString("courseId"), userId));
row.put("studySeconds", actualStudySeconds(row.getString("courseId"), userId));
row.put("studyTimeText", formatStudySeconds(row.getInt("studySeconds", 0)));
});
rows.sort(java.util.Comparator.comparingInt((NutMap row) ->
row.getInt("progressPercent", 0) == 100 && row.getInt("outlineCount", 0) > 0
&& row.getInt("completedOutlineCount", 0) >= row.getInt("outlineCount", 0) ? 1 : 0));
return Result.success(rows);
}
@At
@ApiOperation("开始学习")
@SaCheckPermission("learning.course.display")
@Aop(TransAop.READ_COMMITTED)
/**
* courseId、resourceId 分别传课程和资源主键,positionSeconds 传当前播放秒数;
* 返回学习记录、学习时段、学习进度和本次自增后的课程观看次数。
*/
public Result start(@Param("courseId") String courseId,
@Param("resourceId") String resourceId,
@Param("positionSeconds") Integer positionSeconds) {
if (StrUtil.hasBlank(courseId, resourceId)) {
return Result.error("请选择课程资料后开始学习");
}
LearningCourse course = dao.fetch(LearningCourse.class, courseId);
LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, resourceId);
if (course == null || resource == null || !courseId.equals(resource.getCourseId())) {
return Result.error("课程资料不存在");
}
if (Boolean.FALSE.equals(resource.getAllowPreview()) || !"enabled".equals(resource.getStatus())) {
return Result.error("该资料不允许学习预览");
}
LearningCourseOutline outline = dao.fetch(LearningCourseOutline.class, resource.getOutlineId());
if (outline == null) {
return Result.error("章节不存在");
}
String userId = SecurityUtil.getUserId();
Date now = new Date();
closeUnfinishedSegments(userId, "interrupted");
LearningStudyRecord record = dao.fetch(LearningStudyRecord.class, Cnd.where("userId", "=", userId)
.and("courseId", "=", courseId)
.and("outlineId", "=", outline.getId()));
if (record == null) {
record = buildRecord(userId, course, outline, now);
dao.insert(record);
} else {
record = resourceProgressService.lock(record.getId());
}
int startPositionSeconds = sanitizePosition(positionSeconds, 0);
resourceProgressService.savePosition(record, resourceId, startPositionSeconds, null, false);
dao.update(LearningStudyRecord.class, Chain.make("latestStudyTime", now)
.add("courseName", course.getCourseName()).add("outlineName", outline.getTitle())
.add("lastPositionSeconds", startPositionSeconds)
.add("resourceMaxPositionData", record.getResourceMaxPositionData()),
Cnd.where("id", "=", record.getId()));
LearningStudySegment segment = new LearningStudySegment();
segment.setRecordId(record.getId());
segment.setUserId(userId);
segment.setCourseId(courseId);
segment.setOutlineId(outline.getId());
segment.setResourceId(resourceId);
segment.setResourceName(resource.getResourceTitle());
segment.setStartTime(now);
segment.setLastHeartbeatTime(now);
segment.setActiveSeconds(0);
segment.setState("studying");
dao.insert(segment);
// 每次成功创建学习时段记为一次观看,使用原子自增避免并发开始学习时丢失计数。
dao.update(LearningCourse.class,
Chain.makeSpecial("viewCount", "IFNULL(viewCount, 0) + 1"),
Cnd.where("id", "=", courseId));
LearningCourse updatedCourse = dao.fetch(LearningCourse.class, courseId);
resourceProgressService.refreshOutline(record, requiredSeconds(outline.getId()));
NutMap resourceState = resourceProgressService.state(record, resource);
resourceState.addv("courseProgress", resourceProgressService.courseState(record.getCourseId(), record.getUserId()));
resourceState.addv("recordId", record.getId()).addv("segmentId", segment.getId())
.addv("viewCount", value(updatedCourse == null ? null : updatedCourse.getViewCount()));
return Result.success(resourceState);
}
@At
@ApiOperation("学习心跳")
@SaCheckPermission("learning.course.display")
@Aop(TransAop.READ_COMMITTED)
public Result heartbeat(@Param("segmentId") String segmentId,
@Param("activeSeconds") Integer activeSeconds,
@Param("positionSeconds") Integer positionSeconds) {
LearningStudySegment segment = fetchOwnStudyingSegment(segmentId);
if (segment == null) {
return Result.error("学习时段已结束,请重新开始学习");
}
int seconds = Math.max(0, Math.min(activeSeconds == null ? 0 : activeSeconds, MAX_HEARTBEAT_SECONDS));
return Result.success(addActiveSeconds(segment, seconds, false, false, positionSeconds));
}
@At
@ApiOperation("结束学习")
@SaCheckPermission("learning.course.display")
@Aop(TransAop.READ_COMMITTED)
/**
* segmentId 传当前学习时段主键,activeSeconds 传本次尚未上报的有效学习秒数,positionSeconds 传当前播放位置;
* completed 仅在视频或音频自然播放结束时传 true。返回值为 NutMap,包含累计学习时长、进度和完成状态。
*/
public Result finish(@Param("segmentId") String segmentId,
@Param("activeSeconds") Integer activeSeconds,
@Param("positionSeconds") Integer positionSeconds,
@Param("completed") Boolean completed) {
LearningStudySegment segment = fetchOwnStudyingSegment(segmentId);
if (segment == null) {
return Result.success();
}
int seconds = Math.max(0, Math.min(activeSeconds == null ? 0 : activeSeconds, MAX_HEARTBEAT_SECONDS));
NutMap result = addActiveSeconds(segment, seconds, true, Boolean.TRUE.equals(completed), positionSeconds);
dao.update(LearningStudySegment.class, Chain.make("endTime", new Date()).add("state", "finished"), Cnd.where("id", "=", segmentId));
return Result.success(result);
}
@At
@ApiOperation("保存播放位置")
@SaCheckPermission("learning.course.display")
@Aop(TransAop.READ_COMMITTED)
public Result position(@Param("courseId") String courseId,
@Param("resourceId") String resourceId,
@Param("positionSeconds") Integer positionSeconds) {
if (StrUtil.hasBlank(courseId, resourceId) || positionSeconds == null || positionSeconds < 0) {
return Result.success();
}
LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, resourceId);
if (resource == null || !courseId.equals(resource.getCourseId())) {
return Result.success();
}
LearningStudyRecord record = dao.fetch(LearningStudyRecord.class,
Cnd.where("userId", "=", SecurityUtil.getUserId()).and("courseId", "=", courseId)
.and("outlineId", "=", resource.getOutlineId()));
if (record == null) return Result.success();
record = resourceProgressService.lock(record.getId());
resourceProgressService.savePosition(record, resourceId, positionSeconds, null, false);
dao.update(LearningStudyRecord.class, Chain.make("lastPositionSeconds", positionSeconds)
.add("latestStudyTime", new Date()).add("resourceMaxPositionData", record.getResourceMaxPositionData()),
Cnd.where("id", "=", record.getId()));
return Result.success();
}
@At
@ApiOperation("可选课程")
@SaCheckPermission("learning.my.record")
public Result courseOptions() {
return Result.success(dao.query(LearningCourse.class, Cnd.NEW().asc("sortNum").desc("createdAt")));
}
@At
@ApiOperation("可选分工会")
@SaCheckPermission("learning.my.record")
public Result unionOptions() {
if (hasSchoolScope()) {
return Result.success(dao.query(Sys_union.class, Cnd.NEW().asc("unionCode").asc("name")));
}
if (hasBranchScope()) {
return Result.success(dao.query(Sys_union.class, Cnd.where("id", "=", SecurityUtil.getUnionId())));
}
return Result.success(List.of());
}
@At
@ApiOperation("删除学习记录")
@SaCheckPermission("learning.my.record")
public Result delete(@Param("id") String id) {
if (StrUtil.isBlank(id)) {
return Result.error("请选择要删除的学习记录");
}
LearningStudyRecord record = dao.fetch(LearningStudyRecord.class, id);
if (record == null) {
return Result.error("学习记录不存在");
}
if (!canOperate(record)) {
return Result.error("无权删除该学习记录");
}
dao.clear(LearningStudySegment.class, Cnd.where("recordId", "=", id));
dao.clear(LearningStudyRecord.class, Cnd.where("id", "=", id));
return Result.success();
}
private LearningStudyRecord buildRecord(String userId, LearningCourse course, LearningCourseOutline outline, Date now) {
NutMap user = currentUserInfo(userId);
int requiredSeconds = requiredSeconds(outline.getId());
LearningStudyRecord record = new LearningStudyRecord();
record.setUserId(userId);
record.setLoginName(user.getString("loginname", SecurityUtil.getUserLoginname()));
record.setUserName(user.getString("username", SecurityUtil.getUserUsername()));
record.setUnionId(user.getString("unionId", SecurityUtil.getUnionId()));
record.setUnionName(user.getString("unionName", ""));
record.setCourseId(course.getId());
record.setCourseName(course.getCourseName());
record.setOutlineId(outline.getId());
record.setOutlineName(outline.getTitle());
record.setFirstStudyTime(now);
record.setLatestStudyTime(now);
record.setStudySeconds(0);
record.setLastPositionSeconds(0);
record.setResourceMaxPositionData("{}");
record.setRequiredSeconds(requiredSeconds);
record.setProgressPercent(0);
record.setCompleteStatus("studying");
return record;
}
private NutMap addActiveSeconds(LearningStudySegment segment, int activeSeconds, boolean finish, boolean completed, Integer positionSeconds) {
Date now = new Date();
LearningStudyRecord record = resourceProgressService.lock(segment.getRecordId());
LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, segment.getResourceId());
segment = dao.fetch(LearningStudySegment.class, segment.getId());
if (!"studying".equals(segment.getState())) {
return resourceProgressService.state(record, resource);
}
if (Boolean.FALSE.equals(resource.getAllowPreview()) || !"enabled".equals(resource.getStatus())) {
dao.update(LearningStudySegment.class, Chain.make("state", "interrupted").add("endTime", now),
Cnd.where("id", "=", segment.getId()));
resourceProgressService.refreshOutline(record, requiredSeconds(record.getOutlineId()));
return resourceProgressService.state(record, resource);
}
NutMap before = resourceProgressService.state(record, resource);
int increment = effectiveStudySeconds(record, resource, segment.getResourceId(), activeSeconds, positionSeconds);
LearningStudyRule rule = resourceProgressService.outlineRule(record.getOutlineId());
boolean timeRule = rule != null && "min_study_time".equals(rule.getCompletionRule());
if (timeRule) increment = activeSeconds;
int duration = value(resource.getDurationSeconds());
// 自然播放结束以到达资源末尾为准,补齐计时器和心跳延迟遗漏的秒数。
boolean naturalEnd = completed && isMediaResource(resource) && positionSeconds != null && positionSeconds > 0
&& (duration <= 0 || positionSeconds >= Math.max(0, duration - 2));
if (naturalEnd && duration > 0 && !timeRule) {
increment = Math.max(increment, duration - before.getInt("studySeconds", 0));
}
Chain segmentChain = Chain.make("activeSeconds", value(segment.getActiveSeconds()) + increment)
.add("lastHeartbeatTime", now);
if (finish) segmentChain.add("state", "finished").add("endTime", now);
dao.update(LearningStudySegment.class, segmentChain, Cnd.where("id", "=", segment.getId()));
Integer maxPosition = isMediaResource(resource) && positionSeconds != null ? Math.max(0, positionSeconds) : null;
resourceProgressService.savePosition(record, resource.getId(), positionSeconds, maxPosition, naturalEnd);
Chain chain = Chain.make("latestStudyTime", now).add("resourceMaxPositionData", record.getResourceMaxPositionData());
if (positionSeconds != null && positionSeconds >= 0) chain.add("lastPositionSeconds", positionSeconds);
dao.update(LearningStudyRecord.class, chain, Cnd.where("id", "=", record.getId()));
resourceProgressService.refreshOutline(record, requiredSeconds(record.getOutlineId()));
NutMap result = resourceProgressService.state(record, resource);
result.addv("courseProgress", resourceProgressService.courseState(record.getCourseId(), record.getUserId()));
result.addv("studyTimeText", formatStudySeconds(result.getInt("studySeconds", 0)));
return result;
}
private int actualStudySeconds(String courseId, String userId) {
Sql sql = Sqls.create("""
SELECT COALESCE(SUM(s.activeSeconds), 0)
FROM learning_study_segment s
JOIN learning_study_record r ON r.id = s.recordId AND r.userId = s.userId AND r.courseId = s.courseId
WHERE s.courseId = @courseId AND s.userId = @userId
""");
sql.params().set("courseId", courseId).set("userId", userId);
sql.setCallback(Sqls.callback.integer());
dao.execute(sql);
return Math.max(0, sql.getInt());
}
private LearningStudySegment fetchOwnStudyingSegment(String segmentId) {
if (StrUtil.isBlank(segmentId)) {
return null;
}
return dao.fetch(LearningStudySegment.class, Cnd.where("id", "=", segmentId)
.and("userId", "=", SecurityUtil.getUserId())
.and("state", "=", "studying"));
}
private void closeUnfinishedSegments(String userId, String state) {
dao.update(LearningStudySegment.class,
Chain.make("endTime", new Date()).add("state", state),
Cnd.where("userId", "=", userId).and("state", "=", "studying"));
}
private int requiredSeconds(String outlineId) {
return resourceProgressService.requiredSeconds(outlineId);
}
/**
* 校验媒体自然播放结束能否完成当前章节。
* segment 表示当前用户的学习时段;positionSeconds 必须到达资源末尾附近;requiredSeconds 为章节要求学习时长。
* 仅单资源章节且配置时长不超过媒体时长时返回 true,防止一个资源结束误完成包含多个资源或设置了更长时长的章节。
*/
private boolean canCompleteByNaturalEnd(LearningStudySegment segment, Integer positionSeconds, int requiredSeconds) {
if (positionSeconds == null || requiredSeconds <= 0) {
return false;
}
LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, segment.getResourceId());
if (resource == null || !("video".equals(resource.getResourceType()) || "audio".equals(resource.getResourceType()))) {
return false;
}
int durationSeconds = value(resource.getDurationSeconds());
if (durationSeconds <= 0 || positionSeconds < Math.max(0, durationSeconds - 2) || requiredSeconds > durationSeconds) {
return false;
}
Sql countSql = Sqls.create("SELECT COUNT(1) FROM learning_outline_resource WHERE outlineId = @outlineId AND status = 'enabled'");
countSql.params().set("outlineId", segment.getOutlineId());
countSql.setCallback(Sqls.callback.integer());
dao.execute(countSql);
return countSql.getInt() == 1;
}
/**
* 仅统计当前资源超过历史最高观看位置的新增区间,回退复习不会重复累计学习时长。
* record 保存各资源最高位置,resource 用于区分媒体和非媒体资料,positionSeconds 为本次心跳时的播放器位置。
*/
private int effectiveStudySeconds(LearningStudyRecord record, LearningOutlineResource resource, String resourceId, int activeSeconds, Integer positionSeconds) {
if (!isMediaResource(resource)) {
return activeSeconds;
}
int currentPositionSeconds = Math.max(0, positionSeconds == null ? 0 : positionSeconds);
int maxPositionSeconds = resourceMaxPosition(record, resourceId);
return Math.min(activeSeconds, Math.max(0, currentPositionSeconds - maxPositionSeconds));
}
/**
* 读取学习记录中按资源保存的最高已观看秒数。历史记录或异常 JSON 视为未保存,避免影响已有记录的使用。
*/
private int resourceMaxPosition(LearningStudyRecord record, String resourceId) {
return value(resourceMaxPositions(record == null ? null : record.getResourceMaxPositionData()).get(resourceId));
}
/**
* 将资源 ID 与最高观看秒数转换为可持久化数据;键为资源 ID,值为该资源累计到达过的最远秒数。
*/
private String resourceMaxPositionData(String resourceId, int positionSeconds) {
Map<String, Integer> positions = new HashMap<>();
positions.put(resourceId, Math.max(0, positionSeconds));
return Json.toJson(positions);
}
/**
* 解析资源最高位置 JSON。返回值为资源 ID 到秒数的映射,空值或异常数据返回空映射。
*/
private Map<String, Integer> resourceMaxPositions(String data) {
Map<String, Integer> positions = new HashMap<>();
if (StrUtil.isBlank(data)) {
return positions;
}
try {
NutMap source = Json.fromJson(NutMap.class, data);
for (String resourceId : source.keySet()) {
positions.put(resourceId, Math.max(0, source.getInt(resourceId, 0)));
}
} catch (Exception ignored) {
// 历史记录可能没有该字段或保存了异常内容,按未记录最高位置处理。
}
return positions;
}
/**
* 判断资料是否使用播放器计时。视频和音频按未重复观看区间累计,其他资料沿用活跃时长累计。
*/
private boolean isMediaResource(LearningOutlineResource resource) {
return resource != null && ("video".equals(resource.getResourceType()) || "audio".equals(resource.getResourceType()));
}
private int progress(Integer studySeconds, int requiredSeconds) {
if (requiredSeconds <= 0) {
return 0;
}
return Math.min(100, (int) Math.floor(value(studySeconds) * 100.0 / requiredSeconds));
}
private String completeStatus(Integer studySeconds, int requiredSeconds, boolean finish) {
if (requiredSeconds <= 0) {
return finish ? "completed" : "studying";
}
if (value(studySeconds) >= requiredSeconds) {
return "completed";
}
return value(studySeconds) > 0 || finish ? "studying" : "not_started";
}
private NutMap currentUserInfo(String userId) {
Sql sql = Sqls.create("SELECT id, loginname, username, unionId AS unionId, unionName AS unionName FROM vw_user WHERE id = @id");
sql.params().set("id", userId);
sql.setCallback(Sqls.callback.map());
dao.execute(sql);
NutMap user = sql.getObject(NutMap.class);
return user == null ? NutMap.NEW() : user;
}
private void appendScope(Cnd cnd) {
if (hasSchoolScope()) {
return;
}
if (hasBranchScope()) {
cnd.and("r.unionId", "=", SecurityUtil.getUnionId());
return;
}
cnd.and("r.userId", "=", SecurityUtil.getUserId());
}
private boolean hasSchoolScope() {
return AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name());
}
private boolean hasBranchScope() {
return AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_ADMIN.name());
}
private boolean canOperate(LearningStudyRecord record) {
if (hasSchoolScope()) {
return true;
}
if (hasBranchScope()) {
return StrUtil.equals(record.getUnionId(), SecurityUtil.getUnionId());
}
return StrUtil.equals(record.getUserId(), SecurityUtil.getUserId());
}
private String getOrderColumn(String prop) {
Map<String, String> columns = new HashMap<>();
columns.put("loginName", "r.loginName");
columns.put("userName", "r.userName");
columns.put("unionName", "r.unionName");
columns.put("courseName", "r.courseName");
columns.put("outlineName", "r.outlineName");
columns.put("firstStudyTime", "r.firstStudyTime");
columns.put("latestStudyTime", "r.latestStudyTime");
columns.put("studySeconds", "r.studySeconds");
columns.put("progressPercent", "r.progressPercent");
columns.put("completeStatus", "r.completeStatus");
return columns.get(prop);
}
private String formatStudySeconds(Integer seconds) {
int value = value(seconds);
int hour = value / 3600;
int minute = value % 3600 / 60;
int second = value % 60;
if (hour > 0) {
return hour + "小时" + minute + "分" + second + "秒";
}
if (minute > 0) {
return minute + "分" + second + "秒";
}
return second + "秒";
}
private int value(Integer value) {
return value == null ? 0 : value;
}
private int sanitizePosition(Integer positionSeconds, Integer defaultValue) {
if (positionSeconds == null || positionSeconds < 0) {
return value(defaultValue);
}
return positionSeconds;
}
}
@@ -0,0 +1,122 @@
package com.budwk.app.zhgh.learning.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.interceptor.annotation.PrevInsert;
import java.util.Date;
@Data
@Table
@EqualsAndHashCode(callSuper = true)
public class LearningCourse extends BaseModel {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("课程名称")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String courseName;
@Column
@Comment("课程类型")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String courseTypeId;
@Column
@Comment("授课讲师")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String lecturerName;
@Column
@Comment("讲师基本信息")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String lecturerInfo;
@Column
@Comment("课程简介")
@ColDefine(customType = "longtext")
private String courseIntro;
@Column
@Comment("适合人群")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String suitablePeople;
@Column
@Comment("学习目标")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String learningGoal;
@Column
@Comment("课程周期")
@ColDefine(type = ColType.VARCHAR, width = 50)
private String coursePeriod;
@Column
@Comment("开课类型")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String openType;
@Column
@Comment("开始时间")
@ColDefine(type = ColType.DATETIME)
private Date startTime;
@Column
@Comment("结束时间")
@ColDefine(type = ColType.DATETIME)
private Date endTime;
@Column
@Comment("课程状态")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String status;
@Column
@Comment("推荐标识")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String recommendFlags;
@Column
@Comment("课程标签")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String courseTags;
@Column
@Comment("学习对象")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String targetType;
@Column
@Comment("指定组织")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String targetOrgText;
@Column
@Comment("排序编码")
@ColDefine(type = ColType.INT)
private Integer sortNum;
@Column
@Comment("观看次数")
@ColDefine(type = ColType.INT)
private Integer viewCount;
@Column
@Comment("课程封面")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String cover;
}
@@ -0,0 +1,75 @@
package com.budwk.app.zhgh.learning.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.interceptor.annotation.PrevInsert;
@Data
@Table
@EqualsAndHashCode(callSuper = true)
public class LearningCourseOutline extends BaseModel {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("所属课程ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String courseId;
@Column
@Comment("所属课程名称")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String courseName;
@Column
@Comment("父节点ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String parentId;
@Column
@Comment("节点类型")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String nodeType;
@Column
@Comment("章/节标题")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String title;
@Column
@Comment("副标题")
@ColDefine(type = ColType.VARCHAR, width = 200)
private String subtitle;
@Column
@Comment("简介")
@ColDefine(customType = "longtext")
private String description;
@Column
@Comment("排序值")
@ColDefine(type = ColType.INT)
private Integer sortOrder;
@Column
@Comment("是否必学")
@ColDefine(type = ColType.BOOLEAN)
private Boolean required;
@Column
@Comment("状态")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String status;
}
@@ -0,0 +1,45 @@
package com.budwk.app.zhgh.learning.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.interceptor.annotation.PrevInsert;
@Data
@Table
@EqualsAndHashCode(callSuper = true)
public class LearningCourseType extends BaseModel {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("课程类型名称")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String typeName;
@Column
@Comment("排序编号")
@ColDefine(type = ColType.INT)
private Integer sortNum;
@Column
@Comment("是否启用")
@ColDefine(type = ColType.BOOLEAN)
private Boolean enabled;
@Column
@Comment("备注")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String remark;
}
@@ -0,0 +1,85 @@
package com.budwk.app.zhgh.learning.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.interceptor.annotation.PrevInsert;
@Data
@Table
@EqualsAndHashCode(callSuper = true)
public class LearningOutlineResource extends BaseModel {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("所属课程ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String courseId;
@Column
@Comment("所属章/节ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String outlineId;
@Column
@Comment("资料标题")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String resourceTitle;
@Column
@Comment("资料类型")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String resourceType;
@Column
@Comment("文件扩展名")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String fileExt;
@Column
@Comment("附件")
@ColDefine(customType = "longtext")
private String fileData;
@Column
@Comment("视频/音频时长")
@ColDefine(type = ColType.INT)
private Integer durationSeconds;
@Column
@Comment("排序值")
@ColDefine(type = ColType.INT)
private Integer sortOrder;
@Column
@Comment("是否必学")
@ColDefine(type = ColType.BOOLEAN)
private Boolean required;
@Column
@Comment("是否允许预览")
@ColDefine(type = ColType.BOOLEAN)
private Boolean allowPreview;
@Column
@Comment("是否允许下载")
@ColDefine(type = ColType.BOOLEAN)
private Boolean allowDownload;
@Column
@Comment("状态")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String status;
}
@@ -0,0 +1,124 @@
package com.budwk.app.zhgh.learning.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Index;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.entity.annotation.TableIndexes;
import org.nutz.dao.interceptor.annotation.PrevInsert;
import java.util.Date;
@Data
@Table("learning_study_record")
@TableIndexes({
@Index(name = "idx_learning_record_user_course_outline", fields = {"userId", "courseId", "outlineId"}, unique = true),
@Index(name = "idx_learning_record_union", fields = {"unionId"}, unique = false),
@Index(name = "idx_learning_record_course", fields = {"courseId"}, unique = false)
})
@EqualsAndHashCode(callSuper = true)
public class LearningStudyRecord extends BaseModel {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("学习人ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String userId;
@Column
@Comment("工号")
@ColDefine(type = ColType.VARCHAR, width = 120)
private String loginName;
@Column
@Comment("姓名")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String userName;
@Column
@Comment("所属分工会ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String unionId;
@Column
@Comment("所属分工会")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String unionName;
@Column
@Comment("课程ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String courseId;
@Column
@Comment("课程名称")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String courseName;
@Column
@Comment("章节ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String outlineId;
@Column
@Comment("章节名称")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String outlineName;
@Column
@Comment("第一次进入时间")
@ColDefine(type = ColType.DATETIME)
private Date firstStudyTime;
@Column
@Comment("最近学习时间")
@ColDefine(type = ColType.DATETIME)
private Date latestStudyTime;
@Column
@Comment("累计有效学习时长(秒)")
@ColDefine(type = ColType.INT)
private Integer studySeconds;
@Column
@Comment("最近播放位置(秒)")
@ColDefine(type = ColType.INT)
private Integer lastPositionSeconds;
@Column
@Comment("各资源最高已观看位置(JSON)")
@ColDefine(type = ColType.TEXT)
private String resourceMaxPositionData;
@Column
@Comment("要求学习时长(秒)")
@ColDefine(type = ColType.INT)
private Integer requiredSeconds;
@Column
@Comment("学习进度")
@ColDefine(type = ColType.INT)
private Integer progressPercent;
@Column
@Comment("完成状态")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String completeStatus;
@Column
@Comment("完成时间")
@ColDefine(type = ColType.DATETIME)
private Date completedAt;
}
@@ -0,0 +1,100 @@
package com.budwk.app.zhgh.learning.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.interceptor.annotation.PrevInsert;
@Data
@Table
@EqualsAndHashCode(callSuper = true)
public class LearningStudyRule extends BaseModel {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("所属课程ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String courseId;
@Column
@Comment("规则对象类型")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String targetType;
@Column
@Comment("规则对象ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String targetId;
@Column
@Comment("是否必学")
@ColDefine(type = ColType.BOOLEAN)
private Boolean required;
@Column
@Comment("学习方式")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String studyMode;
@Column
@Comment("完成规则")
@ColDefine(type = ColType.VARCHAR, width = 50)
private String completionRule;
@Column
@Comment("完成比例")
@ColDefine(type = ColType.INT)
private Integer completePercent;
@Column
@Comment("最少学习时长")
@ColDefine(type = ColType.INT)
private Integer minStudySeconds;
@Column
@Comment("解锁规则")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String unlockRule;
@Column
@Comment("是否允许跳过")
@ColDefine(type = ColType.BOOLEAN)
private Boolean allowSkip;
@Column
@Comment("是否允许拖动")
@ColDefine(type = ColType.BOOLEAN)
private Boolean allowDrag;
@Column
@Comment("暂停是否计时")
@ColDefine(type = ColType.BOOLEAN)
private Boolean pauseCountTime;
@Column
@Comment("页面隐藏是否计时")
@ColDefine(type = ColType.BOOLEAN)
private Boolean hiddenCountTime;
@Column
@Comment("长时间无操作是否计时")
@ColDefine(type = ColType.BOOLEAN)
private Boolean inactiveCountTime;
@Column
@Comment("状态")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String status;
}
@@ -0,0 +1,88 @@
package com.budwk.app.zhgh.learning.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Index;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.entity.annotation.TableIndexes;
import org.nutz.dao.interceptor.annotation.PrevInsert;
import java.util.Date;
@Data
@Table("learning_study_segment")
@TableIndexes({
@Index(name = "idx_learning_segment_record", fields = {"recordId"}, unique = false),
@Index(name = "idx_learning_segment_user_state", fields = {"userId", "state"}, unique = false)
})
@EqualsAndHashCode(callSuper = true)
public class LearningStudySegment extends BaseModel {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("学习记录ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String recordId;
@Column
@Comment("学习人ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String userId;
@Column
@Comment("课程ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String courseId;
@Column
@Comment("章节ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String outlineId;
@Column
@Comment("资源ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String resourceId;
@Column
@Comment("资源名称")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String resourceName;
@Column
@Comment("开始时间")
@ColDefine(type = ColType.DATETIME)
private Date startTime;
@Column
@Comment("结束时间")
@ColDefine(type = ColType.DATETIME)
private Date endTime;
@Column
@Comment("最近心跳时间")
@ColDefine(type = ColType.DATETIME)
private Date lastHeartbeatTime;
@Column
@Comment("本时段有效学习时长(秒)")
@ColDefine(type = ColType.INT)
private Integer activeSeconds;
@Column
@Comment("状态")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String state;
}
@@ -0,0 +1,22 @@
package com.budwk.app.zhgh.learning.param;
import com.budwk.app.base.param.PageForm;
import lombok.Data;
import lombok.EqualsAndHashCode;
@EqualsAndHashCode(callSuper = true)
@Data
public class LearningStatisticsPageForm extends PageForm {
private String courseId;
private String courseName;
private String unionId;
private String loginName;
private String userName;
private String completeStatus;
}
@@ -0,0 +1,42 @@
package com.budwk.app.zhgh.learning.service;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.zhgh.learning.models.LearningOutlineResource;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.regex.Pattern;
@IocBean
public class LearningFileAccessService {
@Inject
private Dao dao;
public boolean check(String fileId, boolean download, HttpServletResponse response) throws IOException {
if (StrUtil.isBlank(fileId)) {
response.sendError(400, "文件ID不能为空");
return false;
}
Pattern filePattern = Pattern.compile("(?<![a-zA-Z0-9])" + Pattern.quote(fileId) + "(?![a-zA-Z0-9])", Pattern.CASE_INSENSITIVE);
for (LearningOutlineResource resource : dao.query(LearningOutlineResource.class,
Cnd.where("fileData", "like", "%" + fileId + "%"))) {
if (!filePattern.matcher(resource.getFileData()).find()) {
continue;
}
StpUtil.checkPermission("learning.course.display");
boolean allowed = download ? Boolean.TRUE.equals(resource.getAllowDownload())
: !Boolean.FALSE.equals(resource.getAllowPreview());
if (!"enabled".equals(resource.getStatus()) || !allowed) {
response.sendError(403, download ? "该资料不允许下载" : "该资料不允许预览");
return false;
}
}
response.setHeader("Cache-Control", "no-store");
return true;
}
}
@@ -0,0 +1,175 @@
package com.budwk.app.zhgh.learning.service;
import com.budwk.app.zhgh.learning.models.LearningCourseOutline;
import com.budwk.app.zhgh.learning.models.LearningOutlineResource;
import com.budwk.app.zhgh.learning.models.LearningStudyRecord;
import com.budwk.app.zhgh.learning.models.LearningStudyRule;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json;
import org.nutz.lang.util.NutMap;
import java.util.Date;
import java.util.List;
@IocBean
public class LearningResourceProgressService {
@Inject
private Dao dao;
public LearningStudyRecord lock(String id) {
Sql sql = Sqls.create("SELECT * FROM learning_study_record WHERE id = @id FOR UPDATE");
sql.params().set("id", id);
sql.setEntity(dao.getEntity(LearningStudyRecord.class));
sql.setCallback(Sqls.callback.entity());
dao.execute(sql);
return sql.getObject(LearningStudyRecord.class);
}
public NutMap state(LearningStudyRecord record, LearningOutlineResource resource) {
NutMap totals = NutMap.NEW();
if (record != null) {
Sql sql = Sqls.create("""
SELECT COUNT(1) AS visits, COALESCE(SUM(activeSeconds), 0) AS studySeconds,
COALESCE(SUM(CASE WHEN state = 'finished' THEN 1 ELSE 0 END), 0) AS finishes
FROM learning_study_segment WHERE recordId = @recordId AND resourceId = @resourceId
""");
sql.params().set("recordId", record.getId()).set("resourceId", resource.getId());
sql.setCallback(Sqls.callback.map());
dao.execute(sql);
totals = sql.getObject(NutMap.class);
}
if (totals == null) totals = NutMap.NEW();
NutMap positions = positions(record);
String id = resource.getId();
int seconds = Math.max(0, totals.getInt("studySeconds", 0));
int required = Math.max(0, resource.getDurationSeconds() == null ? 0 : resource.getDurationSeconds());
boolean studied = totals.getInt("visits", 0) > 0;
boolean media = "video".equals(resource.getResourceType()) || "audio".equals(resource.getResourceType());
boolean completed = positions.getInt("completed:" + id, 0) == 1
|| (required > 0 && seconds >= required)
|| (!media && required == 0 && totals.getInt("finishes", 0) > 0);
int percent = completed ? 100 : required > 0 ? Math.min(99, (int) (seconds * 100.0 / required)) : 0;
int maxPosition = Math.max(0, positions.getInt(id, 0));
return NutMap.NEW().addv("resourceId", id).addv("studied", studied)
.addv("studySeconds", seconds).addv("requiredSeconds", required)
.addv("progressPercent", percent)
.addv("completeStatus", completed ? "completed" : studied ? "studying" : "not_started")
.addv("lastPositionSeconds", Math.max(0, positions.getInt("last:" + id, maxPosition)))
.addv("maxPositionSeconds", maxPosition);
}
// 保留原有资源最高位置键,附加独立续播位置和完成标记,不修改数据库结构。
public void savePosition(LearningStudyRecord record, String resourceId, Integer position, Integer maxPosition, boolean completed) {
NutMap positions = positions(record);
if (position != null && position >= 0) positions.put("last:" + resourceId, position);
if (maxPosition != null) positions.put(resourceId, Math.max(positions.getInt(resourceId, 0), maxPosition));
if (completed) positions.put("completed:" + resourceId, 1);
record.setResourceMaxPositionData(Json.toJson(positions));
}
public LearningStudyRule outlineRule(String outlineId) {
return dao.fetch(LearningStudyRule.class, Cnd.where("targetType", "=", "outline")
.and("targetId", "=", outlineId).and("status", "=", "enabled"));
}
public int requiredSeconds(String outlineId) {
LearningStudyRule rule = outlineRule(outlineId);
if (rule != null && "min_study_time".equals(rule.getCompletionRule())) {
return Math.max(0, rule.getMinStudySeconds() == null ? 0 : rule.getMinStudySeconds());
}
Sql sql = Sqls.create("SELECT COALESCE(SUM(durationSeconds), 0) FROM learning_outline_resource WHERE outlineId = @outlineId AND status = 'enabled' AND COALESCE(allowPreview, 1) = 1");
sql.params().set("outlineId", outlineId);
sql.setCallback(Sqls.callback.integer());
dao.execute(sql);
return Math.max(0, sql.getInt());
}
public NutMap outlineState(LearningStudyRecord record, LearningCourseOutline outline) {
List<LearningOutlineResource> resources = outline != null
&& "enabled".equals(outline.getStatus())
? dao.query(LearningOutlineResource.class, Cnd.where("outlineId", "=", outline.getId())
.and("courseId", "=", outline.getCourseId()).and("status", "=", "enabled")
.and(Cnd.exps("allowPreview", "=", true).or("allowPreview", "is", null))) : List.of();
int seconds = 0;
int totalPercent = 0;
boolean studied = false;
boolean completed = !resources.isEmpty();
for (LearningOutlineResource resource : resources) {
NutMap state = state(record, resource);
seconds += state.getInt("studySeconds", 0);
totalPercent += state.getInt("progressPercent", 0);
studied |= state.getBoolean("studied", false);
completed &= "completed".equals(state.getString("completeStatus"));
}
int percent = resources.isEmpty() ? 0 : totalPercent / resources.size();
LearningStudyRule rule = outline == null ? null : outlineRule(outline.getId());
int required = resources.isEmpty() ? 0 : requiredSeconds(outline.getId());
String mode = rule == null ? "all_required_resource" : rule.getCompletionRule();
if (!resources.isEmpty() && "resource_progress".equals(mode)) {
int threshold = rule.getCompletePercent() == null ? 100 : Math.max(1, Math.min(100, rule.getCompletePercent()));
completed = studied && totalPercent >= threshold * resources.size();
percent = (int) Math.min(100, totalPercent * 100L / (resources.size() * threshold));
} else if (!resources.isEmpty() && "min_study_time".equals(mode)) {
completed = studied && required > 0 && seconds >= required;
percent = required > 0 ? (int) Math.min(100, seconds * 100L / required) : 0;
}
return NutMap.NEW().addv("counted", !resources.isEmpty()).addv("studySeconds", seconds)
.addv("requiredSeconds", required).addv("progressPercent", completed ? 100 : Math.min(99, percent))
.addv("completeStatus", completed ? "completed" : studied ? "studying" : "not_started");
}
public NutMap courseState(String courseId, String userId) {
List<LearningStudyRecord> records = dao.query(LearningStudyRecord.class,
Cnd.where("courseId", "=", courseId).and("userId", "=", userId));
java.util.Map<String, LearningStudyRecord> recordMap = new java.util.HashMap<>();
for (LearningStudyRecord record : records) recordMap.put(record.getOutlineId(), record);
int count = 0, completed = 0, percent = 0, seconds = 0;
for (LearningCourseOutline outline : dao.query(LearningCourseOutline.class,
Cnd.where("courseId", "=", courseId).and("status", "=", "enabled"))) {
NutMap state = outlineState(recordMap.get(outline.getId()), outline);
if (!state.getBoolean("counted", false)) continue;
count++;
percent += state.getInt("progressPercent", 0);
seconds += state.getInt("studySeconds", 0);
if ("completed".equals(state.getString("completeStatus"))) completed++;
}
return NutMap.NEW().addv("outlineCount", count).addv("completedOutlineCount", completed)
.addv("progressPercent", count == 0 ? 0 : percent / count).addv("studySeconds", seconds);
}
public void refreshRecords(Cnd condition) {
for (LearningStudyRecord record : dao.query(LearningStudyRecord.class, condition)) {
refreshOutline(record, requiredSeconds(record.getOutlineId()));
}
}
public void refreshOutline(String outlineId) {
refreshRecords(Cnd.where("outlineId", "=", outlineId));
}
public void refreshOutline(LearningStudyRecord record, int requiredSeconds) {
NutMap state = outlineState(record, dao.fetch(LearningCourseOutline.class, record.getOutlineId()));
boolean completed = "completed".equals(state.getString("completeStatus"));
dao.update(LearningStudyRecord.class, Chain.make("studySeconds", state.getInt("studySeconds", 0))
.add("requiredSeconds", state.getInt("requiredSeconds", 0)).add("progressPercent", state.getInt("progressPercent", 0))
.add("completeStatus", state.getString("completeStatus"))
.add("completedAt", completed ? record.getCompletedAt() == null ? new Date() : record.getCompletedAt() : null),
Cnd.where("id", "=", record.getId()));
}
private NutMap positions(LearningStudyRecord record) {
if (record == null || record.getResourceMaxPositionData() == null) return NutMap.NEW();
try {
NutMap positions = Json.fromJson(NutMap.class, record.getResourceMaxPositionData());
return positions == null ? NutMap.NEW() : positions;
} catch (Exception ignored) {
return NutMap.NEW();
}
}
}
@@ -0,0 +1,212 @@
-- 学习教育平台初始化(MySQL),不包含课程、附件或学习记录。
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `learning_course` (
`id` VARCHAR(32) NOT NULL COMMENT 'ID',
`courseName` VARCHAR(100) DEFAULT NULL COMMENT '课程名称',
`courseTypeId` VARCHAR(32) DEFAULT NULL COMMENT '课程类型',
`lecturerName` VARCHAR(100) DEFAULT NULL COMMENT '授课讲师',
`lecturerInfo` VARCHAR(500) DEFAULT NULL COMMENT '讲师基本信息',
`courseIntro` LONGTEXT DEFAULT NULL COMMENT '课程简介',
`suitablePeople` VARCHAR(500) DEFAULT NULL COMMENT '适合人群',
`learningGoal` VARCHAR(500) DEFAULT NULL COMMENT '学习目标',
`coursePeriod` VARCHAR(50) DEFAULT NULL COMMENT '课程周期',
`openType` VARCHAR(20) DEFAULT NULL COMMENT '开课类型',
`startTime` DATETIME DEFAULT NULL COMMENT '开始时间',
`endTime` DATETIME DEFAULT NULL COMMENT '结束时间',
`status` VARCHAR(20) DEFAULT NULL COMMENT '课程状态',
`recommendFlags` VARCHAR(500) DEFAULT NULL COMMENT '推荐标识',
`courseTags` VARCHAR(500) DEFAULT NULL COMMENT '课程标签',
`targetType` VARCHAR(20) DEFAULT NULL COMMENT '学习对象',
`targetOrgText` VARCHAR(500) DEFAULT NULL COMMENT '指定组织',
`sortNum` INT DEFAULT NULL COMMENT '排序编码',
`viewCount` INT DEFAULT NULL COMMENT '观看次数',
`cover` VARCHAR(500) DEFAULT NULL COMMENT '课程封面',
`createdBy` VARCHAR(32) DEFAULT NULL,
`createdAt` BIGINT DEFAULT NULL,
`updatedBy` VARCHAR(32) DEFAULT NULL,
`updatedAt` BIGINT DEFAULT NULL,
`delFlag` BOOLEAN DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `learning_course_outline` (
`id` VARCHAR(32) NOT NULL COMMENT 'ID',
`courseId` VARCHAR(32) DEFAULT NULL COMMENT '所属课程ID',
`courseName` VARCHAR(100) DEFAULT NULL COMMENT '所属课程名称',
`parentId` VARCHAR(32) DEFAULT NULL COMMENT '父节点ID',
`nodeType` VARCHAR(20) DEFAULT NULL COMMENT '节点类型',
`title` VARCHAR(100) DEFAULT NULL COMMENT '章/节标题',
`subtitle` VARCHAR(200) DEFAULT NULL COMMENT '副标题',
`description` LONGTEXT DEFAULT NULL COMMENT '简介',
`sortOrder` INT DEFAULT NULL COMMENT '排序值',
`required` BOOLEAN DEFAULT NULL COMMENT '是否必学',
`status` VARCHAR(20) DEFAULT NULL COMMENT '状态',
`createdBy` VARCHAR(32) DEFAULT NULL,
`createdAt` BIGINT DEFAULT NULL,
`updatedBy` VARCHAR(32) DEFAULT NULL,
`updatedAt` BIGINT DEFAULT NULL,
`delFlag` BOOLEAN DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `learning_course_type` (
`id` VARCHAR(32) NOT NULL COMMENT 'ID',
`typeName` VARCHAR(100) DEFAULT NULL COMMENT '课程类型名称',
`sortNum` INT DEFAULT NULL COMMENT '排序编号',
`enabled` BOOLEAN DEFAULT NULL COMMENT '是否启用',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
`createdBy` VARCHAR(32) DEFAULT NULL,
`createdAt` BIGINT DEFAULT NULL,
`updatedBy` VARCHAR(32) DEFAULT NULL,
`updatedAt` BIGINT DEFAULT NULL,
`delFlag` BOOLEAN DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `learning_outline_resource` (
`id` VARCHAR(32) NOT NULL COMMENT 'ID',
`courseId` VARCHAR(32) DEFAULT NULL COMMENT '所属课程ID',
`outlineId` VARCHAR(32) DEFAULT NULL COMMENT '所属章/节ID',
`resourceTitle` VARCHAR(100) DEFAULT NULL COMMENT '资料标题',
`resourceType` VARCHAR(20) DEFAULT NULL COMMENT '资料类型',
`fileExt` VARCHAR(20) DEFAULT NULL COMMENT '文件扩展名',
`fileData` LONGTEXT DEFAULT NULL COMMENT '附件',
`durationSeconds` INT DEFAULT NULL COMMENT '视频/音频时长',
`sortOrder` INT DEFAULT NULL COMMENT '排序值',
`required` BOOLEAN DEFAULT NULL COMMENT '是否必学',
`allowPreview` BOOLEAN DEFAULT NULL COMMENT '是否允许预览',
`allowDownload` BOOLEAN DEFAULT NULL COMMENT '是否允许下载',
`status` VARCHAR(20) DEFAULT NULL COMMENT '状态',
`createdBy` VARCHAR(32) DEFAULT NULL,
`createdAt` BIGINT DEFAULT NULL,
`updatedBy` VARCHAR(32) DEFAULT NULL,
`updatedAt` BIGINT DEFAULT NULL,
`delFlag` BOOLEAN DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `learning_study_record` (
`id` VARCHAR(32) NOT NULL COMMENT 'ID',
`userId` VARCHAR(32) DEFAULT NULL COMMENT '学习人ID',
`loginName` VARCHAR(120) DEFAULT NULL COMMENT '工号',
`userName` VARCHAR(100) DEFAULT NULL COMMENT '姓名',
`unionId` VARCHAR(32) DEFAULT NULL COMMENT '所属分工会ID',
`unionName` VARCHAR(100) DEFAULT NULL COMMENT '所属分工会',
`courseId` VARCHAR(32) DEFAULT NULL COMMENT '课程ID',
`courseName` VARCHAR(100) DEFAULT NULL COMMENT '课程名称',
`outlineId` VARCHAR(32) DEFAULT NULL COMMENT '章节ID',
`outlineName` VARCHAR(100) DEFAULT NULL COMMENT '章节名称',
`firstStudyTime` DATETIME DEFAULT NULL COMMENT '第一次进入时间',
`latestStudyTime` DATETIME DEFAULT NULL COMMENT '最近学习时间',
`studySeconds` INT DEFAULT NULL COMMENT '累计有效学习时长(秒)',
`lastPositionSeconds` INT DEFAULT NULL COMMENT '最近播放位置(秒)',
`resourceMaxPositionData` TEXT DEFAULT NULL COMMENT '各资源最高已观看位置(JSON)',
`requiredSeconds` INT DEFAULT NULL COMMENT '要求学习时长(秒)',
`progressPercent` INT DEFAULT NULL COMMENT '学习进度',
`completeStatus` VARCHAR(20) DEFAULT NULL COMMENT '完成状态',
`completedAt` DATETIME DEFAULT NULL COMMENT '完成时间',
`createdBy` VARCHAR(32) DEFAULT NULL,
`createdAt` BIGINT DEFAULT NULL,
`updatedBy` VARCHAR(32) DEFAULT NULL,
`updatedAt` BIGINT DEFAULT NULL,
`delFlag` BOOLEAN DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_learning_record_user_course_outline` (`userId`, `courseId`, `outlineId`),
KEY `idx_learning_record_union` (`unionId`),
KEY `idx_learning_record_course` (`courseId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `learning_study_rule` (
`id` VARCHAR(32) NOT NULL COMMENT 'ID',
`courseId` VARCHAR(32) DEFAULT NULL COMMENT '所属课程ID',
`targetType` VARCHAR(20) DEFAULT NULL COMMENT '规则对象类型',
`targetId` VARCHAR(32) DEFAULT NULL COMMENT '规则对象ID',
`required` BOOLEAN DEFAULT NULL COMMENT '是否必学',
`studyMode` VARCHAR(20) DEFAULT NULL COMMENT '学习方式',
`completionRule` VARCHAR(50) DEFAULT NULL COMMENT '完成规则',
`completePercent` INT DEFAULT NULL COMMENT '完成比例',
`minStudySeconds` INT DEFAULT NULL COMMENT '最少学习时长',
`unlockRule` VARCHAR(20) DEFAULT NULL COMMENT '解锁规则',
`allowSkip` BOOLEAN DEFAULT NULL COMMENT '是否允许跳过',
`allowDrag` BOOLEAN DEFAULT NULL COMMENT '是否允许拖动',
`pauseCountTime` BOOLEAN DEFAULT NULL COMMENT '暂停是否计时',
`hiddenCountTime` BOOLEAN DEFAULT NULL COMMENT '页面隐藏是否计时',
`inactiveCountTime` BOOLEAN DEFAULT NULL COMMENT '长时间无操作是否计时',
`status` VARCHAR(20) DEFAULT NULL COMMENT '状态',
`createdBy` VARCHAR(32) DEFAULT NULL,
`createdAt` BIGINT DEFAULT NULL,
`updatedBy` VARCHAR(32) DEFAULT NULL,
`updatedAt` BIGINT DEFAULT NULL,
`delFlag` BOOLEAN DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `learning_study_segment` (
`id` VARCHAR(32) NOT NULL COMMENT 'ID',
`recordId` VARCHAR(32) DEFAULT NULL COMMENT '学习记录ID',
`userId` VARCHAR(32) DEFAULT NULL COMMENT '学习人ID',
`courseId` VARCHAR(32) DEFAULT NULL COMMENT '课程ID',
`outlineId` VARCHAR(32) DEFAULT NULL COMMENT '章节ID',
`resourceId` VARCHAR(32) DEFAULT NULL COMMENT '资源ID',
`resourceName` VARCHAR(100) DEFAULT NULL COMMENT '资源名称',
`startTime` DATETIME DEFAULT NULL COMMENT '开始时间',
`endTime` DATETIME DEFAULT NULL COMMENT '结束时间',
`lastHeartbeatTime` DATETIME DEFAULT NULL COMMENT '最近心跳时间',
`activeSeconds` INT DEFAULT NULL COMMENT '本时段有效学习时长(秒)',
`state` VARCHAR(20) DEFAULT NULL COMMENT '状态',
`createdBy` VARCHAR(32) DEFAULT NULL,
`createdAt` BIGINT DEFAULT NULL,
`updatedBy` VARCHAR(32) DEFAULT NULL,
`updatedAt` BIGINT DEFAULT NULL,
`delFlag` BOOLEAN DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_learning_segment_record` (`recordId`),
KEY `idx_learning_segment_user_state` (`userId`, `state`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
START TRANSACTION;
INSERT INTO sys_module (id, platform, name, icon, sortNum, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('a03149321797435d9d54f9ba03cb275b', 'H5', '培训教育', '/platform/sys/file/download?id=tveo21cc2ki3toav43rpvtankg', 31, '17a7f8ad3ee947b4a26175049a9c253d', 1757990226056, '17a7f8ad3ee947b4a26175049a9c253d', 1764214972112, false);
INSERT INTO sys_module (id, platform, name, icon, sortNum, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('b1ae9f3fcb164be9a8d857f3acf3c3bd', 'PC', '培训教育', '/platform/sys/file/download?id=p7if6ffrrshi7ppttcaajkvdbt', 9, '17a7f8ad3ee947b4a26175049a9c253d', 1756798359497, '17a7f8ad3ee947b4a26175049a9c253d', 1770772532570, false);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('a0f47f6a6d734c8b9c6ce5c0dfd80001', '', '0167', '学习教育平台', 'Learning', 'menu', '', '', 'ti-book', true, false, 'learning', null, 611, false, '1af78f95c16c4504a8bd714d9e76def3', 1777739844000, '17a7f8ad3ee947b4a26175049a9c253d', 1780199079929, false, null, 'PC', 'b1ae9f3fcb164be9a8d857f3acf3c3bd', false);
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('6d603b652abee584ba6274dc1fcee3bb', 'a0f47f6a6d734c8b9c6ce5c0dfd80001');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('fd753e8a8c5a404e8e00ef8e0b0c69da', 'a0f47f6a6d734c8b9c6ce5c0dfd80001');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'a0f47f6a6d734c8b9c6ce5c0dfd80001');
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('3e9a7611fa96490d8adc393387b1d65a', '', '0170', '我的学习', null, 'menu', '/platform/learning/my/record/h5', null, null, true, false, 'learning.my.record.h5', null, 1005, false, '1af78f95c16c4504a8bd714d9e76def3', 1779111719650, '1af78f95c16c4504a8bd714d9e76def3', 1780209965809, false, null, 'H5', 'a03149321797435d9d54f9ba03cb275b', false);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('82946217f37e466d87f123c6987991e1', '', '0170', '学习教育', '', 'menu', '/platform/learning/course/h5', null, null, true, false, 'learning.course.h5', null, 1004, false, '1af78f95c16c4504a8bd714d9e76def3', 1779011454247, '1af78f95c16c4504a8bd714d9e76def3', 1780209948730, false, null, 'H5', 'a03149321797435d9d54f9ba03cb275b', false);
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('6d603b652abee584ba6274dc1fcee3bb', '82946217f37e466d87f123c6987991e1');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('6d603b652abee584ba6274dc1fcee3bb', '3e9a7611fa96490d8adc393387b1d65a');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', '82946217f37e466d87f123c6987991e1');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', '3e9a7611fa96490d8adc393387b1d65a');
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('a0f47f6a6d734c8b9c6ce5c0dfd80002', 'a0f47f6a6d734c8b9c6ce5c0dfd80001', '01670001', '课程类型设置', 'Course Type', 'menu', '/platform/learning/course/type', 'data-pjax', '', true, false, 'learning.course.type', null, 612, false, '', 1777739844000, '', 1777739844000, false, null, 'PC', null, false);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('a0f47f6a6d734c8b9c6ce5c0dfd80003', 'a0f47f6a6d734c8b9c6ce5c0dfd80001', '01670002', '课程管理', 'Course Manage', 'menu', '/platform/learning/course/manage', 'data-pjax', '', true, false, 'learning.course.manage', null, 613, false, '', 1777739844000, '', 1777739844000, false, null, 'PC', null, false);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('a0f47f6a6d734c8b9c6ce5c0dfd80004', 'a0f47f6a6d734c8b9c6ce5c0dfd80001', '01670003', '章节内容管理', 'Chapter Content', 'menu', '/platform/learning/chapter/content', 'data-pjax', '', true, false, 'learning.chapter.content', null, 614, false, '', 1777739844000, '', 1777739844000, false, null, 'PC', null, false);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('a0f47f6a6d734c8b9c6ce5c0dfd80005', 'a0f47f6a6d734c8b9c6ce5c0dfd80001', '01670004', '课程展示', 'Course Display', 'menu', '/platform/learning/course/display', 'data-pjax', '', true, false, 'learning.course.display', null, 615, false, '', 1777739844000, '', 1777739844000, false, null, 'PC', null, false);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('a0f47f6a6d734c8b9c6ce5c0dfd80007', 'a0f47f6a6d734c8b9c6ce5c0dfd80001', '01670006', '学习统计', 'Learning Statistics', 'menu', '/platform/learning/statistics', 'data-pjax', '', true, false, 'learning.statistics', null, 617, false, '', 1777739844000, '', 1777739844000, false, null, 'PC', null, false);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('a0f47f6a6d734c8b9c6ce5c0dfd80008', 'a0f47f6a6d734c8b9c6ce5c0dfd80001', '01670007', '我的学习记录', 'My Learning Record', 'menu', '/platform/learning/my/record', 'data-pjax', '', true, false, 'learning.my.record', null, 618, false, '', 1777792288000, '', 1777792288000, false, null, 'PC', null, false);
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('6d603b652abee584ba6274dc1fcee3bb', 'a0f47f6a6d734c8b9c6ce5c0dfd80005');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('6d603b652abee584ba6274dc1fcee3bb', 'a0f47f6a6d734c8b9c6ce5c0dfd80008');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('fd753e8a8c5a404e8e00ef8e0b0c69da', 'a0f47f6a6d734c8b9c6ce5c0dfd80008');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'a0f47f6a6d734c8b9c6ce5c0dfd80002');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'a0f47f6a6d734c8b9c6ce5c0dfd80003');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'a0f47f6a6d734c8b9c6ce5c0dfd80004');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'a0f47f6a6d734c8b9c6ce5c0dfd80005');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'a0f47f6a6d734c8b9c6ce5c0dfd80007');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'a0f47f6a6d734c8b9c6ce5c0dfd80008');
INSERT INTO sys_dict (id, parentId, path, name, code, disabled, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('077f4c7f51ec41cc9e47559a6b8bf6d5', '', '0038', '学习教育', 'LEARNING', false, 991, true, '17a7f8ad3ee947b4a26175049a9c253d', 1777768520956, '17a7f8ad3ee947b4a26175049a9c253d', 1777768520956, false);
INSERT INTO sys_dict (id, parentId, path, name, code, disabled, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('5477dc43ee1044108aae0e6241d3a87a', '6fa4bb9701e34511ab86ae3cbc39a053', '003800020004', '专题课程', '专题课程', false, 1002, false, '17a7f8ad3ee947b4a26175049a9c253d', 1777769110156, '17a7f8ad3ee947b4a26175049a9c253d', 1777769110156, false);
INSERT INTO sys_dict (id, parentId, path, name, code, disabled, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('6fa4bb9701e34511ab86ae3cbc39a053', '077f4c7f51ec41cc9e47559a6b8bf6d5', '00380002', '推荐标识', 'Recommended Mark', false, 993, true, '17a7f8ad3ee947b4a26175049a9c253d', 1777768764849, '17a7f8ad3ee947b4a26175049a9c253d', 1777768764850, false);
INSERT INTO sys_dict (id, parentId, path, name, code, disabled, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('7863497c73d14e5bb14245d4e7f60089', '6fa4bb9701e34511ab86ae3cbc39a053', '003800020002', '新开课程', '新开课程', false, 1000, false, '17a7f8ad3ee947b4a26175049a9c253d', 1777768964121, '17a7f8ad3ee947b4a26175049a9c253d', 1777769085321, false);
INSERT INTO sys_dict (id, parentId, path, name, code, disabled, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('d8afd3f8e1fe4c25a8089a810707b433', '6fa4bb9701e34511ab86ae3cbc39a053', '003800020001', '热门课程', '热门课程', false, 999, false, '17a7f8ad3ee947b4a26175049a9c253d', 1777768946919, '17a7f8ad3ee947b4a26175049a9c253d', 1777769069517, false);
INSERT INTO sys_dict (id, parentId, path, name, code, disabled, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('e1dbf9e5dd8a45ea89cec68eb36d901f', '6fa4bb9701e34511ab86ae3cbc39a053', '003800020003', '推荐课程', '推荐课程', false, 1001, false, '17a7f8ad3ee947b4a26175049a9c253d', 1777768990516, '17a7f8ad3ee947b4a26175049a9c253d', 1777769094915, false);
COMMIT;
+1 -1
View File
@@ -32,7 +32,7 @@ var ioc={
// 单个文件最大尺寸(大约的值,单位为字节,即 209715200 为 200M)
maxFileSize : 209715200,
// 正则表达式匹配可以支持的文件名
nameFilter : '^(.+[.])(gif||svg|jpg|png|jpeg|doc|docx|xls|xlsx|rar|zip|7z|txt|pdf|pptx|ppt|mp4)$'
nameFilter : '^(.+[.])(gif||svg|jpg|png|jpeg|doc|docx|xls|xlsx|rar|zip|7z|txt|pdf|pptx|ppt|mp4|(?i:mp3|wav|aac))$'
}
},
fileUpload : {
@@ -1,7 +1,7 @@
<template>
<div class="search">
<slot></slot>
<div class="search-query">
<div v-if="isSearchButton" class="search-query">
<el-button type="primary" icon="el-icon-search" @click="$emit('search', null)">搜索</el-button>
</div>
</div>
@@ -9,7 +9,13 @@
<script>
module.exports = {
name: "pageFormSearch"
name: "pageFormSearch",
props: {
isSearchButton: {
type: Boolean,
default: true
}
}
}
</script>
@@ -356,7 +356,7 @@
if (!state.activeModuleId && getters.pcModules) {
commit("setActiveMenuModuleId", getters.pcModules[0].id)
}
pjaxReplace(pathname === "/platform/home" ? pathname : pathname)
pjaxReplace(pathname + search)
return
}
//拷贝一份
@@ -0,0 +1,9 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<el-card shadow="never">学习活动管理模块待完善</el-card>
</div>
<!--#
}
#-->
@@ -0,0 +1,889 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
.learning-outline-layout {
height: calc(100vh - 235px);
}
.learning-outline-tree {
height: 100%;
overflow: auto;
}
.outline-context-menu {
position: fixed;
z-index: 3000;
padding: 5px 0;
margin: 0;
list-style: none;
background: #fff;
border: 1px solid #dcdfe6;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, .1);
}
.outline-context-menu li {
min-width: 120px;
padding: 7px 18px;
cursor: pointer;
}
.outline-context-menu li:hover {
background: #f2f6fc;
}
</style>
<div id="app" v-cloak>
<el-card shadow="never">
<search :is-search-button="false">
<search-item label="课程名称">
<el-select
v-model="currentCourseId"
clearable
filterable
remote
:remote-method="courseNameRemoteSearch"
placeholder="请选择或搜索课程名称"
style="width: 100%"
@change="courseChange"
@clear="courseClear">
<el-option v-for="item in courseOptions" :key="item.id" :label="item.courseName" :value="item.id"></el-option>
</el-select>
</search-item>
<div class="search-query">
<el-button size="medium" type="primary" @click="loadCourses">搜索</el-button>
<el-button size="medium" @click="resetCourseQuery">重置</el-button>
</div>
</search>
</el-card>
<el-row :gutter="10" class="mt10 learning-outline-layout">
<el-col :span="6" style="height: 100%">
<el-card shadow="never" style="height: 100%">
<table-tool label="课程大纲">
<el-button size="mini" type="primary" icon="el-icon-plus" @click="openNodeForm('chapter')">新增章</el-button>
</table-tool>
<div class="learning-outline-tree">
<el-tree
ref="outlineTree"
:data="outlineTreeData"
node-key="id"
default-expand-all
highlight-current
:expand-on-click-node="false"
:props="{children:'children', label:'title'}"
@node-click="nodeClick"
@node-contextmenu="openContextMenu">
<span slot-scope="{ node, data }">
<i :class="data.nodeType === 'course' ? 'el-icon-collection' : (data.nodeType === 'chapter' ? 'el-icon-folder' : 'el-icon-document')"></i>
<span>{{data.title}}</span>
<el-tag v-if="data.status === 'disabled'" size="mini" type="info" style="margin-left: 6px">禁用</el-tag>
<el-tag v-if="data.required" size="mini" type="warning" style="margin-left: 6px">必学</el-tag>
</span>
</el-tree>
<el-empty v-if="treeData.length === 0" description="暂无课程大纲"></el-empty>
</div>
</el-card>
</el-col>
<el-col :span="18" style="height: 100%">
<el-card shadow="never" style="height: 100%; overflow: auto">
<el-empty v-if="!selectedNode.id" description="请选择左侧章/节节点"></el-empty>
<el-tabs v-else v-model="activeTab">
<el-tab-pane label="基础信息" name="basic" style="padding-top: 12px">
<el-form :model="nodeForm" ref="nodeForm" label-width="110px" :rules="nodeRules">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="节点类型">
<el-tag :type="nodeForm.nodeType === 'chapter' ? 'primary' : 'success'">{{nodeForm.nodeType === 'chapter' ? '章' : '节'}}</el-tag>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序值" prop="sortOrder">
<el-input-number v-model="nodeForm.sortOrder" :controls="false" :min="1" :precision="0" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="标题" prop="title">
<el-input v-model="nodeForm.title" maxlength="100" placeholder="请输入章/节标题"></el-input>
</el-form-item>
<el-form-item label="副标题" prop="subtitle">
<el-input v-model="nodeForm.subtitle" maxlength="200" placeholder="请输入副标题"></el-input>
</el-form-item>
<el-form-item label="简介" prop="description">
<el-input v-model="nodeForm.description" type="textarea" :rows="5" maxlength="2000" show-word-limit placeholder="请输入章/节简介"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveNodeBasic">保存基础信息</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="学习资料" name="resource">
<table-tool label="学习资料">
<el-button size="mini" type="primary" icon="el-icon-plus" @click="openResourceForm()">新增资料</el-button>
</table-tool>
<el-table :data="resourceData" border @sort-change="resourcePageOrder">
<el-table-column label="序号" type="index" width="70" :index="resourceIndex"></el-table-column>
<el-table-column label="资料标题" prop="resourceTitle" sortable="custom" show-overflow-tooltip></el-table-column>
<el-table-column label="资料类型" prop="resourceType" sortable="custom" width="110">
<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}">{{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">
<template slot-scope="{row}">
<el-tag :type="row.required ? 'warning' : 'info'" size="mini">{{row.required ? '是' : '否'}}</el-tag>
</template>
</el-table-column>
<el-table-column label="预览" prop="allowPreview" sortable="custom" width="90">
<template slot-scope="{row}">{{row.allowPreview ? '允许' : '不允许'}}</template>
</el-table-column>
<el-table-column label="下载" prop="allowDownload" sortable="custom" width="90">
<template slot-scope="{row}">{{row.allowDownload ? '允许' : '不允许'}}</template>
</el-table-column>
<el-table-column label="状态" prop="status" sortable="custom" width="90">
<template slot-scope="{row}">
<el-tag :type="row.status === 'enabled' ? 'success' : 'info'" size="mini">{{row.status === 'enabled' ? '启用' : '禁用'}}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150">
<template slot-scope="{row}">
<el-button size="mini" type="primary" @click="openResourceForm(row)">编辑</el-button>
<el-button size="mini" type="danger" @click="deleteResource(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="mt20"
@size-change="resourceSizeChange"
@current-change="resourceNumberChange"
:current-page="resourcePageForm.pageNumber"
:page-sizes="[5,10,20,30,50]"
:page-size="resourcePageForm.pageSize"
layout="total, sizes, prev, pager, next"
:total="resourcePageForm.totalCount">
</el-pagination>
</el-tab-pane>
<el-tab-pane label="完成规则" name="rule" style="padding-top: 12px">
<el-form :model="ruleForm" label-width="150px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="是否必学">
<el-switch v-model="ruleForm.required"></el-switch>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="学习方式">
<el-select v-model="ruleForm.studyMode" style="width: 100%">
<el-option v-for="item in studyModeOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="完成规则">
<el-select v-model="ruleForm.completionRule" style="width: 100%">
<el-option v-for="item in completionRuleOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="完成比例">
<el-input-number v-model="ruleForm.completePercent" :min="0" :max="100" :precision="0" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="最少学习时长(分钟)">
<el-input-number v-model="ruleForm.minStudyMinutes" :min="0" :precision="0" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<!--
<el-col :span="12">
<el-form-item label="解锁规则">
<el-select v-model="ruleForm.unlockRule" style="width: 100%">
<el-option v-for="item in unlockRuleOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</el-form-item>
</el-col>
-->
</el-row>
<!--
<el-row :gutter="20">
<el-col :span="8"><el-form-item label="允许跳过"><el-switch v-model="ruleForm.allowSkip"></el-switch></el-form-item></el-col>
<el-col :span="8"><el-form-item label="允许拖动"><el-switch v-model="ruleForm.allowDrag"></el-switch></el-form-item></el-col>
<el-col :span="8"><el-form-item label="暂停计时"><el-switch v-model="ruleForm.pauseCountTime"></el-switch></el-form-item></el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8"><el-form-item label="隐藏计时"><el-switch v-model="ruleForm.hiddenCountTime"></el-switch></el-form-item></el-col>
<el-col :span="8"><el-form-item label="无操作计时"><el-switch v-model="ruleForm.inactiveCountTime"></el-switch></el-form-item></el-col>
</el-row>
-->
<el-form-item>
<el-button type="primary" @click="saveRule">保存完成规则</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="状态设置" name="status" style="padding-top: 12px">
<el-form :model="nodeForm" label-width="110px">
<el-form-item label="节点状态">
<el-radio-group v-model="nodeForm.status">
<el-radio label="enabled">启用</el-radio>
<el-radio label="disabled">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="是否必学">
<el-switch v-model="nodeForm.required"></el-switch>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveNodeBasic">保存状态</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</el-card>
</el-col>
</el-row>
<ul v-show="contextVisible" class="outline-context-menu" :style="{left: contextLeft + 'px', top: contextTop + 'px'}">
<li @click="openNodeForm('chapter')">新增章</li>
<li v-if="contextNode.nodeType === 'chapter'" @click="openNodeForm('section', contextNode)">新增节</li>
<li v-if="contextNode.nodeType !== 'course'" @click="openNodeForm(contextNode.nodeType, contextNode)">编辑</li>
<li v-if="contextNode.nodeType !== 'course'" @click="deleteNode(contextNode)">删除</li>
<li v-if="contextNode.nodeType !== 'course'" @click="toggleNode(contextNode)">{{contextNode.status === 'enabled' ? '禁用' : '启用'}}</li>
<li v-if="contextNode.nodeType !== 'course'" @click="moveNode(contextNode, 'up')">上移</li>
<li v-if="contextNode.nodeType !== 'course'" @click="moveNode(contextNode, 'down')">下移</li>
<li v-if="contextNode.nodeType !== 'course'" @click="openResourceForm(null, contextNode)">上传资料</li>
<li v-if="contextNode.nodeType !== 'course'" @click="setRequired(contextNode)">设置必学</li>
</ul>
<el-dialog :title="nodeDialogTitle" :visible.sync="nodeDialogVisible" :close-on-click-modal="false" width="45%">
<el-form :model="nodeDialogForm" ref="nodeDialogForm" label-width="100px" :rules="nodeRules">
<el-form-item label="节点类型">
<el-tag>{{nodeDialogForm.nodeType === 'chapter' ? '章' : '节'}}</el-tag>
</el-form-item>
<el-form-item label="上级节点">
<el-input v-model="nodeDialogForm.parentName" disabled placeholder="上级节点"></el-input>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="nodeDialogForm.title" maxlength="100" placeholder="请输入标题"></el-input>
</el-form-item>
<el-form-item label="副标题">
<el-input v-model="nodeDialogForm.subtitle" maxlength="200" placeholder="请输入副标题"></el-input>
</el-form-item>
<el-form-item label="简介">
<el-input v-model="nodeDialogForm.description" type="textarea" :rows="4" maxlength="2000" show-word-limit></el-input>
</el-form-item>
<el-form-item label="排序值">
<el-input-number v-model="nodeDialogForm.sortOrder" :min="1" :precision="0" style="width: 100%"></el-input-number>
</el-form-item>
<el-form-item label="是否必学">
<el-switch v-model="nodeDialogForm.required"></el-switch>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="nodeDialogForm.status">
<el-radio label="enabled">启用</el-radio>
<el-radio label="disabled">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="nodeDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitNodeDialog">确定</el-button>
</span>
</el-dialog>
<el-dialog :title="resourceForm.id ? '编辑学习资料' : '新增学习资料'" :visible.sync="resourceDialogVisible" :close-on-click-modal="false" width="55%">
<el-form :model="resourceForm" ref="resourceForm" label-width="120px" :rules="resourceRules">
<el-form-item label="所属章节">
<el-input v-model="resourceForm.outlineName" disabled placeholder="所属章节"></el-input>
</el-form-item>
<el-form-item label="资料标题" prop="resourceTitle">
<el-input v-model="resourceForm.resourceTitle" maxlength="100" placeholder="请输入资料标题"></el-input>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="资料类型" prop="resourceType">
<el-select v-model="resourceForm.resourceType" placeholder="请选择资料类型" style="width: 100%" @change="resourceTypeChange">
<el-option v-for="item in resourceTypeOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序值" prop="sortOrder">
<el-input-number v-model="resourceForm.sortOrder" :min="1" :precision="0" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<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">
<el-form-item label="状态">
<el-radio-group v-model="resourceForm.status">
<el-radio label="enabled">启用</el-radio>
<el-radio label="disabled">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8"><el-form-item label="是否必学"><el-switch v-model="resourceForm.required" @change="resourceRequiredChange"></el-switch></el-form-item></el-col>
<el-col :span="8"><el-form-item label="允许预览"><el-switch :value="resourceForm.allowPreview" @input="resourcePreviewChange"></el-switch></el-form-item></el-col>
<el-col :span="8"><el-form-item label="允许下载"><el-switch v-model="resourceForm.allowDownload"></el-switch></el-form-item></el-col>
</el-row>
<el-form-item label="附件" prop="fileData">
<file-upload
:value.sync="resourceForm.fileData"
:upload_number="1"
upload_mode="drag"
upload_result_category="array"
complete_result
:accept="fileAccept">
</file-upload>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="resourceDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitResource">确定</el-button>
</span>
</el-dialog>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
mixins: [initTableMixins],
data() {
return {
courseQuery: {},
courseOptions: [],
courseTypeOptions: [],
currentCourseId: "",
treeData: [],
selectedNode: {},
activeTab: "basic",
nodeForm: {},
nodeDialogVisible: false,
nodeDialogTitle: "",
nodeDialogForm: {},
nodeRules: {
title: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
sortOrder: [{ required: true, message: "必填", trigger: ["blur", "change"] }]
},
contextVisible: false,
contextLeft: 0,
contextTop: 0,
contextNode: {},
resourceData: [],
resourcePageForm: {
pageNumber: 1,
pageSize: 10,
totalCount: 0,
pageOrderName: "",
pageOrderBy: ""
},
resourceDialogVisible: false,
resourceForm: {},
resourceRules: {
resourceTitle: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
resourceType: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
sortOrder: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
fileData: [{ required: true, message: "请上传附件", trigger: ["blur", "change"] }]
},
fileAccept: "",
ruleForm: {},
statusOptions: [
{ name: "草稿", code: "draft" },
{ name: "待发布", code: "pending" },
{ name: "已发布", code: "published" },
{ name: "已下架", code: "offline" }
],
resourceTypeOptions: [
{ name: "视频", code: "video", accept: ".mp4,.mov" },
{ name: "音频", code: "audio", accept: ".mp3,.wav,.aac" },
{ name: "PDF", code: "pdf", accept: ".pdf" },
{ name: "PPT课件", code: "ppt", accept: ".ppt,.pptx" },
{ name: "Word文档", code: "word", accept: ".doc,.docx" },
{ name: "图片", code: "image", accept: ".jpg,.jpeg,.png" },
{ name: "其他", code: "other", accept: "" }
],
studyModeOptions: [
{ name: "视频", code: "video" },
{ name: "音频", code: "audio" },
{ name: "文档", code: "document" },
{ name: "图片", code: "image" },
{ name: "混合资料", code: "mixed" }
],
completionRuleOptions: [
{ name: "完成全部学习资料", code: "all_required_resource" },
{ name: "按资料学习进度", code: "resource_progress" },
{ name: "达到最少学习时长", code: "min_study_time" },
{ name: "管理员手动确认", code: "manual" }
],
unlockRuleOptions: [
{ name: "自由学习", code: "free" },
{ name: "顺序解锁", code: "sequential" }
]
}
},
computed: {
currentCourse() {
return this.courseOptions.find(v => v.id === this.currentCourseId)
},
outlineTreeData() {
if (!this.currentCourseId || !this.currentCourse) {
return []
}
return [{
id: "course-" + this.currentCourseId,
courseId: this.currentCourseId,
title: this.currentCourse.courseName,
nodeType: "course",
children: this.treeData
}]
}
},
methods: {
resetCourseQuery() {
this.courseQuery = {}
this.currentCourseId = ""
this.loadCourses()
},
getQueryParam(name) {
return new URLSearchParams(window.location.search).get(name) || ""
},
async loadCourseTypes() {
const resp = await this.$axios.post(loc() + "/courseTypes")
if (resp.code === 0) {
this.courseTypeOptions = resp.data
}
},
async loadCourses(keepCurrent = false) {
const resp = await this.$axios.post(loc() + "/courses", this.courseQuery)
if (resp.code === 0) {
this.courseOptions = resp.data
if (!keepCurrent && (!this.currentCourseId || !this.courseOptions.some(v => v.id === this.currentCourseId))) {
this.currentCourseId = this.courseOptions.length ? this.courseOptions[0].id : ""
}
await this.loadTree()
}
},
courseNameRemoteSearch(query) {
this.courseQuery.courseName = query
this.loadCourses(true)
},
courseChange() {
this.selectedNode = {}
this.loadTree()
},
courseClear() {
this.currentCourseId = ""
this.selectedNode = {}
this.treeData = []
},
async loadTree() {
if (!this.currentCourseId) {
this.treeData = []
this.selectedNode = {}
return
}
const resp = await this.$axios.post(loc() + "/tree", { courseId: this.currentCourseId })
if (resp.code === 0) {
this.treeData = resp.data
let nodeToSelect = null
if (this.selectedNode.id) {
nodeToSelect = this.findNode(this.treeData, this.selectedNode.id)
}
if (!nodeToSelect) {
nodeToSelect = this.findFirstOutlineNode(this.treeData)
}
if (nodeToSelect) {
this.nodeClick(nodeToSelect)
this.$nextTick(() => {
if (this.$refs.outlineTree) {
this.$refs.outlineTree.setCurrentKey(nodeToSelect.id)
}
})
} else {
this.selectedNode = {}
this.nodeForm = {}
this.resourceData = []
}
}
},
findFirstOutlineNode(nodes) {
if (!nodes || !nodes.length) return null
for (const node of nodes) {
if (node.nodeType !== "course") return node
const child = this.findFirstOutlineNode(node.children || [])
if (child) return child
}
return null
},
findNode(nodes, id) {
for (const node of nodes) {
if (node.id === id) return node
const child = this.findNode(node.children || [], id)
if (child) return child
}
return null
},
getParentName(type, node, course, isEdit) {
if (type === "chapter") {
return course ? course.courseName : ""
}
if (!isEdit) {
return node ? node.title : ""
}
const parent = node && node.parentId ? this.findNode(this.treeData, node.parentId) : null
return parent ? parent.title : (course ? course.courseName : "")
},
nodeClick(data) {
if (data.nodeType === "course") {
this.selectedNode = {}
this.nodeForm = {}
this.resourceData = []
return
}
this.selectedNode = data
this.nodeForm = Object.assign({}, data)
this.activeTab = "basic"
this.loadResources()
this.loadRule()
},
openContextMenu(event, data) {
event.preventDefault()
this.contextNode = data
this.contextVisible = true
this.contextLeft = event.clientX
this.contextTop = event.clientY
},
openNodeForm(type, node = null) {
if (!this.currentCourseId) {
this.$message.warning("请先选择课程")
return
}
const course = this.courseOptions.find(v => v.id === this.currentCourseId)
if (node && node.id && node.nodeType === type) {
this.nodeDialogTitle = "编辑" + (type === "chapter" ? "章" : "节")
this.nodeDialogForm = Object.assign({}, node, {
parentName: this.getParentName(type, node, course, true)
})
} else {
this.nodeDialogTitle = "新增" + (type === "chapter" ? "章" : "节")
this.nodeDialogForm = {
courseId: this.currentCourseId,
courseName: course ? course.courseName : "",
parentId: type === "section" && node ? node.id : "",
parentName: this.getParentName(type, node, course, false),
nodeType: type,
title: "",
subtitle: "",
description: "",
sortOrder: 1,
required: false,
status: "enabled"
}
}
this.nodeDialogVisible = true
this.$nextTick(() => this.$refs.nodeDialogForm && this.$refs.nodeDialogForm.clearValidate())
},
submitNodeDialog() {
this.$refs.nodeDialogForm.validate(async valid => {
if (!valid) return
const resp = await this.$axios.post(loc() + "/saveNode", this.nodeDialogForm)
if (resp.code === 0) {
this.$message.success(resp.msg)
this.nodeDialogVisible = false
await this.loadTree()
} else {
this.$message.warning(resp.msg)
}
})
},
saveNodeBasic() {
this.$refs.nodeForm.validate(async valid => {
if (!valid) return
const resp = await this.$axios.post(loc() + "/saveNode", this.nodeForm)
if (resp.code === 0) {
this.$message.success(resp.msg)
this.selectedNode = Object.assign({}, this.nodeForm)
await this.loadTree()
await this.loadRule()
} else {
this.$message.warning(resp.msg)
}
})
},
deleteNode(node) {
this.$confirm("确定要删除该节点吗?若下级有内容将不允许删除。", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(async () => {
const resp = await this.$axios.post(loc() + "/deleteNode", { id: node.id })
if (resp.code === 0) {
this.$message.success(resp.msg)
this.selectedNode = {}
await this.loadTree()
} else {
this.$message.warning(resp.msg)
}
})
},
async toggleNode(node) {
const status = node.status === "enabled" ? "disabled" : "enabled"
const resp = await this.$axios.post(loc() + "/toggleNode", { id: node.id, status })
if (resp.code === 0) {
this.$message.success(resp.msg)
await this.loadTree()
}
},
async moveNode(node, direction) {
const resp = await this.$axios.post(loc() + "/moveNode", { id: node.id, direction })
if (resp.code === 0) {
await this.loadTree()
}
},
async setRequired(node) {
const next = !node.required
const data = Object.assign({}, node, { required: next })
const resp = await this.$axios.post(loc() + "/saveNode", data)
if (resp.code === 0) {
this.$message.success(resp.msg)
await this.loadTree()
if (this.selectedNode.id === node.id) {
this.nodeClick(Object.assign({}, node, { required: next }))
}
}
},
async loadResources() {
if (!this.selectedNode.id) return
const resp = await this.$axios.post(loc() + "/resourcePage", Object.assign({}, this.resourcePageForm, { outlineId: this.selectedNode.id }))
if (resp.code === 0) {
this.resourceData = resp.data.list
this.resourcePageForm.totalCount = resp.data.totalCount
}
},
resourcePageOrder(column) {
this.resourcePageForm.pageOrderName = column.prop
this.resourcePageForm.pageOrderBy = column.order
this.loadResources()
},
resourceSizeChange(val) {
this.resourcePageForm.pageSize = val
this.loadResources()
},
resourceNumberChange(val) {
this.resourcePageForm.pageNumber = val
this.loadResources()
},
resourceIndex(index) {
return index + (this.resourcePageForm.pageNumber - 1) * this.resourcePageForm.pageSize + 1
},
openResourceForm(row = null, node = null) {
const target = node || this.selectedNode
if (!target.id) {
this.$message.warning("请先选择章/节节点")
return
}
if (row) {
this.resourceForm = Object.assign({}, row, {
outlineName: target.title,
fileData: this.parseFiles(row.fileData)
})
} else {
this.resourceForm = {
courseId: target.courseId,
outlineId: target.id,
outlineName: target.title,
resourceTitle: "",
resourceType: "video",
fileExt: "",
fileData: [],
durationSeconds: 0,
sortOrder: 1,
required: false,
allowPreview: true,
allowDownload: false,
status: "enabled"
}
}
if (this.resourceForm.required) this.$set(this.resourceForm, "allowPreview", true)
this.resourceTypeChange(this.resourceForm.resourceType)
this.resourceDialogVisible = true
this.$nextTick(() => this.$refs.resourceForm && this.$refs.resourceForm.clearValidate())
},
resourceRequiredChange(required) {
if (required) this.$set(this.resourceForm, "allowPreview", true)
},
resourcePreviewChange(allowPreview) {
if (!allowPreview && this.resourceForm.required) {
this.$set(this.resourceForm, "allowPreview", true)
this.$message.warning("必学资料必须允许预览,请先关闭“是否必学”")
return
}
this.$set(this.resourceForm, "allowPreview", allowPreview)
},
resourceTypeChange(val) {
const type = this.resourceTypeOptions.find(v => v.code === val)
this.fileAccept = type ? type.accept : ""
this.autoFillMediaDuration()
},
submitResource() {
this.$refs.resourceForm.validate(async valid => {
if (!valid) return
const data = Object.assign({}, this.resourceForm)
data.fileExt = this.getFileExt(data.fileData)
if (!this.validResourceExt(data.resourceType, data.fileExt)) {
this.$message.warning("当前资料类型不支持上传 ." + data.fileExt + " 格式文件")
return
}
delete data.outlineName
data.fileData = JSON.stringify(data.fileData || [])
const resp = await this.$axios.post(loc() + "/saveResource", data)
if (resp.code === 0) {
this.$message.success(resp.msg)
this.resourceDialogVisible = false
this.loadResources()
} else {
this.$message.warning(resp.msg)
}
})
},
deleteResource(row) {
this.$confirm("确定要删除该学习资料吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(async () => {
const resp = await this.$axios.post(loc() + "/deleteResource", { id: row.id })
if (resp.code === 0) {
this.$message.success(resp.msg)
this.loadResources()
}
})
},
async loadRule() {
if (!this.selectedNode.id) return
const resp = await this.$axios.post(loc() + "/getRule", {
courseId: this.selectedNode.courseId,
targetType: "outline",
targetId: this.selectedNode.id
})
if (resp.code === 0) {
this.ruleForm = Object.assign({}, resp.data, {
minStudyMinutes: this.formatDurationMinutes(resp.data.minStudySeconds)
})
}
},
async saveRule() {
const data = Object.assign({}, this.ruleForm, {
minStudySeconds: Math.round((this.ruleForm.minStudyMinutes || 0) * 60)
})
delete data.minStudyMinutes
const resp = await this.$axios.post(loc() + "/saveRule", data)
if (resp.code === 0) {
this.$message.success(resp.msg)
await this.loadTree()
await this.loadRule()
} else {
this.$message.warning(resp.msg)
}
},
parseFiles(value) {
if (!value) return []
if (Array.isArray(value)) return value
try {
return JSON.parse(value)
} catch (e) {
return []
}
},
getFileUrl(files) {
const file = Array.isArray(files) && files.length ? files[0] : null
if (!file) return ""
return file.url || file.response?.data || file.data || ""
},
getFileExt(files) {
const file = Array.isArray(files) && files.length ? files[0] : null
if (!file) return ""
const name = file.name || file.url || file.response?.data || ""
const index = name.lastIndexOf(".")
return index > -1 ? name.substring(index + 1).toLowerCase() : ""
},
formatDurationMinutes(durationSeconds) {
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)
if (!url) return
const media = document.createElement(this.resourceForm.resourceType === "video" ? "video" : "audio")
media.preload = "metadata"
media.onloadedmetadata = () => {
window.URL.revokeObjectURL(media.src)
if (isFinite(media.duration) && media.duration > 0) {
// 媒体时长按秒保存,避免先取整到分钟再换算导致实际时长被放大。
this.$set(this.resourceForm, "durationSeconds", Math.ceil(media.duration))
}
}
media.onerror = () => {
window.URL.revokeObjectURL(media.src)
}
const fileId = url.match(/[?&]id=([^&]+)/)
media.src = this.resourceForm.resourceType === "audio" && fileId
? "/platform/sys/file/preview?id=" + fileId[1] : url
},
validResourceExt(resourceType, fileExt) {
const item = this.resourceTypeOptions.find(v => v.code === resourceType)
if (!item || !item.accept) return true
return item.accept.split(",").map(v => v.replace(".", "").toLowerCase()).includes(fileExt)
},
getResourceTypeName(code) {
const item = this.resourceTypeOptions.find(v => v.code === code)
return item ? item.name : code
}
},
watch: {
"resourceForm.fileData": {
handler() {
this.autoFillMediaDuration()
},
deep: true
}
},
mounted() {
document.addEventListener("click", () => this.contextVisible = false)
},
async created() {
await this.loadCourseTypes()
const courseId = this.getQueryParam("courseId")
if (courseId) {
this.currentCourseId = courseId
this.courseQuery.courseId = courseId
await this.loadCourses(true)
this.$delete(this.courseQuery, "courseId")
} else {
await this.loadCourses()
}
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,12 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<el-card shadow="never">
<div slot="header">课程介绍</div>
<el-empty description="课程介绍页面后续设计"></el-empty>
</el-card>
</div>
<!--#
}
#-->
@@ -0,0 +1,350 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
.course-display {
padding: 8px 10px 22px;
background: #f4f5f7;
min-height: calc(100vh - 88px);
}
.course-filter {
background: #fff;
border: 1px solid #e5e8ef;
margin-bottom: 28px;
}
.filter-row {
display: flex;
min-height: 70px;
border-bottom: 1px solid #e8ebf0;
}
.filter-row:last-child {
border-bottom: 0;
}
.filter-label {
width: 132px;
padding: 20px 16px;
background: #eef5ff;
color: #1f2d3d;
font-weight: 600;
text-align: center;
}
.filter-options {
flex: 1;
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: 10px 18px;
padding: 16px 18px;
}
.filter-chip {
min-width: 76px;
height: 32px;
padding: 0 18px;
border: 0;
border-radius: 9px;
background: transparent;
color: #526070;
cursor: pointer;
line-height: 32px;
text-align: center;
}
.filter-chip.active {
background: #1e63e9;
color: #fff;
font-weight: 600;
}
.course-result-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
}
.course-count {
color: #1f2d3d;
}
.course-count span {
color: #2468f2;
padding: 0 4px;
}
.course-search {
width: 310px;
}
.course-list {
background: #fff;
border-top: 1px solid #dfe4ec;
}
.course-item {
display: flex;
gap: 20px;
padding: 18px 0;
border-bottom: 1px solid #dfe4ec;
}
.course-cover {
position: relative;
width: 270px;
height: 150px;
flex: 0 0 270px;
background: #e9eef5;
border-radius: 4px;
overflow: hidden;
}
.course-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.course-cover-empty {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #8a97a8;
background: linear-gradient(135deg, #eaf0f8, #d8e3f2);
font-weight: 600;
}
.course-cover-tag {
position: absolute;
right: 0;
bottom: 12px;
min-width: 70px;
height: 24px;
padding: 0 10px;
background: #f5a400;
color: #fff;
font-size: 12px;
line-height: 24px;
text-align: center;
border-radius: 12px 0 0 12px;
}
.course-info {
flex: 1;
min-width: 0;
padding-right: 12px;
}
.course-title {
display: inline-block;
margin: 4px 0 8px;
color: #111827;
font-size: 18px;
font-weight: 700;
cursor: pointer;
}
.course-title:hover {
color: #006fc9;
}
.course-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
color: #8b96a6;
font-size: 13px;
margin-bottom: 12px;
}
.course-intro {
color: #4f5f73;
line-height: 1.8;
margin-bottom: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.course-time {
color: #8b96a6;
font-size: 13px;
}
.recommend-tags {
display: inline-flex;
gap: 6px;
margin-left: 8px;
vertical-align: 2px;
}
.course-empty {
background: #fff;
padding: 80px 0;
text-align: center;
color: #8b96a6;
border-top: 1px solid #dfe4ec;
}
</style>
<div id="app" class="course-display" v-cloak>
<div class="course-filter">
<div class="filter-row">
<div class="filter-label">推荐标识</div>
<div class="filter-options">
<button class="filter-chip" :class="{active: !query.recommendFlag}" @click="selectRecommend('')">全部</button>
<button
v-for="item in recommendOptions"
:key="item.code"
class="filter-chip"
:class="{active: query.recommendFlag === item.code}"
@click="selectRecommend(item.code)">
{{item.name}}
</button>
</div>
</div>
<div class="filter-row">
<div class="filter-label">课程类型</div>
<div class="filter-options">
<button class="filter-chip" :class="{active: !query.courseTypeId}" @click="selectCourseType('')">全部</button>
<button
v-for="item in courseTypeOptions"
:key="item.id"
class="filter-chip"
:class="{active: query.courseTypeId === item.id}"
@click="selectCourseType(item.id)">
{{item.typeName}}
</button>
</div>
</div>
</div>
<div class="course-result-bar">
<div class="course-count">为您找到相关课程<span>{{pageForm.totalCount || 0}}</span>门</div>
<el-input
class="course-search"
v-model="query.keyword"
clearable
placeholder="搜索关键字"
prefix-icon="el-icon-search"
@keyup.enter.native="doSearch"
@clear="doSearch">
<el-button slot="append" icon="el-icon-search" @click="doSearch"></el-button>
</el-input>
</div>
<div v-if="pageForm.list && pageForm.list.length" class="course-list">
<div v-for="course in pageForm.list" :key="course.id" class="course-item">
<div class="course-cover">
<img v-if="getCoverUrl(course.cover)" :src="getCoverUrl(course.cover)" :alt="course.courseName">
<div v-else class="course-cover-empty">{{course.courseTypeName || '课程'}}</div>
<div v-if="firstRecommendName(course.recommendFlags)" class="course-cover-tag">{{firstRecommendName(course.recommendFlags)}}</div>
</div>
<div class="course-info">
<div>
<span class="course-title" @click="openDetail(course)">{{course.courseName}}</span>
<span class="recommend-tags">
<el-tag v-for="code in splitFlags(course.recommendFlags)" :key="code" size="mini" type="warning">{{getRecommendName(code)}}</el-tag>
</span>
</div>
<div class="course-meta">
<span><i class="el-icon-office-building"></i> {{course.courseTypeName || '未设置课程类型'}}</span>
<span><i class="el-icon-user"></i> {{course.lecturerName || '未设置讲师'}}</span>
</div>
<div class="course-intro">{{course.courseIntro || '暂无课程介绍'}}</div>
<div class="course-time">
<i class="el-icon-time"></i>
<span v-if="course.openType === 'long_term'">长期开放</span>
<span v-else>{{course.startTimeText || '-'}} 至 {{course.endTimeText || '-'}}</span>
</div>
</div>
</div>
</div>
<div v-else class="course-empty">暂无相关课程</div>
<el-pagination
class="mt20"
background
layout="total, sizes, prev, pager, next"
:current-page.sync="pageForm.pageNumber"
:page-size.sync="pageForm.pageSize"
:total="pageForm.totalCount"
@size-change="pageData"
@current-change="pageData">
</el-pagination>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
query: {
keyword: "",
courseTypeId: "",
recommendFlag: ""
},
courseTypeOptions: [],
recommendOptions: [],
pageForm: {
pageNumber: 1,
pageSize: 10,
totalCount: 0,
list: []
}
}
},
methods: {
async pageData() {
const resp = await this.$axios.post(loc() + "/pageData", Object.assign({}, this.query, {
pageNumber: this.pageForm.pageNumber,
pageSize: this.pageForm.pageSize
}))
if (resp.code === 0) {
this.pageForm = resp.data
}
},
doSearch() {
this.pageForm.pageNumber = 1
this.pageData()
},
selectRecommend(code) {
this.query.recommendFlag = code
this.doSearch()
},
selectCourseType(id) {
this.query.courseTypeId = id
this.doSearch()
},
async loadOptions() {
const typeResp = await this.$axios.post(loc() + "/courseTypes")
if (typeResp.code === 0) {
this.courseTypeOptions = typeResp.data
}
const recommendResp = await this.$axios.post(loc() + "/recommendOptions")
if (recommendResp.code === 0) {
this.recommendOptions = recommendResp.data
}
},
getCoverUrl(cover) {
if (!cover) return ""
if (Array.isArray(cover)) {
return cover.length ? (cover[0].url || cover[0].response?.data || cover[0].data || "") : ""
}
if (typeof cover === "string" && cover.trim().startsWith("[")) {
try {
const files = JSON.parse(cover)
return files.length ? (files[0].url || files[0].response?.data || files[0].data || "") : ""
} catch (e) {
return ""
}
}
return cover
},
splitFlags(flags) {
return flags ? flags.split(",").filter(Boolean) : []
},
getRecommendName(code) {
const item = this.recommendOptions.find(v => v.code === code)
return item ? item.name : code
},
firstRecommendName(flags) {
const codes = this.splitFlags(flags)
return codes.length ? this.getRecommendName(codes[0]) : ""
},
openDetail(course) {
window.location.href = loc() + "/study?id=" + course.id
}
},
async created() {
await this.loadOptions()
await this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,213 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
[v-cloak] { display: none; }
#sub-app-container-main-content { padding: 0 !important; overflow: hidden; background: #f5f8fc; }
#sub-app-container-main-content-body { height: 100%; overflow: hidden; }
.learning-list-page { height: 100%; min-height: 0; overflow: hidden; color: #17233d; background: #f5f8fc; }
.learning-body { width: min(1180px, calc(100% - 24px)); height: 100%; min-height: 0; margin: 0 auto; padding: 12px 0 22px; display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 30px; box-sizing: border-box; }
.filter-panel { min-height: 0; overflow: hidden; display: flex; flex-direction: column; gap: 16px; }
.filter-group { padding: 16px; flex: none; background: #fff; border-radius: 9px; box-shadow: 0 6px 22px rgba(31, 70, 121, .08); }
.filter-group + .filter-group { margin-top: 0; }
.filter-title { margin: 2px 0 10px; padding-left: 9px; position: relative; color: #24344e; font-size: 14px; font-weight: 700; line-height: 20px; }
.filter-title::before { content: ""; width: 3px; height: 14px; position: absolute; left: 0; top: 3px; border-radius: 2px; background: #1677ff; }
.filter-option { height: 34px; padding: 0 10px; display: flex; align-items: center; color: #45536a; border-radius: 6px; cursor: pointer; font-size: 14px; transition: .2s; }
.filter-option-icon { width: 18px; margin-right: 10px; color: #6e7d94; font-size: 16px; text-align: center; }
.filter-option:hover { color: #1677ff; background: #edf5ff; }
.filter-option.active { color: #1677ff; background: #eaf3ff; font-weight: 600; }
.filter-option.active .filter-option-icon { color: #1677ff; }
.course-content { min-width: 0; min-height: 0; overflow-y: auto; padding: 0 10px 6px 0; scrollbar-gutter: stable; }
.course-content::-webkit-scrollbar { width: 7px; }
.course-content::-webkit-scrollbar-thumb { border-radius: 5px; background: #d6e1ef; }
.course-toolbar { height: 38px; margin-bottom: 10px; position: sticky; top: 0; z-index: 2; display: flex; align-items: flex-start; justify-content: space-between; color: #4e5d74; font-size: 14px; background: #f5f8fc; }
.course-toolbar > span { padding-top: 8px; font-size: 15px; font-weight: 600; }
.course-toolbar .el-select { width: 122px; }
.course-toolbar .el-input__inner { border-color: #e2e9f2; border-radius: 6px; background: #fff; color: #53627a; }
.course-list { display: flex; flex-direction: column; gap: 16px; }
.course-card { min-height: 160px; display: grid; grid-template-columns: 236px minmax(0, 1fr) 112px; overflow: hidden; background: #fff; border: 0; border-radius: 10px; box-shadow: 0 5px 18px rgba(30, 66, 115, .08); transition: box-shadow .2s, transform .2s; }
.course-card:hover { transform: translateY(-1px); box-shadow: 0 9px 24px rgba(30, 67, 119, .12); }
.course-cover { position: relative; width: 100%; aspect-ratio: 16 / 9; align-self: center; overflow: hidden; background: #eaf5ee; }
.course-cover img { position: absolute; inset: 0; width: 100%; height: 100%; display: block; object-fit: cover; }
.course-cover-empty { position: absolute; inset: 0; width: 100%; height: 100%; 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) {
pjaxReplace("/platform/learning/course/display/study?id=" + encodeURIComponent(course.id || ""))
}
},
created() {
this.loadOptions().then(() => {
this.pageData()
})
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,755 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
#sidebar-menu,
#menu-toggle-btn,
#menu-overlay {
display: none !important;
}
#sub-app-container-main-content {
padding: 0 !important;
}
#sub-app-container-main-content-body {
height: 100%;
}
.study-page {
min-height: calc(100vh - 50px);
background: #eef3f8;
}
.study-header {
height: 150px;
margin: 12px 14px 0;
padding: 20px 24px;
color: #1f2937;
background: linear-gradient(120deg, #ffffff 0%, #f4f8ff 58%, #eaf2ff 100%);
border: 1px solid #dbe7f5;
border-radius: 10px;
box-shadow: 0 6px 18px rgba(31, 64, 116, .08);
display: flex;
align-items: center;
gap: 22px;
box-sizing: border-box;
}
.study-back {
align-self: flex-start;
margin-left: auto;
order: 5;
color: #1e63c8;
border-color: #b8d0f2;
background: #fff;
}
.study-cover {
width: 190px;
height: 102px;
object-fit: cover;
background: #eef4fb;
border: 1px solid #d7e3f1;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(31, 64, 116, .12);
}
.study-cover-empty {
width: 190px;
height: 102px;
display: flex;
align-items: center;
justify-content: center;
background: #eef4fb;
color: #6b7d90;
border: 1px solid #d7e3f1;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(31, 64, 116, .12);
font-weight: 600;
}
.study-course-main {
flex: 1;
min-width: 0;
}
.study-title {
font-size: 28px;
line-height: 1.2;
font-weight: 700;
margin-bottom: 14px;
}
.study-title .el-tag {
margin-left: 8px;
vertical-align: 5px;
}
.study-meta {
display: flex;
flex-wrap: wrap;
gap: 10px 26px;
color: #526376;
font-size: 14px;
}
.study-body {
display: flex;
gap: 14px;
height: calc(100vh - 212px);
padding: 14px;
box-sizing: border-box;
}
.study-tree-panel {
width: 340px;
background: #fff;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.study-tree-title {
height: 54px;
padding: 0 18px;
display: flex;
align-items: center;
justify-content: space-between;
color: #1768e5;
font-size: 18px;
font-weight: 700;
border-bottom: 1px solid #edf1f7;
}
.study-tree {
flex: 1;
overflow: auto;
padding: 10px;
}
.study-node {
display: inline-flex;
align-items: center;
max-width: 100%;
gap: 6px;
}
.study-node-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.study-content {
flex: 1;
min-width: 0;
background: #fff;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.resource-head {
min-height: 54px;
padding: 0 18px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #edf1f7;
}
.resource-title {
color: #1f2937;
font-size: 16px;
font-weight: 600;
}
.resource-actions {
display: flex;
align-items: center;
gap: 10px;
}
.study-progress {
width: 180px;
}
.resource-view {
flex: 1;
overflow: auto;
padding: 20px;
background: #f8fafc;
}
.media-player {
width: 100%;
max-height: calc(100vh - 300px);
background: #000;
}
.audio-wrap {
display: flex;
align-items: center;
justify-content: center;
height: 320px;
background: linear-gradient(135deg, #132544, #0a1224);
border-radius: 6px;
}
.audio-wrap audio {
width: 80%;
}
.image-preview {
max-width: 100%;
display: block;
margin: 0 auto;
background: #fff;
}
.doc-frame {
width: 100%;
height: calc(100vh - 275px);
border: 0;
background: #fff;
}
.file-fallback {
padding: 60px 20px;
text-align: center;
color: #667085;
background: #fff;
border-radius: 6px;
}
</style>
<div id="app" class="study-page" v-cloak>
<div class="study-header">
<el-button class="study-back" icon="el-icon-back" size="small" @click="goBack">返回</el-button>
<img v-if="coverUrl" class="study-cover" :src="coverUrl" :alt="course.courseName">
<div v-else class="study-cover-empty">课程图片</div>
<div class="study-course-main">
<div class="study-title">
{{course.courseName || '课程学习'}}
<el-tag v-for="code in splitFlags(course.recommendFlags)" :key="code" size="small" type="warning">{{getRecommendName(code)}}</el-tag>
</div>
<div class="study-meta">
<span><i class="el-icon-user"></i> 授课讲师:{{course.lecturerName || '未设置'}}</span>
<span><i class="el-icon-date"></i> {{course.openType === 'long_term' ? '长期开放' : ((course.startTimeText || '-') + ' 至 ' + (course.endTimeText || '-'))}}</span>
<span><i class="el-icon-collection-tag"></i> 课程类型:{{course.courseTypeName || '未设置'}}</span>
</div>
</div>
</div>
<div class="study-body">
<div class="study-tree-panel">
<div class="study-tree-title">
<span>课程安排</span>
<el-button type="text" size="mini" @click="collapseAll">收起</el-button>
</div>
<div class="study-tree">
<el-tree
ref="studyTree"
:data="treeData"
node-key="id"
default-expand-all
highlight-current
:expand-on-click-node="false"
:props="{children:'children', label:'title'}"
@node-click="nodeClick">
<span slot-scope="{ data }" class="study-node">
<i :class="getNodeIcon(data)"></i>
<span class="study-node-title">{{data.title}}</span>
<el-tag v-if="data.required" size="mini" type="warning">必学</el-tag>
</span>
</el-tree>
</div>
</div>
<div class="study-content">
<div class="resource-head">
<div class="resource-title">{{selectedResource.title || '请选择课程资料'}}</div>
<div class="resource-actions">
<el-progress
v-if="selectedResource.id"
class="study-progress"
:percentage="recordProgress"
:stroke-width="8">
</el-progress>
<el-tag v-if="studying" size="mini" type="success">学习中 {{recordStudyTime}}</el-tag>
<el-button v-if="selectedResource.id && !studying" size="mini" type="primary" icon="el-icon-video-play" @click="startStudy">开始学习</el-button>
<el-button v-if="studying" size="mini" type="danger" icon="el-icon-video-pause" @click="finishStudy(false)">结束学习</el-button>
<el-button v-if="fileUrl" size="mini" type="text" icon="el-icon-download" @click="openFile">打开原文件</el-button>
</div>
</div>
<div class="resource-view">
<template v-if="selectedResource.id">
<video
v-if="selectedResource.resourceType === 'video'"
ref="mediaPlayer"
class="media-player"
:src="fileUrl"
controls
controlslist="nodownload"
@play="mediaPlay"
@ended="mediaEnded"
@timeupdate="saveProgress"
@loadedmetadata="mediaReady">
</video>
<div v-else-if="selectedResource.resourceType === 'audio'" class="audio-wrap">
<audio
ref="mediaPlayer"
:src="fileUrl"
controls
controlslist="nodownload"
@play="mediaPlay"
@ended="mediaEnded"
@timeupdate="saveProgress"
@loadedmetadata="mediaReady">
</audio>
</div>
<img v-else-if="selectedResource.resourceType === 'image'" class="image-preview" :src="fileUrl" :alt="selectedResource.title">
<iframe v-else-if="canInlinePreview(selectedResource)" class="doc-frame" :src="inlinePreviewUrl"></iframe>
<div v-else class="file-fallback">
<file-preview :files="selectedResource.fileData" complete_result></file-preview>
<el-button class="mt20" type="primary" icon="el-icon-view" @click="openFile">打开资料</el-button>
</div>
</template>
<el-empty v-else description="请选择左侧课程资料"></el-empty>
</div>
</div>
</div>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
courseId: "",
apiBase: "/platform/learning/course/display",
recordApi: "/platform/learning/study/record",
course: {},
treeData: [],
selectedResource: {},
recommendOptions: [],
studying: false,
currentSegmentId: "",
recordProgress: 0,
recordStudyTime: "0秒",
pendingSeconds: 0,
heartbeatTimer: null,
startingStudy: false,
autoResumeKey: "",
lastPositionSyncAt: 0,
lastActiveAt: Date.now(),
inactiveLimit: 60000
}
},
computed: {
coverUrl() {
return this.getFileUrl(this.course.cover)
},
fileUrl() {
return this.getFileUrl(this.selectedResource.fileData)
},
inlinePreviewUrl() {
const id = this.getFileId(this.selectedResource.fileData)
return id ? this.apiBase + "/pdfPreview?id=" + encodeURIComponent(id) : this.fileUrl
}
},
methods: {
getQuery(name) {
return new URLSearchParams(window.location.search).get(name) || ""
},
async loadCourse() {
const resp = await this.$axios.post(this.apiBase + "/courseInfo", { id: this.courseId })
if (resp.code === 0) {
this.course = resp.data
} else {
this.$message.warning(resp.msg)
}
},
async loadTree() {
const resp = await this.$axios.post(this.apiBase + "/studyTree", { courseId: this.courseId })
if (resp.code === 0) {
this.treeData = resp.data || []
const first = this.findFirstResource(this.treeData)
if (first) {
this.nodeClick(first)
this.$nextTick(() => this.$refs.studyTree && this.$refs.studyTree.setCurrentKey(first.id))
}
}
},
async loadRecommendOptions() {
const resp = await this.$axios.post(this.apiBase + "/recommendOptions")
if (resp.code === 0) {
this.recommendOptions = resp.data
}
},
findFirstResource(nodes) {
for (const node of nodes || []) {
if (node.type === "resource") return node
const child = this.findFirstResource(node.children || [])
if (child) return child
}
return null
},
nodeClick(data) {
if (data.type !== "resource") return
if (this.studying && this.selectedResource.id !== data.id) {
this.$message.warning("请先结束当前章节资料的学习")
return
}
this.selectedResource = data
this.recordProgress = data.progressPercent || 0
this.recordStudyTime = this.formatSeconds(Number(data.studySeconds || 0))
},
getNodeIcon(data) {
if (data.type === "resource") {
const map = {
video: "el-icon-video-camera",
audio: "el-icon-headset",
image: "el-icon-picture-outline",
pdf: "el-icon-document",
word: "el-icon-document",
ppt: "el-icon-document"
}
return map[data.resourceType] || "el-icon-paperclip"
}
return data.nodeType === "chapter" ? "el-icon-folder" : "el-icon-notebook-2"
},
getFileUrl(value) {
if (!value) return ""
if (Array.isArray(value)) {
return value.length ? (value[0].url || value[0].response?.data || value[0].data || "") : ""
}
if (typeof value === "string" && value.trim().startsWith("[")) {
try {
const files = JSON.parse(value)
return files.length ? (files[0].url || files[0].response?.data || files[0].data || "") : ""
} catch (e) {
return ""
}
}
return value
},
getFileId(value) {
const url = this.getFileUrl(value)
if (!url) return ""
const matched = url.match(/[?&]id=([^&]+)/)
if (matched) return decodeURIComponent(matched[1])
if (!url.includes("/") && !url.includes(".")) return url
return ""
},
canInlinePreview(resource) {
const type = resource.resourceType
const ext = (resource.fileExt || "").toLowerCase()
return ["pdf", "ppt", "word"].includes(type) || ["pdf", "ppt", "pptx", "doc", "docx"].includes(ext)
},
progressKey() {
return "learning-progress-" + this.courseId + "-" + this.selectedResource.id
},
mediaReady() {
this.askResume()
},
askResume() {
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return
if (this.studying || this.startingStudy) return
const currentKey = this.progressKey()
if (this.autoResumeKey === currentKey) return
this.autoResumeKey = currentKey
const player = this.$refs.mediaPlayer
const localSaved = Number(localStorage.getItem(this.progressKey()) || 0)
const serverSaved = Number(this.selectedResource.lastPositionSeconds || 0)
const saved = Math.max(localSaved, serverSaved)
if (!player || !saved || saved < 5 || saved >= player.duration - 5) return
this.$confirm("检测到上次学习到 " + this.formatSeconds(saved) + ",请选择继续学习或从头开始。", "继续学习", {
confirmButtonText: "继续学习",
cancelButtonText: "从头开始",
type: "info"
}).then(() => {
this.startStudy({ autoPlay: true, seekTo: saved })
}).catch(() => {
localStorage.removeItem(this.progressKey())
this.saveServerPosition(0)
this.startStudy({ autoPlay: true, seekTo: 0 })
})
},
async mediaPlay() {
if (this.studying || this.startingStudy) return
await this.startStudy({ fromMedia: true })
},
mediaEnded() {
this.finishStudy(false, { silent: true })
localStorage.removeItem(this.progressKey())
},
async playSelectedMedia(seekTo) {
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return
await this.$nextTick()
const player = this.$refs.mediaPlayer
if (!player) return
try {
if (typeof seekTo === "number" && !Number.isNaN(seekTo)) {
await this.seekBeforePlay(player, seekTo)
}
const playPromise = player.play && player.play()
if (playPromise && playPromise.catch) {
await playPromise.catch(() => {
this.$message.warning("浏览器阻止了自动播放,请点击播放器播放按钮继续")
})
}
} catch (e) {
this.$message.warning("播放器定位失败,请手动点击播放后继续")
}
},
async seekBeforePlay(player, seekTo) {
await this.waitForMediaReady(player)
const target = this.normalizeSeekTarget(player, seekTo)
if (target === null) return
if (Math.abs((player.currentTime || 0) - target) <= 1) return
player.pause()
player.currentTime = target
await this.waitForSeek(player, target, 1800)
if (Math.abs((player.currentTime || 0) - target) > 1) {
player.currentTime = target
await this.waitForSeek(player, target, 1200)
}
},
waitForMediaReady(player) {
if (player.readyState >= 1 && !Number.isNaN(player.duration)) {
return Promise.resolve()
}
return new Promise(resolve => {
const done = () => {
player.removeEventListener("loadedmetadata", done)
player.removeEventListener("durationchange", done)
resolve()
}
player.addEventListener("loadedmetadata", done, { once: true })
player.addEventListener("durationchange", done, { once: true })
setTimeout(done, 2000)
})
},
waitForSeek(player, target, timeout) {
return new Promise(resolve => {
const done = () => {
player.removeEventListener("seeked", done)
player.removeEventListener("timeupdate", done)
resolve()
}
player.addEventListener("seeked", done, { once: true })
player.addEventListener("timeupdate", done, { once: true })
setTimeout(done, timeout)
})
},
normalizeSeekTarget(player, seekTo) {
const raw = Math.max(0, Number(seekTo || 0))
if (Number.isNaN(raw)) return null
if (!Number.isNaN(player.duration) && player.duration > 0) {
return Math.min(raw, Math.max(0, player.duration - 1))
}
return raw
},
saveProgress() {
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return
const player = this.$refs.mediaPlayer
if (player && player.currentTime > 0) {
const position = Math.floor(player.currentTime)
localStorage.setItem(this.progressKey(), String(position))
this.$set(this.selectedResource, "lastPositionSeconds", position)
if (Date.now() - this.lastPositionSyncAt > 5000) {
this.saveServerPosition(position)
}
}
},
saveServerPosition(position, useBeacon) {
if (!this.selectedResource.id || !["video", "audio"].includes(this.selectedResource.resourceType)) return
const seconds = Math.max(0, Math.floor(Number(position || 0)))
this.lastPositionSyncAt = Date.now()
if (useBeacon && navigator.sendBeacon) {
const body = new URLSearchParams()
body.append("courseId", this.courseId)
body.append("resourceId", this.selectedResource.id)
body.append("positionSeconds", String(seconds))
const blob = new Blob([body.toString()], { type: "application/x-www-form-urlencoded;charset=UTF-8" })
navigator.sendBeacon(this.recordApi + "/position", blob)
return
}
this.$axios.post(this.recordApi + "/position", {
courseId: this.courseId,
resourceId: this.selectedResource.id,
positionSeconds: seconds
})
},
async startStudy(options) {
const config = options && !options.target ? options : {}
if (!this.selectedResource.id) {
this.$message.warning("请选择左侧课程资料")
return
}
if (this.studying) {
if (config.autoPlay) {
this.playSelectedMedia(config.seekTo)
}
return
}
this.startingStudy = true
let resp
try {
resp = await this.$axios.post(this.recordApi + "/start", {
courseId: this.courseId,
resourceId: this.selectedResource.id,
positionSeconds: typeof config.seekTo === "number" ? Math.floor(config.seekTo) : this.getMediaPosition()
})
} finally {
this.startingStudy = false
}
if (resp.code !== 0) {
this.$message.warning(resp.msg)
return
}
this.currentSegmentId = resp.data.segmentId
this.studying = true
this.pendingSeconds = 0
this.lastActiveAt = Date.now()
this.applyRecordState(resp.data)
this.startHeartbeatTimer()
if (config.autoPlay !== false && !config.fromMedia) {
this.playSelectedMedia(config.seekTo)
}
this.$message.success("已开始学习")
},
async heartbeat() {
if (!this.studying || !this.currentSegmentId || this.pendingSeconds <= 0) return
const seconds = this.pendingSeconds
this.pendingSeconds = 0
const resp = await this.$axios.post(this.recordApi + "/heartbeat", {
segmentId: this.currentSegmentId,
activeSeconds: seconds,
positionSeconds: this.getMediaPosition()
})
if (resp.code === 0) {
this.applyRecordState(resp.data)
} else {
this.stopHeartbeatTimer()
this.studying = false
this.currentSegmentId = ""
this.$message.warning(resp.msg)
}
},
async finishStudy(force, options) {
const config = options || {}
if (!this.currentSegmentId) return
const segmentId = this.currentSegmentId
const seconds = this.pendingSeconds
this.pendingSeconds = 0
this.stopHeartbeatTimer()
this.studying = false
this.currentSegmentId = ""
if (!force) {
this.pauseSelectedMedia()
}
if (force && navigator.sendBeacon) {
this.saveServerPosition(this.getMediaPosition(), true)
const formData = new FormData()
formData.append("segmentId", segmentId)
formData.append("activeSeconds", String(seconds))
formData.append("positionSeconds", String(this.getMediaPosition()))
navigator.sendBeacon(this.recordApi + "/finish", formData)
return
}
const resp = await this.$axios.post(this.recordApi + "/finish", {
segmentId: segmentId,
activeSeconds: seconds,
positionSeconds: this.getMediaPosition()
})
if (resp.code === 0 && resp.data) {
this.applyRecordState(resp.data)
if (!config.silent) {
this.$message.success("学习已结束")
}
}
},
pauseSelectedMedia() {
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return
const player = this.$refs.mediaPlayer
if (player && !player.paused) {
player.pause()
}
},
startHeartbeatTimer() {
this.stopHeartbeatTimer()
this.heartbeatTimer = setInterval(() => {
if (!this.studying) return
if (this.isEffectiveLearning()) {
this.pendingSeconds += 1
}
if (this.pendingSeconds >= 15) {
this.heartbeat()
}
}, 1000)
},
stopHeartbeatTimer() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
},
isEffectiveLearning() {
if (document.hidden) return false
if (Date.now() - this.lastActiveAt > this.inactiveLimit) return false
if (["video", "audio"].includes(this.selectedResource.resourceType)) {
const player = this.$refs.mediaPlayer
return !!player && !player.paused && !player.ended
}
return true
},
markActive() {
this.lastActiveAt = Date.now()
},
applyRecordState(data) {
this.recordProgress = Number(data.progressPercent || 0)
this.recordStudyTime = data.studyTimeText || this.formatSeconds(Number(data.studySeconds || 0))
if (this.selectedResource.id && data.lastPositionSeconds !== undefined) {
this.$set(this.selectedResource, "lastPositionSeconds", Number(data.lastPositionSeconds || 0))
}
},
getMediaPosition() {
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return 0
const player = this.$refs.mediaPlayer
return player ? Math.floor(player.currentTime || 0) : Number(this.selectedResource.lastPositionSeconds || 0)
},
formatSeconds(seconds) {
const hour = Math.floor(seconds / 3600)
const minute = Math.floor(seconds % 3600 / 60)
const second = Math.floor(seconds % 60)
if (hour > 0) return hour + "小时" + minute + "分" + second + "秒"
if (minute > 0) return minute + "分" + second + "秒"
return second + "秒"
},
splitFlags(flags) {
return flags ? flags.split(",").filter(Boolean) : []
},
getRecommendName(code) {
const item = this.recommendOptions.find(v => v.code === code)
return item ? item.name : code
},
openFile() {
if (this.fileUrl) window.open(this.fileUrl)
},
collapseAll() {
const nodesMap = this.$refs.studyTree && this.$refs.studyTree.store.nodesMap
Object.keys(nodesMap || {}).forEach(key => {
nodesMap[key].expanded = false
})
},
async goBack() {
this.saveServerPosition(this.getMediaPosition())
await this.finishStudy(false, { silent: true })
window.location.href = this.apiBase
}
},
async created() {
this.courseId = this.getQuery("id")
window.addEventListener("mousemove", this.markActive)
window.addEventListener("keydown", this.markActive)
window.addEventListener("click", this.markActive)
window.addEventListener("scroll", this.markActive, true)
window.addEventListener("beforeunload", () => this.finishStudy(true))
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
this.saveServerPosition(this.getMediaPosition(), true)
}
})
await this.loadRecommendOptions()
await this.loadCourse()
await this.loadTree()
},
beforeDestroy() {
this.finishStudy(true)
this.stopHeartbeatTimer()
window.removeEventListener("mousemove", this.markActive)
window.removeEventListener("keydown", this.markActive)
window.removeEventListener("click", this.markActive)
window.removeEventListener("scroll", this.markActive, true)
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,266 @@
<!--#
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">
<div v-if="!canPreview" class="empty-resource">该资料不允许预览</div>
<video v-else-if="selectedResource.resourceType === 'video'" ref="mediaPlayer" class="media-player" :src="mediaUrl" controls :controlslist="canDownload ? null : '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" preload="metadata" controls :controlslist="canDownload ? null : '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">该资料暂不支持在线预览</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="canPreview && (isMediaResource || selectedResource.resourceType === 'image' || canInlinePreview(selectedResource))" size="mini" icon="el-icon-view" @click="openFile">预览资料</el-button>
<el-button v-if="canDownload" size="mini" icon="el-icon-download" @click="downloadFile">下载资料</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 && canPreview && !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",
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: {
canPreview() { return !!this.selectedResource.id && this.selectedResource.allowPreview !== false },
canDownload() { return !!this.selectedResource.id && this.selectedResource.allowDownload === true },
fileUrl() {
if (!this.canPreview) return ""
const id = this.getFileId(this.selectedResource.fileData)
return id ? "/platform/sys/file/preview?id=" + encodeURIComponent(id) : this.getFileUrl(this.selectedResource.fileData)
},
downloadUrl() {
if (!this.canDownload) return ""
const id = this.getFileId(this.selectedResource.fileData)
return id ? "/platform/sys/file/download?id=" + encodeURIComponent(id) : this.getFileUrl(this.selectedResource.fileData)
},
mediaUrl() {
if (!this.canPreview) return ""
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() { if (!this.canPreview) return ""; const id = this.getFileId(this.selectedResource.fileData); const url = id ? this.apiBase + "/pdfPreview?id=" + encodeURIComponent(id) : this.fileUrl; return this.canDownload ? url : url + "#toolbar=0" },
resourceTypeText() { const names = {video: "视频资料", audio: "音频资料", image: "图片资料", pdf: "文档资料", word: "文档资料", ppt: "演示资料"}; return names[this.selectedResource.resourceType] || "课程资料" },
resourceList() { return this.collectResources(this.treeData) },
learnableResources() { return this.resourceList.filter(item => item.allowPreview !== false) },
resourceCount() { return this.learnableResources.length },
completedCount() { return this.learnableResources.filter((item) => item.completeStatus === "completed" || Number(item.progressPercent || 0) >= 100).length },
courseProgress() { return Number(this.course.progressPercent || 0) },
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) 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) { if (data && data.courseProgress) Object.keys(data.courseProgress).forEach(key => this.$set(this.course, key, data.courseProgress[key])); if (!data || data.resourceId !== this.selectedResource.id) return; 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.canPreview) return; const url = this.canInlinePreview(this.selectedResource) ? this.inlinePreviewUrl : this.mediaUrl; if (url) window.open(url) },
downloadFile() { if (this.canDownload && this.downloadUrl) window.open(this.downloadUrl) },
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>
<!--#
}
#-->
@@ -0,0 +1,412 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<guava ref="guava">
<el-card shadow="never">
<search :is-search-button="false">
<search-item label="课程名称">
<el-input v-model="pageForm.courseName" clearable placeholder="请输入课程名称" style="width: 100%" @keyup.enter.native="doSearch"></el-input>
</search-item>
<search-item label="课程类型">
<el-select v-model="pageForm.courseTypeId" clearable filterable placeholder="请选择课程类型" style="width: 100%">
<el-option v-for="item in courseTypeOptions" :key="item.id" :label="item.typeName" :value="item.id"></el-option>
</el-select>
</search-item>
<search-item label="开课时间">
<el-date-picker
v-model="pageForm.courseTime"
type="daterange"
start-placeholder="开始时间"
end-placeholder="结束时间"
range-separator="至"
value-format="yyyy-MM-dd HH:mm:ss"
style="width: 100%">
</el-date-picker>
</search-item>
<search-item label="授课讲师">
<el-input v-model="pageForm.lecturerName" clearable placeholder="请输入授课讲师" style="width: 100%" @keyup.enter.native="doSearch"></el-input>
</search-item>
<search-item label="课程状态">
<el-select v-model="pageForm.status" clearable placeholder="请选择课程状态" style="width: 100%">
<el-option v-for="item in statusOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</search-item>
<search-item label="推荐标识">
<el-select v-model="pageForm.recommendFlag" clearable placeholder="请选择推荐标识" style="width: 100%">
<el-option v-for="item in recommendOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</search-item>
<div class="search-query">
<el-button size="medium" type="primary" @click="doSearch">搜索</el-button>
<el-button size="medium" @click="resetSearch">重置</el-button>
</div>
</search>
</el-card>
<el-card class="mt10" shadow="never">
<table-tool :app="this" label="课程列表">
<el-button @click="openAdd" size="medium" type="primary">
<i class="el-icon-plus"></i>
新增课程
</el-button>
</table-tool>
<el-table
v-loading="tableLoading"
:data="tableData"
:size="tableSize"
border
@sort-change="pageOrder">
<el-table-column :index="indexMethod" align="center" header-align="center" label="序号" type="index" width="80"></el-table-column>
<el-table-column align="center" header-align="center" label="课程名称" prop="courseName" sortable="custom" show-overflow-tooltip min-width="180"></el-table-column>
<el-table-column align="center" header-align="center" label="课程类型" prop="courseTypeName" sortable="custom" show-overflow-tooltip width="140"></el-table-column>
<el-table-column align="center" header-align="center" label="授课讲师" prop="lecturerName" sortable="custom" show-overflow-tooltip width="140"></el-table-column>
<el-table-column align="center" header-align="center" label="开课时间" prop="startTime" sortable="custom" width="230">
<template slot-scope="{row}">
<span v-if="row.openType === 'long_term'">长期开放</span>
<span v-else>{{formatTime(row.startTime)}} 至 {{formatTime(row.endTime)}}</span>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="课程状态" prop="status" sortable="custom" width="120">
<template slot-scope="{row}">
<el-tag :type="getStatusType(row.status)" size="mini">{{getStatusName(row.status)}}</el-tag>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="推荐标识" prop="recommendFlags" sortable="custom" show-overflow-tooltip min-width="140">
<template slot-scope="{row}">
<el-tag
v-for="code in splitValue(row.recommendFlags)"
:key="code"
:type="getRecommendType(code)"
size="mini"
style="margin: 2px 3px">
{{getDictName(code, recommendOptions)}}
</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}">
<el-button @click="openEdit(row)" size="mini" type="primary">编辑</el-button>
<el-button @click="goChapterContent(row)" size="mini" type="success">章节内容</el-button>
<el-button @click="doDelete(row)" size="mini" type="danger">删除</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</guava>
<el-dialog :title="title" :visible.sync="dialogVisible" :close-on-click-modal="false" width="72%">
<el-form :model="formData" ref="courseForm" :rules="formRules" label-width="110px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="课程名称" prop="courseName">
<el-input v-model="formData.courseName" maxlength="100" placeholder="请输入课程名称"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="课程类型" prop="courseTypeId">
<el-select v-model="formData.courseTypeId" filterable placeholder="请选择课程类型" style="width: 100%">
<el-option v-for="item in courseTypeOptions" :key="item.id" :label="item.typeName" :value="item.id"></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="授课讲师" prop="lecturerName">
<el-input v-model="formData.lecturerName" maxlength="100" placeholder="请输入教师姓名"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="课程状态" prop="status">
<el-select v-model="formData.status" placeholder="请选择课程状态" style="width: 100%">
<el-option v-for="item in statusOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="开课方式" prop="openType">
<el-radio-group v-model="formData.openType">
<el-radio label="fixed">固定开课时间</el-radio>
<el-radio label="long_term">长期开放</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="开课时间" prop="openTime" v-if="formData.openType !== 'long_term'">
<el-date-picker
v-model="formData.openTime"
type="datetimerange"
start-placeholder="开始时间"
end-placeholder="结束时间"
range-separator="至"
value-format="yyyy-MM-dd HH:mm:ss"
style="width: 100%">
</el-date-picker>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="推荐标识" prop="recommendFlagList">
<el-select v-model="formData.recommendFlagList" multiple clearable placeholder="请选择推荐标识" style="width: 100%">
<el-option v-for="item in recommendOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</el-form-item>
</el-col>
<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>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="适合人群" prop="suitablePeople">
<el-input v-model="formData.suitablePeople" type="textarea" maxlength="500" :rows="3" show-word-limit placeholder="请输入适合人群"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="学习目标" prop="learningGoal">
<el-input v-model="formData.learningGoal" type="textarea" maxlength="500" :rows="3" show-word-limit placeholder="请输入学习目标"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="课程封面" prop="cover">
<file-upload
:upload_number="1"
:upload_size="5242880"
:value.sync="formData.cover"
accept=".jpg,.jpeg,.png"
complete_result
upload_mode="image"
upload_result_category="interval">
</file-upload>
<div class="el-upload__tip">只能上传1个文件;只能上传.jpg,.jpeg,.png文件;单个文件大小不能超过5M。</div>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :disabled="subDis" @click="operation">确定</el-button>
</span>
</el-dialog>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
mixins: [initTableMixins],
data() {
return {
title: "",
dialogVisible: false,
subDis: false,
courseTypeOptions: [],
recommendOptions: [],
statusOptions: [
{ name: "草稿", code: "draft" },
{ name: "待发布", code: "pending" },
{ name: "已发布", code: "published" },
{ name: "已下架", code: "offline" }
],
formData: {},
formRules: {
courseName: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
courseTypeId: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
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"] }]
}
}
},
methods: {
buildPageParams() {
const params = Object.assign({}, this.pageForm)
params.startTime = params.courseTime && params.courseTime.length ? params.courseTime[0] : ""
params.endTime = params.courseTime && params.courseTime.length ? params.courseTime[1] : ""
return params
},
pageData(data = null) {
const address = this.pageDataUrl ? this.pageDataUrl : loc() + "/pageData"
this.tableLoading = true
this.$axios.post(address, data ? data : this.buildPageParams()).then((res) => {
if (res.code === 0) {
this.tableData = res.data.list
this.pageForm.totalCount = res.data.totalCount
}
}).finally(() => {
this.tableLoading = false
})
},
resetSearch() {
this.pageForm.courseName = ""
this.pageForm.courseTypeId = ""
this.pageForm.courseTime = []
this.pageForm.lecturerName = ""
this.pageForm.status = ""
this.pageForm.recommendFlag = ""
this.doSearch()
},
openAdd() {
this.title = "新增课程"
this.formData = {
courseName: "",
courseTypeId: "",
lecturerName: "",
courseIntro: "",
suitablePeople: "",
learningGoal: "",
openType: "fixed",
openTime: [],
status: "draft",
recommendFlagList: [],
sortNum: 0,
viewCount: 0,
cover: ""
}
this.dialogVisible = true
this.$nextTick(() => {
this.$refs.courseForm && this.$refs.courseForm.clearValidate()
})
},
openEdit(row) {
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),
viewCount: row.viewCount == null ? 0 : row.viewCount
})
this.dialogVisible = true
this.$nextTick(() => {
this.$refs.courseForm && this.$refs.courseForm.clearValidate()
})
},
goChapterContent(row) {
const url = "/platform/learning/chapter/content?courseId=" + encodeURIComponent(row.id || "")
if (window.commonUtil && commonUtil.pjaxPush) {
commonUtil.pjaxPush(url)
} else {
window.location.href = url
}
},
operation() {
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("请选择开课时间")
return
}
const data = Object.assign({}, this.formData)
data.startTime = data.openType === "long_term" ? "" : data.openTime[0]
data.endTime = data.openType === "long_term" ? "" : data.openTime[1]
data.recommendFlags = (data.recommendFlagList || []).join(",")
delete data.openTime
delete data.recommendFlagList
const method = data.id ? "/doEdit" : "/doAdd"
this.subDis = true
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) {
this.$confirm("确定要删除该课程吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).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.courseTypeOptions = 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) : []
},
getStatusName(code) {
const item = this.statusOptions.find(v => v.code === code)
return item ? item.name : ""
},
getStatusType(code) {
const map = {
draft: "info",
pending: "warning",
published: "success",
offline: "danger"
}
return map[code] || ""
},
getDictName(code, options) {
const item = options.find(v => v.code === code)
return item ? item.name : code
},
getDictNames(value, options) {
const values = this.splitValue(value)
return values.map(code => {
const item = options.find(v => v.code === code)
return item ? item.name : code
}).join("、")
},
getRecommendType(code) {
const name = this.getDictName(code, this.recommendOptions)
if (name.includes("热门") || name.includes("推荐")) return "warning"
if (name.includes("新")) return "success"
return ""
},
formatTime(time) {
return time ? this.$moment(time).format("YYYY-MM-DD HH:mm:ss") : ""
}
},
created() {
this.pageForm.courseTime = []
Promise.all([this.getCourseTypes(), this.getLearningDict("推荐标识")]).then((values) => {
this.recommendOptions = values[1]
this.pageData()
})
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,189 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<guava ref="guava">
<el-card shadow="never">
<search :is-search-button="false">
<search-item label="课程类型名称">
<el-input
v-model="pageForm.typeName"
clearable
placeholder="请输入课程类型名称"
style="width: 100%"
@keyup.enter.native="doSearch">
</el-input>
</search-item>
<div class="search-query">
<el-button size="medium" type="primary" @click="doSearch">搜索</el-button>
<el-button size="medium" @click="resetSearch">重置</el-button>
</div>
</search>
</el-card>
<el-card class="mt10" shadow="never">
<table-tool :app="this" label="课程类型列表">
<el-button @click="openAdd" size="medium" type="primary">
<i class="el-icon-plus"></i>
新建
</el-button>
</table-tool>
<el-table
v-loading="tableLoading"
:data="tableData"
:size="tableSize"
border>
<el-table-column :index="indexMethod" align="center" header-align="center" label="序号" type="index" width="80"></el-table-column>
<el-table-column align="center" header-align="center" label="课程类型名称" prop="typeName" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="备注" prop="remark" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="排序编号" prop="sortNum" width="140"></el-table-column>
<el-table-column align="center" header-align="center" label="是否启用" prop="enabled" width="140">
<template slot-scope="{row}">
<el-switch
v-model="row.enabled"
active-color="#13ce66"
inactive-color="#ff4949"
@change="switchChange(row)">
</el-switch>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="操作" width="180">
<template slot-scope="{row}">
<el-button @click="openEdit(row)" size="mini" type="primary">编辑</el-button>
<el-button @click="doDelete(row)" size="mini" type="danger">删除</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</guava>
<el-dialog :title="title" :visible.sync="dialogVisible" :close-on-click-modal="false" width="40%">
<el-form :model="formData" ref="courseTypeForm" :rules="formRules" label-width="120px">
<el-form-item label="课程类型名称" prop="typeName">
<el-input v-model="formData.typeName" maxlength="100" placeholder="请输入课程类型名称"></el-input>
</el-form-item>
<el-form-item label="排序编号" prop="sortNum">
<el-input-number
v-model="formData.sortNum"
:controls="false"
:min="0"
:precision="0"
placeholder="请输入排序编号"
style="width: 100%">
</el-input-number>
</el-form-item>
<el-form-item label="是否启用" prop="enabled">
<el-switch v-model="formData.enabled" active-color="#13ce66" inactive-color="#ff4949"></el-switch>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
maxlength="500"
show-word-limit
:rows="4"
placeholder="请输入备注">
</el-input>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :disabled="subDis" @click="operation">确定</el-button>
</span>
</el-dialog>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
mixins: [initTableMixins],
data() {
return {
title: "",
dialogVisible: false,
subDis: false,
formData: {},
formRules: {
typeName: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
sortNum: [{ required: true, message: "必填", trigger: ["blur", "change"] }]
}
}
},
methods: {
resetSearch() {
this.pageForm.typeName = ""
this.doSearch()
},
openAdd() {
this.title = "新建课程类型"
this.formData = {
typeName: "",
sortNum: 0,
enabled: true,
remark: ""
}
this.dialogVisible = true
this.$nextTick(() => {
this.$refs.courseTypeForm && this.$refs.courseTypeForm.clearValidate()
})
},
openEdit(row) {
this.title = "编辑课程类型"
this.formData = Object.assign({}, row)
this.dialogVisible = true
this.$nextTick(() => {
this.$refs.courseTypeForm && this.$refs.courseTypeForm.clearValidate()
})
},
operation() {
const method = this.formData.id ? "/doEdit" : "/doAdd"
this.$refs.courseTypeForm.validate(async (valid) => {
if (!valid) return
this.subDis = true
const resp = await this.$axios.post(loc() + method, this.formData)
this.subDis = false
if (resp.code === 0) {
this.dialogVisible = false
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.warning(resp.msg)
}
})
},
async switchChange(row) {
const resp = await this.$axios.post(loc() + "/doEdit", row)
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.error(resp.msg)
row.enabled = !row.enabled
}
},
doDelete(row) {
this.$confirm("确定要删除该课程类型吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(async () => {
const resp = await this.$axios.post(loc() + "/doDelete", { id: row.id })
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.error(resp.msg)
}
})
}
},
created() {
this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,158 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<guava ref="guava">
<el-card shadow="never">
<search :is-search-button="false">
<search-item label="课程名称">
<el-select v-model="pageForm.courseId" clearable filterable placeholder="请选择课程" style="width: 100%">
<el-option v-for="item in courseOptions" :key="item.id" :label="item.courseName" :value="item.id"></el-option>
</el-select>
</search-item>
<search-item label="是否完成">
<el-select v-model="pageForm.completeStatus" clearable placeholder="请选择完成状态" style="width: 100%">
<el-option v-for="item in statusOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</search-item>
<div class="search-query">
<el-button size="medium" type="primary" @click="doSearch">搜索</el-button>
<el-button size="medium" @click="resetSearch">重置</el-button>
</div>
</search>
</el-card>
<el-card class="mt10" shadow="never">
<table-tool :app="this" label="学习列表"></table-tool>
<el-table
v-loading="tableLoading"
:data="tableData"
:size="tableSize"
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="loginName" sortable="custom" show-overflow-tooltip width="120"></el-table-column>
<el-table-column align="center" header-align="center" label="姓名" prop="userName" sortable="custom" show-overflow-tooltip width="110"></el-table-column>
<el-table-column align="center" header-align="center" label="所属分工会" prop="unionName" sortable="custom" show-overflow-tooltip min-width="150"></el-table-column>
<el-table-column align="center" header-align="center" label="课程名称" prop="courseName" sortable="custom" show-overflow-tooltip min-width="180"></el-table-column>
<el-table-column align="center" header-align="center" label="章节名称" prop="outlineName" sortable="custom" show-overflow-tooltip min-width="160"></el-table-column>
<el-table-column align="center" header-align="center" label="学习开始时间" prop="firstStudyTime" sortable="custom" width="170">
<template slot-scope="{row}">{{formatTime(row.firstStudyTime)}}</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="最近学习时间" prop="latestStudyTime" sortable="custom" width="170">
<template slot-scope="{row}">{{formatTime(row.latestStudyTime)}}</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="学习时长" prop="studySeconds" sortable="custom" width="130">
<template slot-scope="{row}">{{row.studyTimeText}}</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="学习进度" prop="progressPercent" sortable="custom" width="180">
<template slot-scope="{row}">
<el-progress :percentage="Number(row.progressPercent || 0)" :stroke-width="8"></el-progress>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="是否完成" prop="completeStatus" sortable="custom" width="110">
<template slot-scope="{row}">
<el-tag :type="statusType(row.completeStatus)" size="mini">{{statusName(row.completeStatus)}}</el-tag>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="操作" width="100">
<template slot-scope="{row}">
<el-button size="mini" type="danger" icon="el-icon-delete" @click="deleteRecord(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</guava>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
mixins: [initTableMixins],
data() {
return {
apiBase: "/platform/learning/study/record",
courseOptions: [],
statusOptions: [
{ name: "未开始", code: "not_started", type: "info" },
{ name: "学习中", code: "studying", type: "warning" },
{ name: "已完成", code: "completed", type: "success" }
]
}
},
methods: {
pageData() {
this.tableLoading = true
this.$axios.post(this.apiBase + "/pageData", this.pageForm).then(resp => {
this.tableLoading = false
if (resp.code === 0) {
this.tableData = resp.data.list || []
this.pageForm.totalCount = resp.data.totalCount
} else {
this.$message.warning(resp.msg)
}
}).catch(() => {
this.tableLoading = false
})
},
pageOrder(column) {
this.pageForm.pageOrderName = column.prop
this.pageForm.pageOrderBy = column.order
this.pageData()
},
doSearch() {
this.pageForm.pageNumber = 1
this.pageData()
},
resetSearch() {
this.pageForm.courseId = ""
this.pageForm.completeStatus = ""
this.pageForm.pageNumber = 1
this.pageData()
},
async loadOptions() {
const courseResp = await this.$axios.post(this.apiBase + "/courseOptions")
if (courseResp.code === 0) {
this.courseOptions = courseResp.data || []
}
},
statusName(code) {
const item = this.statusOptions.find(v => v.code === code)
return item ? item.name : "未开始"
},
statusType(code) {
const item = this.statusOptions.find(v => v.code === code)
return item ? item.type : "info"
},
formatTime(value) {
return value ? this.$moment(value).format("YYYY-MM-DD HH:mm:ss") : ""
},
deleteRecord(row) {
this.$confirm("确定要删除该学习记录吗?删除后该章节的学习时段也会一并清理。", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(async () => {
const resp = await this.$axios.post(this.apiBase + "/delete", { id: row.id })
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.warning(resp.msg)
}
})
}
},
async created() {
this.$set(this.pageForm, "courseId", "")
this.$set(this.pageForm, "completeStatus", "")
await this.loadOptions()
this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,222 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<guava ref="guava">
<el-card shadow="never">
<div class="search">
<search-item label="课程名称">
<el-select
v-model="pageForm.courseId"
clearable
filterable
remote
:remote-method="queryCourseOptions"
placeholder="请选择课程"
style="width: 100%">
<el-option v-for="item in courseOptions" :key="item.id" :label="item.courseName" :value="item.id"></el-option>
</el-select>
</search-item>
<search-item label="所属分工会" v-if="unionOptions.length">
<el-select v-model="pageForm.unionId" clearable filterable placeholder="请选择所属分工会" style="width: 100%">
<el-option v-for="item in unionOptions" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
</search-item>
<search-item label="工号" v-if="activeTab === 'records'">
<el-input v-model="pageForm.loginName" clearable placeholder="请输入工号" @keyup.enter.native="doSearch"></el-input>
</search-item>
<search-item label="姓名" v-if="activeTab === 'records'">
<el-input v-model="pageForm.userName" clearable placeholder="请输入姓名" @keyup.enter.native="doSearch"></el-input>
</search-item>
<search-item label="是否完成" v-if="activeTab === 'records'">
<el-select v-model="pageForm.completeStatus" clearable placeholder="请选择完成状态" style="width: 100%">
<el-option label="已完成" value="completed"></el-option>
<el-option label="未完成" value="incomplete"></el-option>
</el-select>
</search-item>
<div class="search-query">
<el-button size="medium" type="primary" @click="doSearch">搜索</el-button>
<el-button size="medium" @click="resetSearch">重置</el-button>
</div>
</div>
</el-card>
<el-card class="mt10" shadow="never">
<el-tabs v-model="activeTab" @tab-click="tabChange" style="margin-bottom: 12px">
<el-tab-pane label="课程统计" name="course"></el-tab-pane>
<el-tab-pane label="分工会统计" name="union"></el-tab-pane>
<el-tab-pane label="学习列表" name="records"></el-tab-pane>
</el-tabs>
<table-tool :app="this" :label="activeTab === 'course' ? '课程统计' : activeTab === 'union' ? '分工会统计' : '学习列表'"></table-tool>
<el-table
v-if="activeTab === 'course'"
key="course-statistics"
v-loading="tableLoading"
:data="tableData"
:size="tableSize"
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" ></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="completeRate" sortable="custom" width="130"></el-table-column>
<el-table-column align="center" header-align="center" label="平均学习时长" prop="avgStudySeconds" sortable="custom" width="170">
<template slot-scope="{row}">{{row.avgStudyTimeText}}</template>
</el-table-column>
</el-table>
<el-table
v-else-if="activeTab === 'union'"
key="union-statistics"
v-loading="tableLoading"
:data="tableData"
:size="tableSize"
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"></el-table-column>
<el-table-column align="center" header-align="center" label="分工会名称" prop="unionName" sortable="custom" show-overflow-tooltip min-width="170"></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="studySeconds" sortable="custom" width="160">
<template slot-scope="{row}">{{row.studyTimeText}}</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="完成率" prop="completeRate" sortable="custom" width="130"></el-table-column>
</el-table>
<el-table v-else key="learning-records" v-loading="tableLoading" :data="tableData" :size="tableSize" border @sort-change="pageOrder">
<el-table-column :index="indexMethod" align="center" label="序号" type="index" width="70"></el-table-column>
<el-table-column align="center" label="工号" prop="loginName" sortable="custom" show-overflow-tooltip width="120"></el-table-column>
<el-table-column align="center" label="姓名" prop="userName" sortable="custom" show-overflow-tooltip width="110"></el-table-column>
<el-table-column align="center" label="所属分工会" prop="unionName" sortable="custom" show-overflow-tooltip min-width="150"></el-table-column>
<el-table-column align="center" label="课程名称" prop="courseName" sortable="custom" show-overflow-tooltip min-width="180"></el-table-column>
<el-table-column align="center" label="章节名称" prop="outlineName" sortable="custom" show-overflow-tooltip min-width="160"></el-table-column>
<el-table-column align="center" label="学习开始时间" prop="firstStudyTime" sortable="custom" width="170">
<template slot-scope="{row}">{{formatTime(row.firstStudyTime)}}</template>
</el-table-column>
<el-table-column align="center" label="最近学习时间" prop="latestStudyTime" sortable="custom" width="170">
<template slot-scope="{row}">{{formatTime(row.latestStudyTime)}}</template>
</el-table-column>
<el-table-column align="center" label="学习时长" prop="studySeconds" sortable="custom" width="130">
<template slot-scope="{row}">{{row.studyTimeText}}</template>
</el-table-column>
<el-table-column align="center" label="学习进度" prop="progressPercent" sortable="custom" width="180">
<template slot-scope="{row}"><el-progress :percentage="Number(row.progressPercent || 0)" :stroke-width="8"></el-progress></template>
</el-table-column>
<el-table-column align="center" label="是否完成" prop="completeStatus" sortable="custom" width="110">
<template slot-scope="{row}"><el-tag :type="row.completeStatus === 'completed' ? 'success' : 'warning'" size="mini">{{row.completeStatus === 'completed' ? '已完成' : '未完成'}}</el-tag></template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</guava>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
mixins: [initTableMixins],
data() {
return {
apiBase: "/platform/learning/statistics",
activeTab: "course",
courseOptions: [],
unionOptions: [],
requestSeq: 0
}
},
methods: {
pageData() {
this.tableLoading = true
const requestSeq = ++this.requestSeq
const urls = { course: "/coursePageData", union: "/unionPageData", records: "/recordPageData" }
this.$axios.post(this.apiBase + urls[this.activeTab], this.pageForm).then(resp => {
if (requestSeq !== this.requestSeq) return
if (resp.code === 0) {
this.tableData = resp.data.list || []
this.pageForm.totalCount = resp.data.totalCount
} else {
this.$message.warning(resp.msg)
}
}).catch(() => {
if (requestSeq === this.requestSeq) this.$message.warning("查询失败")
}).finally(() => {
if (requestSeq === this.requestSeq) this.tableLoading = false
})
},
formatTime(value) {
return value ? this.$moment(value).format("YYYY-MM-DD HH:mm:ss") : ""
},
pageOrder(column) {
this.pageForm.pageOrderName = column.prop
this.pageForm.pageOrderBy = column.order
this.pageData()
},
async doSearch() {
this.pageForm.pageNumber = 1
await this.ensureUnionDefaultCourse()
this.pageData()
},
async resetSearch() {
this.pageForm.courseId = ""
this.pageForm.unionId = ""
this.pageForm.loginName = ""
this.pageForm.userName = ""
this.pageForm.completeStatus = ""
this.pageForm.pageOrderName = ""
this.pageForm.pageOrderBy = ""
this.pageForm.pageNumber = 1
await this.queryCourseOptions("")
await this.ensureUnionDefaultCourse()
this.pageData()
},
async tabChange() {
this.requestSeq += 1
this.tableData = []
this.pageForm.totalCount = 0
this.pageForm.pageNumber = 1
this.pageForm.pageOrderName = ""
this.pageForm.pageOrderBy = ""
await this.ensureUnionDefaultCourse()
this.pageData()
},
async queryCourseOptions(query) {
const resp = await this.$axios.post(this.apiBase + "/courseOptions", { courseName: query || "" })
if (resp.code === 0) {
this.courseOptions = resp.data || []
}
},
async ensureUnionDefaultCourse() {
if (this.activeTab !== "union" || this.pageForm.courseId) {
return
}
if (!this.courseOptions.length) {
await this.queryCourseOptions("")
}
if (this.courseOptions.length) {
this.pageForm.courseId = this.courseOptions[0].id
}
},
async loadUnionOptions() {
const resp = await this.$axios.post(this.apiBase + "/unionOptions")
if (resp.code === 0) {
this.unionOptions = resp.data || []
}
}
},
async created() {
this.$set(this.pageForm, "courseId", "")
this.$set(this.pageForm, "unionId", "")
this.$set(this.pageForm, "loginName", "")
this.$set(this.pageForm, "userName", "")
this.$set(this.pageForm, "completeStatus", "")
await this.queryCourseOptions("")
await this.loadUnionOptions()
this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,504 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<style>
.learning-course-page {
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
background: #f4f7fb;
padding-bottom: env(safe-area-inset-bottom);
box-sizing: border-box;
}
.learning-course-page .van-nav-bar {
background: #f4f7fb;
}
.learning-filter-card {
flex-shrink: 0;
margin: 10px 10px 0;
padding: 4px 10px 8px;
border-radius: 10px;
background: #ffffff;
box-shadow: 0 3px 12px rgba(31, 72, 122, .08);
}
.learning-list-scroll {
min-height: 0;
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
box-sizing: border-box;
}
.learning-filter-group {
overflow: hidden;
border-bottom: 1px solid #edf1f7;
}
.learning-filter-group + .learning-filter-group {
margin-top: 0;
}
.learning-filter-group:last-child {
border-bottom: 0;
}
.learning-filter-category {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 40px;
padding: 0 2px;
color: #26354c;
box-sizing: border-box;
}
.learning-filter-category-main {
display: flex;
align-items: center;
}
.learning-filter-category-title {
margin-left: 6px;
font-size: 14px;
font-weight: 800;
line-height: 1.2;
}
.learning-filter-category-main .van-icon {
color: #1677ff;
font-size: 17px;
}
.learning-filter-options {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
gap: 6px;
padding: 4px 1px 10px;
scrollbar-width: none;
}
.learning-filter-options::-webkit-scrollbar {
display: none;
}
.learning-filter-item {
flex: 0 0 auto;
min-width: 62px;
height: 26px;
padding: 0 14px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #e8f2ff;
border-radius: 999px;
background: #f0f7ff;
text-align: center;
color: #7c899c;
font-size: 12px;
box-sizing: border-box;
}
.learning-filter-item.active {
color: #ffffff;
font-weight: 400;
background: #1989fa;
border-color: #1989fa;
}
.learning-filter-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.learning-section-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 16px 10px;
}
.learning-section-title {
color: #202733;
font-size: 20px;
font-weight: 800;
line-height: 1.2;
}
.learning-section-total {
color: #8b96a6;
font-size: 13px;
}
.learning-course-list {
padding: 0 12px 18px;
}
.learning-course-card {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 104px;
margin-bottom: 12px;
padding: 10px;
border-radius: 8px;
background: #ffffff;
box-sizing: border-box;
}
.learning-course-cover {
position: relative;
flex: none;
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: 4px;
background: #e6edf6;
}
.learning-course-cover img {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.learning-course-cover-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 8px;
color: #ffffff;
font-size: 13px;
text-align: center;
box-sizing: border-box;
background: linear-gradient(135deg, #3a7bd5, #8b5cf6);
}
.learning-course-info {
flex: 1;
min-width: 0;
}
.learning-course-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 7px;
min-width: 0;
}
.learning-course-title-text {
min-width: 0;
color: #202733;
font-size: 17px;
font-weight: 700;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.learning-course-badge {
flex-shrink: 0;
max-width: 72px;
padding: 1px 6px;
border: 1px solid #ffd1a8;
border-radius: 4px;
color: #f28a35;
font-size: 11px;
font-weight: 500;
line-height: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: #fff7ee;
}
.learning-course-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 9px;
}
.learning-course-intro {
margin-top: 8px;
color: #7a8798;
font-size: 12px;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.learning-empty {
padding: 22px 0 36px;
}
@media (max-width: 360px) {
.learning-filter-card { margin-left: 8px; margin-right: 8px; padding-left: 8px; padding-right: 8px; }
}
</style>
<div id="app" class="learning-course-page" v-cloak>
<van-nav-bar title="学习教育" left-text="返回" left-arrow @click-left="historyBack" fixed placeholder></van-nav-bar>
<div class="learning-filter-card">
<div class="learning-filter-group" v-for="group in filterGroups" :key="group.key">
<div class="learning-filter-category">
<div class="learning-filter-category-main">
<van-icon :name="group.icon"></van-icon>
<div class="learning-filter-category-title">{{ group.name }}</div>
</div>
</div>
<div class="learning-filter-options">
<div
v-for="item in group.items"
:key="item.key"
class="learning-filter-item"
:class="{active: isFilterActive(item)}"
@click="selectFilter(item)">
<div class="learning-filter-name">{{ item.name }}</div>
</div>
</div>
</div>
</div>
<div class="learning-list-scroll">
<div class="learning-section-head">
<div class="learning-section-title">我的课堂</div>
<div class="learning-section-total">共 {{ pageForm.totalCount || 0 }} 门</div>
</div>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model="loading"
:finished="finished"
:immediate-check="false"
:finished-text="courseList.length ? '没有更多了' : ''"
@load="onLoad">
<div class="learning-course-list">
<div
class="learning-course-card"
v-for="course in courseList"
:key="course.id"
@click="openCourse(course)">
<div class="learning-course-cover">
<img v-if="getCoverUrl(course.cover)" :src="getCoverUrl(course.cover)" :alt="course.courseName">
<div v-else class="learning-course-cover-empty">{{ course.courseTypeName || '课程' }}</div>
</div>
<div class="learning-course-info">
<div class="learning-course-title">
<div class="learning-course-title-text">{{ course.courseName || '未命名课程' }}</div>
<div v-if="firstRecommendName(course.recommendFlags)" class="learning-course-badge">{{ firstRecommendName(course.recommendFlags) }}</div>
</div>
<div class="learning-course-meta">
<van-tag v-if="course.courseTypeName" plain type="primary">{{ course.courseTypeName }}</van-tag>
<van-tag v-if="course.lecturerName" plain type="success">{{ course.lecturerName }}</van-tag>
</div>
<div class="learning-course-intro">{{ course.courseIntro || '暂无课程介绍' }}</div>
</div>
</div>
</div>
</van-list>
</van-pull-refresh>
<van-empty v-if="!loading && !courseList.length" class="learning-empty" description="暂无相关课程"></van-empty>
</div>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
store,
data() {
return {
apiBase: "/platform/learning/course/display",
query: {
keyword: "",
courseTypeId: "",
recommendFlag: ""
},
pageForm: {
pageNumber: 1,
pageSize: 10,
totalCount: 0
},
courseList: [],
courseTypeOptions: [],
recommendOptions: [],
loading: false,
finished: false,
refreshing: false,
requestSeq: 0
}
},
computed: {
filterGroups() {
const recommendItems = [{ key: "recommend_all", type: "recommend", value: "", name: "全部" }].concat(this.recommendOptions.map((item) => ({
key: "recommend_" + item.code,
type: "recommend",
value: item.code,
name: item.name
})))
const typeItems = [{ key: "type_all", type: "type", value: "", name: "全部" }].concat(this.courseTypeOptions.map((item) => ({
key: "type_" + item.id,
type: "type",
value: item.id,
name: item.typeName
})))
return [
{
key: "recommend",
name: "推荐标识",
icon: "label-o",
items: recommendItems
},
{
key: "type",
name: "课程类型",
icon: "apps-o",
items: typeItems
}
]
},
listTitle() {
const recommend = this.recommendOptions.find(item => item.code === this.query.recommendFlag)
const type = this.courseTypeOptions.find(item => item.id === this.query.courseTypeId)
if (recommend && type) {
return recommend.name + " · " + type.typeName
}
if (recommend) {
return recommend.name
}
if (type) {
return type.typeName
}
return "我的课堂"
}
},
methods: {
historyBack,
async loadOptions() {
const [typeResp, recommendResp] = await Promise.all([
this.$axios.post(this.apiBase + "/courseTypes"),
this.$axios.post(this.apiBase + "/recommendOptions")
])
if (typeResp.code === 0) {
this.courseTypeOptions = typeResp.data || []
}
if (recommendResp.code === 0) {
this.recommendOptions = recommendResp.data || []
}
},
onRefresh() {
this.finished = false
this.pageForm.pageNumber = 1
this.courseList = []
this.onLoad()
},
onLoad() {
this.fetchCourses()
},
fetchCourses() {
this.loading = true
const requestSeq = ++this.requestSeq
this.$axios.post(this.apiBase + "/pageData", {
keyword: this.query.keyword,
courseTypeId: this.query.courseTypeId,
recommendFlag: this.query.recommendFlag,
pageNumber: this.pageForm.pageNumber,
pageSize: this.pageForm.pageSize
}).then((res) => {
if (requestSeq !== this.requestSeq) return
if (res.code === 0) {
const data = res.data || {}
const list = data.list || []
if (this.refreshing || this.pageForm.pageNumber === 1) {
this.courseList = []
}
this.courseList = this.courseList.concat(list)
this.pageForm.totalCount = data.totalCount || 0
this.pageForm.pageNumber += 1
this.finished = this.courseList.length >= this.pageForm.totalCount
} else {
this.finished = true
this.$toast(res.msg || "查询失败")
}
}).finally(() => {
if (requestSeq === this.requestSeq) {
this.loading = false
this.refreshing = false
}
})
},
doSearch() {
this.pageForm.pageNumber = 1
this.pageForm.totalCount = 0
this.courseList = []
this.finished = false
this.loading = true
this.onLoad()
},
selectFilter(item) {
if (!item) return
if (item.type === "recommend") {
this.query.recommendFlag = item.value || ""
} else if (item.type === "type") {
this.query.courseTypeId = item.value || ""
}
this.doSearch()
},
isFilterActive(item) {
if (!item) return false
if (item.type === "recommend") {
return (this.query.recommendFlag || "") === (item.value || "")
}
if (item.type === "type") {
return (this.query.courseTypeId || "") === (item.value || "")
}
return false
},
getCoverUrl(cover) {
if (!cover) return ""
if (Array.isArray(cover)) {
return cover.length ? (cover[0].url || (cover[0].response && cover[0].response.data) || cover[0].data || "") : ""
}
if (typeof cover === "string") {
const text = cover.trim()
if (!text) return ""
if (text.startsWith("[")) {
try {
const files = JSON.parse(text)
return files.length ? (files[0].url || (files[0].response && files[0].response.data) || files[0].data || "") : ""
} catch (e) {
return ""
}
}
return text
}
return ""
},
openCourse(course) {
if (!course || !course.id) return
pjaxReplace("/platform/learning/course/h5/study?id=" + course.id)
},
splitFlags(flags) {
return flags ? flags.split(",").filter(Boolean) : []
},
getRecommendName(code) {
const item = this.recommendOptions.find(v => v.code === code)
return item ? item.name : code
},
firstRecommendName(flags) {
const codes = this.splitFlags(flags)
return codes.length ? this.getRecommendName(codes[0]) : ""
},
recommendIcon(index) {
const icons = ["fire-o", "star-o", "award-o", "gem-o", "flag-o", "good-job-o"]
return icons[index % icons.length]
},
typeIcon(index) {
const icons = ["video-o", "records-o", "description-o", "bookmark-o", "cluster-o", "desktop-o"]
return icons[index % icons.length]
},
palette(index) {
const colors = ["#28a6df", "#f2b84b", "#f46d5f", "#7b8fda", "#59bbb2", "#2d7db8", "#ee755b", "#f3c256", "#ef9461", "#8ba4dc"]
return colors[index % colors.length]
}
},
async mounted() {
await this.loadOptions()
this.onLoad()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,944 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<style>
.learning-study-page { height: 100vh; height: 100dvh; overflow-y: auto; background: #f5f8fd; color: #152238; -webkit-overflow-scrolling: touch; }
.study-hero { position: relative; min-height: 240px; background: linear-gradient(135deg, #172a49, #427ac2); overflow: hidden; }
.study-hero > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.study-hero::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, rgba(10, 19, 37, .86), rgba(10, 19, 37, .15)), linear-gradient(0deg, rgba(10, 19, 37, .75), transparent); }
.study-back { position: absolute; top: 12px; left: 14px; z-index: 2; width: 34px; height: 34px; border: 0; border-radius: 50%; background: rgba(0, 0, 0, .5); color: #fff; font-size: 24px; display: flex; align-items: center; justify-content: center; }
.study-title-block { position: relative; z-index: 1; padding: 65px 16px 30px; color: #fff; }
.study-badge { display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px; margin-bottom: 9px; border-radius: 12px; background: rgba(154, 91, 0, .72); color: #fff4d8; font-size: 12px; }
.study-title { font-size: 23px; font-weight: 700; line-height: 1.45; overflow-wrap: anywhere; }
.study-subtitle { margin-top: 7px; color: #d4deed; font-size: 13px; line-height: 1.6; }
.study-meta { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 12px; color: #d4deed; font-size: 12px; }
.study-meta-item { display: inline-flex; align-items: center; gap: 5px; }
.study-content { position: relative; z-index: 1; margin-top: -16px; padding: 8px 14px 24px; border-radius: 20px 20px 0 0; background: #f5f8fd; }
.study-navigation { display: flex; padding: 4px; margin-bottom: 12px; border-radius: 12px; background: #fff; }
.study-navigation button { position: relative; flex: 1; padding: 8px 4px; border: 0; border-radius: 9px; background: transparent; color: #667b9a; font-size: 12px; }
.study-navigation .van-icon { display: block; margin-bottom: 4px; font-size: 20px; }
.study-navigation .active { color: #1677ff; background: #f2f7ff; font-weight: 700; }
.study-navigation .active::after { content: ""; position: absolute; bottom: 0; left: calc(50% - 12px); width: 24px; height: 3px; border-radius: 2px; background: #1677ff; }
.study-progress-card, .study-card { padding: 16px; margin-bottom: 14px; border-radius: 16px; background: #fff; }
.study-progress-card { display: flex; align-items: center; gap: 10px; padding: 12px 14px; margin-bottom: 12px; border-radius: 12px; }
.study-progress-main { flex: 1; min-width: 0; }
.study-progress-title { margin-bottom: 4px; font-size: 13px; font-weight: 700; line-height: 1.4; }
.study-progress-line { display: flex; align-items: center; gap: 8px; color: #1677ff; font-size: 16px; font-weight: 700; line-height: 1.2; }
.study-progress-line .van-progress { flex: 1; min-width: 0; overflow: hidden; }
.study-progress-line > span { flex: 0 0 3.5em; white-space: nowrap; text-align: right; font-variant-numeric: tabular-nums; }
.study-progress-note { margin-top: 4px; color: #7a8ca6; font-size: 10px; line-height: 1.4; }
.study-action { flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; gap: 4px; padding: 6px 10px; border: 1px solid #dbe9ff; border-radius: 20px; background: #f6faff; color: #1677ff; font-size: 12px; white-space: nowrap; }
.study-action:disabled { color: #a3afbf; background: #f5f6f8; }
.study-continue { padding: 9px 12px; border: 0; background: #e8f1ff; font-size: 12px; font-weight: 700; line-height: 1.5; }
.study-section-head { display: flex; align-items: center; gap: 8px; margin: 20px 0 14px; }
.study-section-head strong { font-size: 20px; }
.study-section-head small { color: #7a8ca6; font-size: 12px; }
.study-text-button { margin-left: auto; border: 0; padding: 4px 0; background: transparent; color: #7a8ca6; font-size: 12px; white-space: nowrap; }
.study-chapter-head { display: flex; align-items: center; gap: 9px; width: 100%; padding: 0; border: 0; background: transparent; color: #152238; text-align: left; font-size: 16px; font-weight: 700; }
.study-chapter-head > span:nth-child(2) { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.study-chapter-dot { flex-shrink: 0; width: 10px; height: 10px; box-sizing: border-box; border: 2px solid #1677ff; border-radius: 50%; box-shadow: 0 0 0 6px #edf5ff; margin: 0 4px; }
.study-chapter-description { margin: 12px 0 4px 27px; color: #7a8ca6; font-size: 13px; line-height: 1.7; white-space: pre-line; }
.study-resource { display: flex; align-items: center; gap: 10px; padding: 16px 0; border-bottom: 1px solid #edf1f7; }
.study-resource:last-child { padding-bottom: 0; border-bottom: 0; }
.study-resource-icon { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 30px; height: 34px; border-radius: 7px; color: #fff; font-size: 23px; }
.study-resource-icon.video { background: #ffb723; }
.study-resource-icon.audio { background: #33b3a6; }
.study-resource-icon.image { background: #5a94f2; }
.study-resource-icon.word { background: transparent; font-size: 34px; }
.study-resource-icon.doc { background: #ff6868; }
.study-resource-main { flex: 1; min-width: 0; }
.study-resource-title { font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; }
.study-resource-status { margin-top: 5px; color: #7a8ca6; font-size: 11px; line-height: 1.6; }
.study-resource-status .completed { color: #2fc78c; }
.study-download-list { padding: 0 12px; border-radius: 12px; background: #fff; }
.study-download-row { display: flex; align-items: center; gap: 12px; min-height: 72px; padding: 14px 0; box-sizing: border-box; border-bottom: 1px solid #edf1f7; }
.study-download-row:last-child { border-bottom: 0; }
.study-download-icon { flex-shrink: 0; width: 34px; height: 38px; object-fit: contain; }
.study-download-info { flex: 1; min-width: 0; }
.study-download-name { color: #26364c; font-size: 14px; font-weight: 600; line-height: 1.5; overflow-wrap: anywhere; }
.study-download-meta { display: flex; flex-wrap: wrap; gap: 4px 8px; margin-top: 4px; color: #8b9ab0; font-size: 12px; line-height: 1.5; }
.study-download-meta:empty { display: none; }
.study-download-button { flex-shrink: 0; min-width: 54px; padding: 5px 12px; border: 1px solid #cce2ff; border-radius: 18px; background: #f5faff; color: #1989fa; font-size: 12px; line-height: 1.5; }
.study-catalog-card { padding: 14px 12px; }
.study-catalog-card .study-chapter-head { gap: 10px; font-size: 15px; line-height: 1.5; }
.study-catalog-card .study-chapter-head > .van-icon { color: #7a8ca6; font-size: 14px; }
.study-chapter-body { position: relative; padding-left: 26px; }
.study-chapter-body::before { content: ""; position: absolute; top: 5px; bottom: 24px; left: 9px; width: 1px; transform: translateX(-50%); background: #e8eef7; }
.study-catalog-card .study-chapter-description { margin: 8px 0 4px; color: #8293ad; font-size: 12px; line-height: 1.6; }
.study-catalog-card .study-resource { position: relative; gap: 9px; padding: 14px 0; }
.study-catalog-card .study-resource::before { content: ""; position: absolute; top: 50%; left: -17px; width: 8px; height: 8px; box-sizing: border-box; transform: translate(-50%, -50%); border: 2px solid #96aaca; border-radius: 50%; background: #fff; }
.study-catalog-card .study-resource:last-child { padding-bottom: 10px; }
.study-catalog-card .study-resource-title { font-size: 13px; font-weight: 600; line-height: 1.5; }
.study-catalog-card .study-resource-status { margin-top: 3px; font-size: 10px; }
.study-catalog-card .study-resource-icon { width: 28px; height: 32px; font-size: 21px; }
.study-catalog-card .study-resource-icon.word, .study-catalog-card .study-resource-icon.pdf { background: transparent; font-size: 30px; }
.study-catalog-card .study-action { padding: 5px 9px; font-size: 11px; }
.study-catalog-card .completed .van-icon { margin-right: 3px; vertical-align: -1px; }
.study-intro { padding: 14px; margin-bottom: 10px; border-radius: 12px; background: #fff; color: #7a8ca6; font-size: 12px; line-height: 1.7; overflow-wrap: anywhere; }
.study-intro h3 { margin: 0 0 7px; color: #26364c; font-size: 14px; line-height: 1.5; font-weight: 700; }
.study-intro-text { white-space: pre-line; }
.study-goals { margin: 0; padding: 0; list-style: none; }
.study-goals li { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 7px; }
.study-goals li:last-child { margin-bottom: 0; }
.study-goals .van-icon { flex-shrink: 0; margin-top: 3px; color: #1989fa; font-size: 14px; }
.study-audience { display: flex; align-items: flex-start; gap: 10px; }
.study-audience > .van-icon { flex-shrink: 0; padding: 4px; border-radius: 6px; color: #1989fa; background: #edf6ff; font-size: 22px; }
.study-audience > span { padding-top: 4px; white-space: pre-line; }
@media (max-width: 359px) { .study-action { padding: 6px 8px; } }
.study-player-popup {
width: 100%;
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
border-radius: 0;
background: #000000;
}
.study-player-head {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
color: #ffffff;
background: #101828;
border-bottom: 1px solid rgba(255, 255, 255, .08);
}
.study-player-title {
min-width: 0;
flex: 1;
color: #ffffff;
font-size: 16px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.study-player-body {
min-height: 0;
flex: 1;
overflow: auto;
padding: 0;
box-sizing: border-box;
background: #000000;
}
.study-media {
width: 100%;
height: 100%;
max-height: none;
background: #000000;
border-radius: 0;
object-fit: contain;
}
.study-audio-wrap {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 220px;
border-radius: 0;
background: linear-gradient(135deg, #10233f, #0f766e);
}
.study-audio-wrap audio {
width: 88%;
}
.study-image-preview {
display: block;
max-width: 100%;
margin: 0 auto;
border-radius: 0;
background: #ffffff;
}
.study-doc-frame {
width: 100%;
height: 100%;
border: 0;
border-radius: 0;
background: #ffffff;
}
.study-file-fallback {
padding: 46px 18px;
color: #7a8798;
font-size: 15px;
text-align: center;
background: #ffffff;
border-radius: 0;
}
.study-player-exit {
flex-shrink: 0;
margin-left: 12px;
color: #ffffff;
font-size: 14px;
}
.study-player-actions {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 14px;
margin-left: 12px;
color: #ffffff;
font-size: 14px;
}
</style>
<div id="app" class="learning-study-page" v-cloak>
<div class="study-hero">
<button type="button" class="study-back" aria-label="返回" @click="goBack"><van-icon name="arrow-left"></van-icon></button>
<img v-if="coverUrl" :src="coverUrl" :alt="course.courseName">
<div class="study-title-block">
<div v-if="courseBadge" class="study-badge"><van-icon name="fire"></van-icon>{{ courseBadge }}</div>
<div class="study-title">{{ course.courseName || '课程学习' }}</div>
<div v-if="course.courseTags" class="study-subtitle">{{ course.courseTags }}</div>
<div v-if="course.lecturerName" class="study-meta">
<span v-if="course.lecturerName" class="study-meta-item"><van-icon name="manager"></van-icon>{{ course.lecturerName }}</span>
</div>
</div>
</div>
<div class="study-content">
<nav class="study-navigation" aria-label="课程详情">
<button v-for="tab in detailTabs" :key="tab.name" type="button" :class="{ active: activeTab === tab.name }" :aria-pressed="activeTab === tab.name" @click="activeTab = tab.name">
<van-icon :name="tab.icon"></van-icon>{{ tab.title }}
</button>
</nav>
<div v-if="activeTab === 'intro'" class="study-introduction">
<section class="study-intro">
<h3>课程简介</h3>
<div class="study-intro-text">{{ course.courseIntro || '暂无课程简介' }}</div>
</section>
<section class="study-intro">
<h3>学习目标</h3>
<ul v-if="learningGoals.length" class="study-goals">
<li v-for="(goal, index) in learningGoals" :key="index"><van-icon name="checked"></van-icon><span>{{ goal }}</span></li>
</ul>
<div v-else class="study-intro-text">暂无学习目标</div>
</section>
<section class="study-intro">
<h3>适用人群</h3>
<div class="study-audience"><van-icon name="manager"></van-icon><span>{{ course.suitablePeople || '暂无适用人群说明' }}</span></div>
</section>
</div>
<div v-if="activeTab === 'catalog'">
<div v-if="Number(course.outlineCount || 0) > 0" class="study-progress-card">
<div class="study-progress-main">
<div class="study-progress-title">学习进度</div>
<div class="study-progress-line"><van-progress :percentage="totalProgress" :show-pivot="false" stroke-width="8" color="#1989fa"></van-progress><span>{{ totalProgress }}%</span></div>
<div class="study-progress-note">已完成 {{ completedChapterCount }} 个章节,共 {{ totalMinutes }} 分钟</div>
</div>
<button type="button" class="study-action study-continue" :disabled="!learnableResources.length" @click="continueStudy"><van-icon name="play"></van-icon>{{ totalProgress ? '继续学习' : '开始学习' }}</button>
</div>
<div class="study-section-head">
<strong>课程目录</strong><small>共 {{ chapterCount }} 个章节</small>
<button v-if="chapterCount" type="button" class="study-text-button" @click="toggleAllChapters">{{ allChaptersCollapsed ? '展开全部' : '收起全部' }} <van-icon :name="allChaptersCollapsed ? 'arrow-down' : 'arrow-up'"></van-icon></button>
</div>
<div v-for="chapter in catalogList" :key="chapter.id" class="study-card study-catalog-card">
<button type="button" class="study-chapter-head" :aria-expanded="!collapsedChapters[chapter.id]" @click="toggleChapter(chapter.id)">
<span class="study-chapter-dot"></span><span>{{ chapter.title }}</span><van-icon :name="collapsedChapters[chapter.id] ? 'arrow-down' : 'arrow-up'"></van-icon>
</button>
<div v-show="!collapsedChapters[chapter.id]" class="study-chapter-body">
<div v-if="chapter.description" class="study-chapter-description">{{ chapter.description }}</div>
<div v-for="resource in chapter.resources" :key="resource.id" class="study-resource">
<div class="study-resource-icon" :class="resourceIconClass(resource)"><van-icon :name="resourceIcon(resource)"></van-icon></div>
<div class="study-resource-main">
<div class="study-resource-title">{{ resource.title }}</div>
<div class="study-resource-status">{{ resourceMeta(resource) }} · <span :class="{ completed: resource.studied && resource.completeStatus === 'completed' }"><van-icon v-if="resource.completeStatus === 'completed'" name="checked"></van-icon>{{ statusText(resource) }}</span></div>
</div>
<button type="button" class="study-action" :disabled="resource.allowPreview === false" @click="openResource(resource)"><van-icon name="play"></van-icon>{{ resource.allowPreview === false ? '不可学习' : resource.studied ? '再次学习' : '学习' }}</button>
</div>
</div>
</div>
<van-empty v-if="!catalogList.length" description="暂无课程目录"></van-empty>
</div>
<div v-if="activeTab === 'downloads'" class="study-downloads">
<div v-if="downloadableResources.length" class="study-download-list">
<div v-for="resource in downloadableResources" :key="resource.id" class="study-download-row">
<img class="study-download-icon" :src="downloadIcon(resource)" alt="">
<div class="study-download-info">
<div class="study-download-name">{{ downloadFileInfo(resource).name || resource.title }}</div>
<div class="study-download-meta">
<span v-if="downloadSize(resource)">{{ downloadSize(resource) }}</span>
<span v-if="downloadDate(resource)">{{ downloadDate(resource) }}</span>
</div>
</div>
<button type="button" class="study-download-button" @click="downloadResource(resource)">下载</button>
</div>
</div>
<van-empty v-else description="暂无可下载资料"></van-empty>
</div>
</div>
<van-popup v-model="playerVisible" position="bottom" class="study-player-popup" :overlay="false">
<div class="study-player-head">
<div class="study-player-title">{{ selectedResource.title || '学习资料' }}</div>
<div class="study-player-actions">
<span v-if="canPreview && selectedResource.resourceType === 'video'" @click="enterVideoFullscreen">横屏</span>
<span @click="closePlayer">退出</span>
</div>
</div>
<div class="study-player-body">
<div v-if="!canPreview" class="study-file-fallback">该资料不允许预览</div>
<video
v-else-if="selectedResource.resourceType === 'video'"
ref="mediaPlayer"
class="study-media"
:src="mediaUrl"
controls
:controlslist="canDownload ? null : 'nodownload'"
playsinline
webkit-playsinline
x5-video-player-type="h5"
x5-video-orientation="landscape|portrait"
x-webkit-airplay="allow"
@loadedmetadata="restoreMediaPosition"
@seeked="handleMediaSeeked"
@seeking="handleMediaSeeking"
@play="startStudy"
@pause="saveMediaPosition(false)"
@timeupdate="saveProgress"
@ended="finishStudy(false, true)">
</video>
<div v-else-if="selectedResource.resourceType === 'audio'" class="study-audio-wrap">
<audio
ref="mediaPlayer"
:src="fileUrl"
preload="metadata"
controls
:controlslist="canDownload ? null : 'nodownload'"
@loadedmetadata="restoreMediaPosition"
@seeked="handleMediaSeeked"
@seeking="handleMediaSeeking"
@play="startStudy"
@pause="saveMediaPosition(false)"
@timeupdate="saveProgress"
@ended="finishStudy(false, true)">
</audio>
</div>
<img v-else-if="selectedResource.resourceType === 'image'" class="study-image-preview" :src="fileUrl" :alt="selectedResource.title">
<iframe v-else-if="canInlinePreview(selectedResource)" class="study-doc-frame" :src="inlinePreviewUrl"></iframe>
<div v-else class="study-file-fallback">
<p>该资料暂不支持内嵌预览</p>
</div>
</div>
</van-popup>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
courseId: "",
apiBase: "/platform/learning/course/display",
recordApi: "/platform/learning/study/record",
activeTab: "catalog",
detailTabs: [
{ name: "intro", title: "课程介绍", icon: "description" },
{ name: "catalog", title: "课程目录", icon: "orders-o" },
{ name: "downloads", title: "资料下载", icon: "down" }
],
collapsedChapters: {},
course: {},
treeData: [],
downloadFiles: {},
selectedResource: {},
playerVisible: false,
startingStudy: false,
switchingResource: false,
currentSegmentId: "",
studying: false,
pendingSeconds: 0,
heartbeatTimer: null,
resumePositionSeconds: 0,
resumeSeekRetryCount: 0,
resumePositionRestored: false,
lastSavedPositionSeconds: 0,
lastPositionSyncAt: 0,
lastAllowedPositionSeconds: 0,
restoringMediaPosition: false,
pageVisibilityHandler: null,
navigatingBack: false
}
},
computed: {
coverUrl() {
return this.getFileUrl(this.course.cover)
},
canPreview() { return !!this.selectedResource.id && this.selectedResource.allowPreview !== false },
canDownload() { return !!this.selectedResource.id && this.selectedResource.allowDownload === true },
fileUrl() {
if (!this.canPreview) return ""
const id = this.getFileId(this.selectedResource.fileData)
return id ? "/platform/sys/file/preview?id=" + encodeURIComponent(id) : this.getFileUrl(this.selectedResource.fileData)
},
downloadUrl() {
if (!this.canDownload) return ""
const id = this.getFileId(this.selectedResource.fileData)
return id ? "/platform/sys/file/download?id=" + encodeURIComponent(id) : this.getFileUrl(this.selectedResource.fileData)
},
mediaUrl() {
if (!this.canPreview) return ""
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() {
if (!this.canPreview) return ""
const id = this.getFileId(this.selectedResource.fileData)
const url = id ? this.apiBase + "/pdfPreview?id=" + encodeURIComponent(id) : this.fileUrl
return this.canDownload ? url : url + "#toolbar=0"
},
catalogList() {
const result = []
;(this.treeData || []).forEach(node => this.appendCatalogNode(node, result))
return result
},
courseBadge() {
const flags = this.course.recommendFlags || ""
return Array.isArray(flags) ? flags[0] : String(flags).split(",")[0]
},
allResources() {
return this.collectResources(this.treeData)
},
learnableResources() {
return this.allResources.filter(resource => resource.allowPreview !== false)
},
downloadableResources() {
return this.allResources.filter(resource => resource.allowDownload === true && this.getFileUrl(resource.fileData))
},
completedChapterCount() {
return Number(this.course.completedOutlineCount || 0)
},
allChaptersCollapsed() {
return this.catalogList.length > 0 && this.catalogList.every(chapter => this.collapsedChapters[chapter.id])
},
chapterCount() {
return this.catalogList.filter(chapter => chapter.resources.some(resource => resource.allowPreview !== false)).length
},
totalMinutes() {
const seconds = this.learnableResources.reduce((sum, item) => sum + Number(item.durationSeconds || 0), 0)
return Math.max(0, Math.ceil(seconds / 60))
},
totalProgress() {
return Number(this.course.progressPercent || 0)
},
learningGoals() {
return String(this.course.learningGoal || "").split(/[\r\n;;]+/).map(goal => goal.trim()).filter(Boolean)
}
},
methods: {
getQuery(name) {
return new URLSearchParams(window.location.search).get(name) || ""
},
async loadCourse() {
const resp = await this.$axios.post(this.apiBase + "/courseInfo", { id: this.courseId })
if (resp.code === 0) {
this.course = resp.data || {}
} else {
this.$toast(resp.msg || "课程不存在")
}
},
async loadTree() {
const resp = await this.$axios.post(this.apiBase + "/studyTree", { courseId: this.courseId })
if (resp.code === 0) {
this.treeData = resp.data || []
this.loadDownloadFiles()
}
},
appendCatalogNode(node, result) {
if (!node) return
if (node.type === "resource") return
const resources = this.collectResources(node.children || [])
if (node.nodeType === "chapter" || resources.length) {
result.push({
id: node.id,
title: node.title,
description: node.description,
resources: resources
})
}
;(node.children || []).forEach(child => {
if (child.type !== "resource" && child.nodeType === "chapter") {
this.appendCatalogNode(child, result)
}
})
},
collectResources(nodes) {
const resources = []
;(nodes || []).forEach(node => {
if (node.type === "resource") {
resources.push(node)
} else {
resources.push.apply(resources, this.collectResources(node.children || []))
}
})
return resources
},
toggleChapter(id) {
this.$set(this.collapsedChapters, id, !this.collapsedChapters[id])
},
toggleAllChapters() {
const collapsed = !this.allChaptersCollapsed
this.catalogList.forEach(chapter => this.$set(this.collapsedChapters, chapter.id, collapsed))
},
continueStudy() {
const resources = this.learnableResources
const resource = resources.find(item => item.studied && item.completeStatus !== "completed")
|| resources.find(item => !item.studied)
|| resources.find(item => item.completeStatus !== "completed")
|| resources[0]
if (resource) this.openResource(resource)
},
resourceMeta(resource) {
const types = { video: "视频", audio: "音频", image: "图片", word: "Word文档", pdf: "PDF文档", ppt: "演示文稿" }
const type = types[resource.resourceType] || ((resource.fileExt || "").toUpperCase() + " 文档").trim()
const seconds = Number(resource.durationSeconds || 0)
if (seconds > 0) return type + " · " + Math.floor(seconds / 60) + ":" + String(seconds % 60).padStart(2, "0")
return type
},
async loadDownloadFiles() {
const ids = [...new Set(this.downloadableResources.map(resource => this.getFileId(resource.fileData)).filter(id => id && !this.downloadFiles[id]))]
if (!ids.length) return
try {
const resp = await this.$axios.post("/platform/sys/file/previewFileData", { ids: JSON.stringify(ids) })
if (resp.code === 0) {
;(resp.data || []).forEach(file => this.$set(this.downloadFiles, file.id, file))
}
} catch (e) {
this.$toast("文件信息加载失败")
}
},
downloadFileInfo(resource) {
const file = this.downloadFiles[this.getFileId(resource.fileData)]
if (file) return file
const attachment = this.getFileEntry(resource.fileData)
return attachment && typeof attachment === "object" ? attachment : {}
},
downloadIcon(resource) {
const file = this.downloadFileInfo(resource)
const ext = (file.suffix || resource.fileExt || (file.name || "").split(".").pop() || resource.resourceType || "").toLowerCase()
const icons = { pdf: "pdf", doc: "docx", docx: "docx", word: "docx", xls: "xlsx", xlsx: "xlsx", csv: "xlsx", ppt: "pptx", pptx: "pptx", zip: "zip", rar: "zip", "7z": "zip", mp3: "mp3", audio: "mp3", mp4: "mp4", video: "mp4" }
return "/assets/platform/img/svg/" + (icons[ext] || "txt") + ".svg"
},
downloadSize(resource) {
const file = this.downloadFileInfo(resource)
if (file.sizeInfo) return file.sizeInfo
const bytes = file.size != null ? Number(file.size) : file.sizeKb != null ? Number(file.sizeKb) * 1024 : NaN
if (!Number.isFinite(bytes) || bytes < 0) return ""
if (bytes < 1024) return bytes + "B"
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + "KB"
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + "MB"
return (bytes / 1073741824).toFixed(1) + "GB"
},
downloadDate(resource) {
const value = this.downloadFileInfo(resource).createdAt
if (!value) return ""
const date = new Date(/^\d+$/.test(String(value)) ? Number(value) : value)
if (Number.isNaN(date.getTime())) return ""
return date.getFullYear() + "-" + String(date.getMonth() + 1).padStart(2, "0") + "-" + String(date.getDate()).padStart(2, "0")
},
downloadResource(resource) {
if (resource.allowDownload !== true) return
const id = this.getFileId(resource.fileData)
const url = id ? "/platform/sys/file/download?id=" + encodeURIComponent(id) : this.getFileUrl(resource.fileData)
if (url) window.open(url)
},
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)
},
async openResource(resource) {
if (!resource || !resource.id || this.switchingResource || this.startingStudy) return
this.switchingResource = true
try {
this.pauseMedia()
await this.finishStudy(false)
resource = this.allResources.find(item => item.id === resource.id) || resource
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 (!this.canPreview) return
if (!["video", "audio"].includes(resource.resourceType)) {
this.$nextTick(() => this.startStudy())
} else {
this.$nextTick(() => this.restoreMediaPosition())
}
} finally {
this.switchingResource = false
}
},
restoreMediaPosition() {
const player = this.$refs.mediaPlayer
if (!player || !this.selectedResource.id || this.resumePositionRestored || this.resumePositionSeconds <= 0 || player.readyState < 1) return
let targetPosition = this.resumePositionSeconds
if (Number.isFinite(player.duration) && player.duration > 0) {
// 已完成或接近结束的记录回退两秒,防止恢复后立即触发播放结束。
targetPosition = Math.min(this.resumePositionSeconds, Math.max(0, Math.floor(player.duration) - 2))
}
if (targetPosition <= 0) {
this.lastAllowedPositionSeconds = 0
this.restoringMediaPosition = false
this.resumePositionRestored = true
return
}
this.resumePositionSeconds = targetPosition
this.restoringMediaPosition = true
player.currentTime = targetPosition
},
verifyMediaPosition() {
const player = this.$refs.mediaPlayer
if (!player || this.resumePositionRestored || this.resumePositionSeconds <= 0) return
const actualPosition = Number(player.currentTime || 0)
if (Math.abs(actualPosition - this.resumePositionSeconds) <= 1) {
this.lastAllowedPositionSeconds = actualPosition
this.restoringMediaPosition = false
this.resumePositionRestored = true
return
}
if (this.resumeSeekRetryCount < 1) {
this.resumeSeekRetryCount += 1
// 部分移动端容器首次定位会被播放器重置,收到 seeked 后再校验并重试一次。
setTimeout(() => this.restoreMediaPosition(), 100)
} else {
this.lastAllowedPositionSeconds = actualPosition
this.restoringMediaPosition = false
this.resumePositionRestored = true
}
},
// 原生播放器点击进度条会在 seeking 后再次写入时间,因此开始定位和完成定位均需限制向前跳转;回退复习不受影响。
restoreBlockedMediaPosition() {
const player = this.$refs.mediaPlayer
if (!player || this.selectedResource.allowDrag !== false || this.restoringMediaPosition) return false
const allowedPosition = Math.max(0, Number(this.lastAllowedPositionSeconds || 0))
if (Number(player.currentTime || 0) <= allowedPosition + 0.25) return false
player.currentTime = allowedPosition
setTimeout(() => {
if (!this.restoringMediaPosition && this.selectedResource.allowDrag === false && Number(player.currentTime || 0) > allowedPosition + 0.25) {
player.currentTime = allowedPosition
}
}, 0)
return true
},
handleMediaSeeking() {
this.restoreBlockedMediaPosition()
},
handleMediaSeeked() {
this.restoreBlockedMediaPosition()
this.verifyMediaPosition()
},
async startStudy() {
if (!this.canPreview || this.studying || this.startingStudy) return
this.startingStudy = true
const resourceId = this.selectedResource.id
try {
const resp = await this.$axios.post(this.recordApi + "/start", {
courseId: this.courseId,
resourceId: resourceId,
positionSeconds: this.resumePositionRestored ? this.getMediaPosition() : this.resumePositionSeconds
})
if (resp.code !== 0) {
this.$toast(resp.msg || "开始学习失败")
return
}
if (!this.playerVisible || this.selectedResource.id !== resourceId) {
await this.$axios.post(this.recordApi + "/finish", { segmentId: resp.data.segmentId, activeSeconds: 0 })
return
}
this.currentSegmentId = resp.data.segmentId
this.studying = true
this.pendingSeconds = 0
this.applyRecordState(resp.data)
this.startHeartbeatTimer()
} finally {
this.startingStudy = false
}
},
async heartbeat() {
if (!this.currentSegmentId || this.pendingSeconds <= 0) return
const seconds = this.pendingSeconds
this.pendingSeconds = 0
const resp = await this.$axios.post(this.recordApi + "/heartbeat", {
segmentId: this.currentSegmentId,
activeSeconds: seconds,
positionSeconds: this.getMediaPosition()
})
if (resp.code === 0) {
this.applyRecordState(resp.data)
}
},
// force 用于页面销毁时静默结束;completed 仅在视频或音频自然播放结束时传 true。
async finishStudy(force, completed) {
this.pauseMedia()
if (!this.currentSegmentId) return
const segmentId = this.currentSegmentId
const seconds = this.pendingSeconds
this.pendingSeconds = 0
this.stopHeartbeatTimer()
this.studying = false
this.currentSegmentId = ""
const resp = await this.$axios.post(this.recordApi + "/finish", {
segmentId: segmentId,
activeSeconds: seconds,
positionSeconds: this.getMediaPosition(),
completed: !!completed
})
if (resp.code === 0) this.applyRecordState(resp.data)
if (force) return
await this.loadTree()
},
startHeartbeatTimer() {
this.stopHeartbeatTimer()
this.heartbeatTimer = setInterval(() => {
if (!this.studying || document.hidden) return
if (["video", "audio"].includes(this.selectedResource.resourceType)) {
const player = this.$refs.mediaPlayer
if (!player || player.paused || player.ended) return
}
this.pendingSeconds += 1
if (this.pendingSeconds >= 15) {
this.heartbeat()
}
}, 1000)
},
stopHeartbeatTimer() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
},
saveProgress() {
const position = this.getMediaPosition()
if (!position || !this.selectedResource.id) return
if (this.selectedResource.allowDrag === false && !this.restoringMediaPosition && position > Number(this.lastAllowedPositionSeconds || 0) + 2) {
this.restoreBlockedMediaPosition()
return
}
this.lastAllowedPositionSeconds = Math.max(Number(this.lastAllowedPositionSeconds || 0), position)
this.$set(this.selectedResource, "lastPositionSeconds", position)
try {
localStorage.setItem(this.progressKey(), String(position))
} catch (e) {
// 无痕模式或受限容器可能禁用本地存储,继续使用服务端记录。
}
if (Date.now() - this.lastPositionSyncAt >= 5000) {
this.saveServerPosition(position, false)
}
},
saveMediaPosition(useBeacon) {
const position = this.getMediaPosition()
if (!this.selectedResource.id || position <= 0) return
try {
localStorage.setItem(this.progressKey(), String(position))
} catch (e) {
// 无痕模式或受限容器可能禁用本地存储,继续使用服务端记录。
}
this.saveServerPosition(position, useBeacon)
},
saveServerPosition(position, useBeacon) {
if (!this.selectedResource.id || !["video", "audio"].includes(this.selectedResource.resourceType)) return
const seconds = Math.max(0, Math.floor(Number(position || 0)))
this.lastPositionSyncAt = Date.now()
this.lastSavedPositionSeconds = seconds
if (useBeacon && navigator.sendBeacon) {
const formData = new URLSearchParams()
formData.append("courseId", this.courseId)
formData.append("resourceId", this.selectedResource.id)
formData.append("positionSeconds", String(seconds))
const body = new Blob([formData.toString()], { type: "application/x-www-form-urlencoded;charset=UTF-8" })
navigator.sendBeacon(this.recordApi + "/position", body)
return
}
// 播放中每五秒、暂停时立即同步,确保服务端续播位置不会只依赖学习心跳。
this.$axios.post(this.recordApi + "/position", {
courseId: this.courseId,
resourceId: this.selectedResource.id,
positionSeconds: seconds
}).then((resp) => {
if (resp.code !== 0) {
this.lastSavedPositionSeconds = 0
}
})
},
applyRecordState(data) {
if (data && data.courseProgress) Object.keys(data.courseProgress).forEach(key => this.$set(this.course, key, data.courseProgress[key]))
if (!data || !data.resourceId) return
const resources = this.allResources.filter(item => item.id === data.resourceId)
if (this.selectedResource.id === data.resourceId && !resources.includes(this.selectedResource)) resources.push(this.selectedResource)
resources.forEach(resource => {
this.$set(resource, "studied", data.studied !== false)
this.$set(resource, "progressPercent", Number(data.progressPercent || 0))
this.$set(resource, "studySeconds", Number(data.studySeconds || 0))
this.$set(resource, "completeStatus", data.completeStatus || "studying")
if (data.lastPositionSeconds !== undefined) this.$set(resource, "lastPositionSeconds", Number(data.lastPositionSeconds || 0))
if (data.maxPositionSeconds !== undefined) this.$set(resource, "maxPositionSeconds", Number(data.maxPositionSeconds || 0))
})
if (this.selectedResource.id === data.resourceId && data.maxPositionSeconds !== undefined) {
this.lastAllowedPositionSeconds = Math.max(Number(this.lastAllowedPositionSeconds || 0), 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)
},
statusText(resource) {
if (!resource || !resource.studied) return "未学习"
if (resource.completeStatus === "completed") return "已完成"
return "学习中 " + Number(resource.progressPercent || 0) + "%"
},
resourceIcon(resource) {
if (resource.resourceType === "pdf" || (resource.fileExt || "").toLowerCase() === "pdf") return "/assets/platform/img/svg/pdf.svg"
if (resource.resourceType === "word" || ["doc", "docx"].includes((resource.fileExt || "").toLowerCase())) return "/assets/platform/img/svg/docx.svg"
if (resource.resourceType === "video") return "video"
if (resource.resourceType === "audio") return "music-o"
if (resource.resourceType === "image") return "photo-o"
return "description"
},
resourceIconClass(resource) {
if (resource.resourceType === "pdf" || (resource.fileExt || "").toLowerCase() === "pdf") return "pdf"
if (resource.resourceType === "word" || ["doc", "docx"].includes((resource.fileExt || "").toLowerCase())) return "word"
if (resource.resourceType === "video") return "video"
if (resource.resourceType === "audio") return "audio"
if (resource.resourceType === "image") return "image"
return "doc"
},
getFileEntry(value) {
if (!value) return null
if (typeof value === "string") {
const text = value.trim()
if (!text) return null
if (["[", "{", '"'].includes(text.charAt(0))) {
try { return this.getFileEntry(JSON.parse(text)) } catch (e) { return null }
}
return text
}
if (Array.isArray(value)) {
return value.map(item => this.getFileEntry(item)).find(item => this.getFileUrl(item)) || null
}
return typeof value === "object" ? value : null
},
getFileUrl(value) {
const file = this.getFileEntry(value)
if (!file) return ""
if (typeof file === "string") {
return /^[a-zA-Z0-9_-]+$/.test(file) ? "/platform/sys/file/download?id=" + encodeURIComponent(file) : file
}
return this.getFileUrl(file.url)
|| this.getFileUrl(file.downloadPath)
|| this.getFileUrl(file.response && file.response.data)
|| this.getFileUrl(file.data)
|| this.getFileUrl(file.id || file.fileId)
},
getFileId(value) {
const url = this.getFileUrl(value)
if (!url) return ""
const matched = url.match(/[?&]id=([^&]+)/)
if (matched) return decodeURIComponent(matched[1])
if (!url.includes("/") && !url.includes(".")) return url
return ""
},
canInlinePreview(resource) {
const type = resource.resourceType || ""
const ext = (resource.fileExt || "").toLowerCase()
return ["pdf", "ppt", "word"].includes(type) || ["pdf", "ppt", "pptx", "doc", "docx"].includes(ext)
},
openFile() {
if (!this.canPreview) return
const url = this.canInlinePreview(this.selectedResource) ? this.inlinePreviewUrl : this.mediaUrl
if (url) window.open(url)
},
downloadFile() {
if (this.canDownload && this.downloadUrl) window.open(this.downloadUrl)
},
pauseMedia() {
const player = this.$refs.mediaPlayer
if (player && player.pause) {
player.pause()
}
},
enterVideoFullscreen() {
const player = this.$refs.mediaPlayer
if (!player) return
const requestFullscreen = player.requestFullscreen || player.webkitRequestFullscreen || player.mozRequestFullScreen || player.msRequestFullscreen
if (requestFullscreen) {
requestFullscreen.call(player)
} else if (player.webkitEnterFullscreen) {
player.webkitEnterFullscreen()
}
if (screen.orientation && screen.orientation.lock) {
screen.orientation.lock("landscape").catch(() => {})
}
},
closePlayer() {
this.pauseMedia()
this.finishStudy(false)
this.playerVisible = false
},
async goBack() {
if (this.navigatingBack) return
this.navigatingBack = true
try {
await this.finishStudy(true)
} finally {
this.navigateBack()
}
},
navigateBack() {
if (window.history.length > 1) {
window.history.back()
return
}
$.pjax({
url: "/platform/learning/course/h5",
container: "#container",
maxCacheLength: 0,
push: false,
replace: true,
fragment: "#container",
timeout: 8000
})
},
handlePageVisibility(event) {
if (document.hidden || (event && event.type === "pagehide")) {
this.saveMediaPosition(true)
}
}
},
async mounted() {
this.courseId = this.getQuery("id")
if (!this.courseId) {
this.$toast("请选择课程")
return
}
await this.loadCourse()
await this.loadTree()
this.pageVisibilityHandler = (event) => this.handlePageVisibility(event)
document.addEventListener("visibilitychange", this.pageVisibilityHandler)
window.addEventListener("pagehide", this.pageVisibilityHandler)
},
beforeDestroy() {
this.saveMediaPosition(true)
this.finishStudy(true)
this.stopHeartbeatTimer()
if (this.pageVisibilityHandler) {
document.removeEventListener("visibilitychange", this.pageVisibilityHandler)
window.removeEventListener("pagehide", this.pageVisibilityHandler)
}
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,219 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<style>
#app.my-study-page { min-height: 100%; height: auto; box-sizing: border-box; padding-bottom: 24px; padding-bottom: calc(24px + env(safe-area-inset-bottom, 0px)); background: #f7fbff linear-gradient(180deg, #cfe8ff 0, #e5f3ff 160px, #f1f8ff 320px, #f7fbff 480px) no-repeat; color: #26364c; }
.my-study-hero { padding: 24px 16px 18px; background: transparent; }
.my-study-head { display: flex; align-items: center; gap: 8px; }
.my-study-back { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 36px; width: 36px; height: 36px; padding: 0; border: 0; border-radius: 50%; background: #fff; color: #1989fa; font-size: 20px; }
.my-study-heading { margin: 0 0 0 auto; text-align: right; font-size: 22px; line-height: 1.5; font-weight: 700; }
.my-study-subtitle { margin: 4px 0 18px; text-align: right; color: #8192a9; font-size: 12px; }
.my-study-overview { padding: 18px 16px 14px; border-radius: 16px; background: #fff; box-shadow: 0 6px 20px rgba(45, 111, 188, .04); }
.my-study-time-heading { position: relative; min-height: 80px; padding-right: 100px; }
.my-study-book { position: absolute; top: 0; right: 0; width: 92px; height: 80px; background-color: #c9e5ff; -webkit-mask: url("${base!}/assets/platform/img/svg/ant-design_read-filled.svg") center / contain no-repeat; mask: url("${base!}/assets/platform/img/svg/ant-design_read-filled.svg") center / contain no-repeat; transform: rotate(-15deg); pointer-events: none; }
.my-study-time-heading .my-study-time { flex-wrap: wrap; }
.my-study-time-label { display: flex; align-items: center; gap: 6px; color: #7a8ca6; font-size: 12px; }
.my-study-time-label .van-icon { color: #1989fa; font-size: 16px; }
.my-study-time { display: flex; align-items: baseline; gap: 6px; margin-top: 8px; color: #8192a9; font-size: 12px; }
.my-study-time strong { color: #1989fa; font-size: 30px; font-weight: 700; line-height: 1.3; font-variant-numeric: tabular-nums; }
.my-study-time strong:not(:first-child) { margin-left: 8px; }
.my-study-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); padding-top: 16px; margin-top: 16px; border-top: 1px solid #edf2f8; text-align: center; }
.my-study-stat + .my-study-stat { border-left: 1px solid #edf2f8; }
.my-study-stat-value { font-size: 20px; line-height: 1.3; font-weight: 700; font-variant-numeric: tabular-nums; }
.my-study-stat-label { margin-top: 4px; color: #8192a9; font-size: 11px; }
.my-study-main { padding: 0 16px; }
.my-study-list-header { position: sticky; top: 0; z-index: 10; margin: 0 -16px; padding: 12px 16px 14px; background: #f7fbff; }
.my-study-list-header .my-study-tabs { margin-bottom: 0; }
.my-study-section-head { display: flex; align-items: baseline; justify-content: space-between; margin: 2px 0 12px; }
.my-study-section-title { margin: 0; font-size: 17px; font-weight: 700; }
.my-study-section-count { color: #8b9ab0; font-size: 12px; }
.my-study-tabs { display: flex; gap: 8px; margin-bottom: 14px; }
.my-study-tab { flex: 1; padding: 8px 4px; border: 0; border-radius: 9px; background: #eaf1fa; color: #7a8ca6; font-size: 12px; line-height: 1.5; }
.my-study-tab.active { background: #1989fa; color: #fff; font-weight: 600; }
.my-study-list-card { margin-bottom: 12px; padding: 12px; border-radius: 14px; background: #fff; }
.my-study-list-card:last-child { margin-bottom: 0; }
.my-study-course-top { display: flex; align-items: flex-start; gap: 12px; width: 100%; padding: 0; border: 0; background: transparent; text-align: left; color: inherit; }
.my-study-list-cover { position: relative; flex: 0 0 112px; width: 112px; height: 84px; aspect-ratio: 4 / 3; border-radius: 8px; overflow: hidden; background: #edf5ff; }
.my-study-list-cover img { display: block; width: 100%; height: 100%; object-fit: cover; }
.my-study-cover-empty { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; color: #98bde9; font-size: 30px; }
.my-study-list-info { min-width: 0; flex: 1; }
.my-study-list-title { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; color: #26364c; font-size: 14px; font-weight: 600; line-height: 1.5; }
.my-study-status { display: inline-flex; align-items: center; gap: 3px; margin-top: 6px; padding: 2px 6px; border-radius: 5px; color: #1989fa; background: #edf6ff; font-size: 10px; }
.my-study-status.completed { color: #28b583; background: #eafaf3; }
.my-study-progress { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
.my-study-progress .van-progress { flex: 1; }
.my-study-progress-value { flex-shrink: 0; color: #1989fa; font-size: 12px; font-weight: 600; }
.my-study-course-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 10px; }
.my-study-list-meta { min-width: 0; color: #8b9ab0; font-size: 11px; line-height: 1.7; }
.my-study-list-meta span { display: block; }
.my-study-continue { display: inline-flex; align-items: center; gap: 4px; flex-shrink: 0; padding: 7px 12px; border: 0; border-radius: 16px; background: #edf6ff; color: #1989fa; font-size: 12px; line-height: 1.5; }
.my-study-loading { padding: 40px 0; text-align: center; }
.my-study-page button:focus-visible { outline: 2px solid #1989fa; outline-offset: 2px; }
@media (max-width: 359px) { .my-study-main { padding: 0 12px; } .my-study-list-header { margin-right: -12px; margin-left: -12px; padding-right: 12px; padding-left: 12px; } .my-study-list-cover { flex-basis: 96px; width: 96px; height: 72px; } .my-study-course-top { gap: 9px; } }
</style>
<div id="app" class="my-study-page" v-cloak>
<div class="my-study-hero">
<div class="my-study-head">
<button type="button" class="my-study-back" aria-label="返回" @click="goBack"><van-icon name="arrow-left"></van-icon></button>
<h1 class="my-study-heading">我的学习</h1>
</div>
<p class="my-study-subtitle">记录学习点滴,见证每一次进步</p>
<div class="my-study-overview">
<div class="my-study-time-heading">
<div class="my-study-time-label"><van-icon name="clock-o"></van-icon>累计学习时长</div>
<div class="my-study-time"><strong>{{ summary.studyHour || 0 }}</strong>小时<strong>{{ summary.studyMinute || 0 }}</strong>分钟</div>
<span class="my-study-book" aria-hidden="true"></span>
</div>
<div class="my-study-stats">
<div v-for="item in statItems" :key="item.label" class="my-study-stat">
<div class="my-study-stat-value">{{ item.value }}</div>
<div class="my-study-stat-label">{{ item.label }}</div>
</div>
</div>
</div>
</div>
<div class="my-study-main">
<div class="my-study-list-header">
<div class="my-study-section-head"><h2 class="my-study-section-title">我学习的课程</h2><span class="my-study-section-count">共 {{ courses.length }} 门</span></div>
<nav class="my-study-tabs" aria-label="学习状态">
<button v-for="tab in statusTabs" :key="tab.value" type="button" class="my-study-tab" :class="{ active: activeStatus === tab.value }" :aria-pressed="activeStatus === tab.value" @click="activeStatus = tab.value">{{ tab.label }}</button>
</nav>
</div>
<div v-if="loading" class="my-study-loading"><van-loading size="22px" color="#1989fa">加载中…</van-loading></div>
<van-empty v-else-if="loadError" description="学习记录加载失败"><van-button round size="small" type="info" @click="loadData">重新加载</van-button></van-empty>
<div v-else class="my-study-list">
<article v-for="course in visibleCourses" :key="course.courseId" class="my-study-list-card">
<button type="button" class="my-study-course-top" @click="openCourse(course)">
<div class="my-study-list-cover">
<img v-if="getCoverUrl(course.cover)" :src="getCoverUrl(course.cover)" :alt="course.courseName">
<div v-else class="my-study-cover-empty"><van-icon name="description"></van-icon></div>
</div>
<div class="my-study-list-info">
<div class="my-study-list-title">{{ course.courseName || '未命名课程' }}</div>
<span class="my-study-status" :class="{ completed: isCompleted(course) }"><van-icon :name="isCompleted(course) ? 'checked' : 'clock-o'"></van-icon>{{ isCompleted(course) ? '已完成' : '学习中' }}</span>
</div>
</button>
<div v-if="Number(course.outlineCount || 0) > 0" class="my-study-progress"><van-progress :percentage="courseProgress(course)" :show-pivot="false" stroke-width="5" :color="isCompleted(course) ? '#2fc78c' : '#1989fa'"></van-progress><span class="my-study-progress-value">{{ courseProgress(course) }}%</span></div>
<div class="my-study-course-foot">
<div class="my-study-list-meta"><span>已完成 {{ course.completedOutlineCount || 0 }} 个章节 · 已学 {{ course.studyTimeText || '0秒' }}</span><span v-if="studyDate(course.latestStudyTime)">最近学习 {{ studyDate(course.latestStudyTime) }}</span></div>
<button type="button" class="my-study-continue" @click="openCourse(course)"><van-icon name="play"></van-icon>{{ isCompleted(course) ? '再次学习' : '继续学习' }}</button>
</div>
</article>
<van-empty v-if="!visibleCourses.length" :description="activeStatus === 'completed' ? '暂无已完成课程' : activeStatus === 'studying' ? '暂无学习中的课程' : '暂无学习课程'"></van-empty>
</div>
</div>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
apiBase: "/platform/learning/study/record",
summary: {},
courses: [],
loading: false,
loadError: false,
activeStatus: "all",
statusTabs: [
{ value: "all", label: "全部" },
{ value: "studying", label: "学习中" },
{ value: "completed", label: "已完成" }
]
}
},
computed: {
completedCount() {
return this.courses.filter(course => this.isCompleted(course)).length
},
statItems() {
return [
{ label: "已学课程", value: this.courses.length },
{ label: "学习中", value: this.courses.length - this.completedCount },
{ label: "已完成", value: this.completedCount }
]
},
visibleCourses() {
if (this.activeStatus === "completed") return this.courses.filter(course => this.isCompleted(course))
if (this.activeStatus === "studying") return this.courses.filter(course => !this.isCompleted(course))
return this.courses
}
},
methods: {
goBack() {
if (window.history.length > 1) {
window.history.back()
return
}
pjaxReplace("/platform/learning/course/h5")
},
async loadData() {
this.loading = true
this.loadError = false
try {
const [summaryResp, courseResp] = await Promise.all([
this.$axios.post(this.apiBase + "/h5Summary"),
this.$axios.post(this.apiBase + "/h5Courses")
])
if (summaryResp.code !== 0 || courseResp.code !== 0) {
this.loadError = true
return
}
this.summary = summaryResp.data || {}
this.courses = courseResp.data || []
} catch (e) {
this.loadError = true
} finally {
this.loading = false
}
},
courseProgress(course) {
const value = Number(course.progressPercent || 0)
return Number.isFinite(value) ? Math.max(0, Math.min(100, Math.round(value))) : 0
},
isCompleted(course) {
return this.courseProgress(course) === 100 && Number(course.outlineCount || 0) > 0
&& Number(course.completedOutlineCount || 0) >= Number(course.outlineCount)
},
studyDate(value) {
if (!value) return ""
const date = new Date(/^\d+$/.test(String(value)) ? Number(value) : String(value).replace(/-/g, "/"))
if (Number.isNaN(date.getTime())) return ""
return date.getFullYear() + "-" + String(date.getMonth() + 1).padStart(2, "0") + "-" + String(date.getDate()).padStart(2, "0")
},
getCoverUrl(cover) {
if (!cover) return ""
if (Array.isArray(cover)) {
return cover.length ? (cover[0].url || (cover[0].response && cover[0].response.data) || cover[0].data || "") : ""
}
if (typeof cover === "string") {
const text = cover.trim()
if (!text) return ""
if (text.startsWith("[")) {
try {
const files = JSON.parse(text)
return files.length ? (files[0].url || (files[0].response && files[0].response.data) || files[0].data || "") : ""
} catch (e) {
return ""
}
}
return text
}
return ""
},
openCourse(course) {
if (!course || !course.courseId) return
pjaxReplace("/platform/learning/course/h5/study?id=" + encodeURIComponent(course.courseId))
}
},
mounted() {
this.loadData()
}
})
</script>
<!--#
}
#-->
@@ -8,7 +8,7 @@ const work = {
</div>
<div class="module-content">
<van-grid :column-num="3">
<van-grid-item icon="photo-o" :text="menu.name" v-for="menu in item.menus" :key="menu.id"
<van-grid-item icon="photo-o" :text="menu.name" v-for="menu in getDisplayMenus(item.menus)" :key="menu.id"
@click="menuClick(menu)">
<template #icon>
<img :src="menu.icon" alt="" style="width: 40px;height: 40px">
@@ -24,6 +24,14 @@ const work = {
`,
store,
methods: {
getDisplayMenus(menus) {
return (menus || []).flatMap(menu => {
if (menu.permission === "h5.learning" && menu.children && menu.children.length > 0) {
return menu.children
}
return [menu]
})
},
menuClick(item) {
this.$pjaxReplace(item.href)
}