This commit is contained in:
hongqiwei
2026-06-09 15:16:33 +08:00
parent c085e83703
commit 7643424fa3
33 changed files with 7593 additions and 0 deletions
@@ -0,0 +1,386 @@
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 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;
@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(), "enabled");
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"));
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 (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());
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("请选择要删除的数据");
}
dao.clear(LearningStudyRule.class, Cnd.where("targetId", "=", id).and("targetType", "=", "resource"));
dao.clear(LearningOutlineResource.class, Cnd.where("id", "=", id));
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");
}
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");
}
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()));
}
if ("resource".equals(rule.getTargetType())) {
dao.update(LearningOutlineResource.class, Chain.make("required", rule.getRequired()).add("status", rule.getStatus()), Cnd.where("id", "=", rule.getTargetId()));
}
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,307 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaMode;
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.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.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")
/**
* 课程展示控制器,提供 PC 与移动端课程列表、课程详情、学习目录和资料预览接口。
*/
public class LearningCourseDisplayController {
@Inject
private Dao dao;
@Inject
private SysDictService sysDictService;
@At("")
@Ok("beetl:/platform/zhgh/Learning/courseDisplay/index.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/study.html")
@SaCheckPermission("learning.course.display")
public void study() {
}
@At
@ApiOperation("课程展示列表")
@SaCheckPermission(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
public Result pageData(PageForm pageForm, String keyword, String courseTypeId, String recommendFlag) {
// 按已发布课程构造基础查询条件,移动端首页和课程列表共用该分页接口。
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);
if (StrUtil.isNotBlank(recommendFlag)) {
cnd.and("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, t.typeName AS courseTypeName
FROM learning_course c
LEFT JOIN learning_course_type t ON t.id = c.courseTypeId
$condition
ORDER BY c.sortNum ASC, c.createdAt DESC
""");
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 -> {
// 前端展示统一使用格式化时间字段,保留原时间字段用于排序和后续扩展。
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(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
public Result courseInfo(String id) {
if (StrUtil.isBlank(id)) {
return Result.error("请选择课程");
}
Sql sql = Sqls.create("""
SELECT c.id, c.courseName, c.courseIntro, c.startTime, c.endTime, c.openType, c.cover,
c.recommendFlags, c.lecturerName, t.typeName AS courseTypeName
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)));
return Result.success(course);
}
@At
@ApiOperation("课程学习安排")
@SaCheckPermission(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
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()));
Map<String, List<NutMap>> childrenMap = new HashMap<>();
List<NutMap> roots = new ArrayList<>();
Map<String, LearningStudyRecord> recordMap = new HashMap<>();
for (LearningStudyRecord record : records) {
recordMap.put(record.getOutlineId(), record);
}
Map<String, NutMap> outlineNodeMap = new HashMap<>();
for (LearningCourseOutline outline : outlines) {
// 先构造所有章节节点,后续再按 parentId 关系挂接为树。
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("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);
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) {
// 资源节点挂在对应章节下,并携带章节学习记录进度。
NutMap node = NutMap.NEW()
.addv("id", resource.getId())
.addv("type", "resource")
.addv("outlineId", resource.getOutlineId())
.addv("title", resource.getResourceTitle())
.addv("resourceType", resource.getResourceType())
.addv("fileExt", resource.getFileExt())
.addv("fileData", resource.getFileData())
.addv("durationSeconds", resource.getDurationSeconds())
.addv("required", resource.getRequired())
.addv("progressPercent", recordMap.containsKey(resource.getOutlineId()) ? recordMap.get(resource.getOutlineId()).getProgressPercent() : 0)
.addv("studySeconds", recordMap.containsKey(resource.getOutlineId()) ? recordMap.get(resource.getOutlineId()).getStudySeconds() : 0)
.addv("lastPositionSeconds", recordMap.containsKey(resource.getOutlineId()) ? value(recordMap.get(resource.getOutlineId()).getLastPositionSeconds()) : 0)
.addv("completeStatus", recordMap.containsKey(resource.getOutlineId()) ? recordMap.get(resource.getOutlineId()).getCompleteStatus() : "not_started");
childrenMap.computeIfAbsent(resource.getOutlineId(), key -> new ArrayList<>()).add(node);
}
return Result.success(roots);
}
@At
@Ok("void")
@ApiOperation("PDF内联预览")
@SaCheckPermission(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
public void pdfPreview(String id, HttpServletResponse response) throws IOException {
if (StrUtil.isBlank(id)) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "文件ID不能为空");
return;
}
// 资料预览优先读取平台文件存储,非 PDF 文件临时转换后以内联 PDF 输出。
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(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
public Result courseTypes() {
return Result.success(dao.query(LearningCourseType.class, Cnd.where("enabled", "=", true).asc("sortNum").asc("createdAt")));
}
@At
@ApiOperation("推荐标识")
@SaCheckPermission(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
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;
}
}
@@ -0,0 +1,235 @@
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.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}")
public Result doAdd(LearningCourse course) {
Result checkResult = checkCourse(course, null);
if (checkResult != null) {
return checkResult;
}
if (StrUtil.isBlank(course.getStatus())) {
course.setStatus("draft");
}
dao.insert(course);
return Result.success();
}
@At
@ApiOperation("编辑课程")
@SaCheckPermission("learning.course.manage")
@SLog(tag = "编辑课程", msg = "课程ID:${args[0].id}")
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]}")
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 (!"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");
return columns.get(prop);
}
}
@@ -0,0 +1,113 @@
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,69 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaMode;
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")
/**
* 学习教育平台页面路由控制器,负责 PC 与移动端页面模板跳转。
*/
public class LearningPlatformController {
/**
* PC 端学习活动管理页面。
*/
@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() {
}
/**
* 移动端我的学习记录页面,权限按 H5 规则增加 h5. 前缀。
*/
@At("/my/record/h5")
@Ok("beetl:/platform/zhghh5/learning/myRecord/index.html")
@SaCheckPermission(value = {"learning.my.record", "h5.learning.my.record"}, mode = SaMode.OR)
public void myRecordH5() {
}
/**
* PC 端学习统计页面。
*/
@At("/statistics")
@Ok("beetl:/platform/zhgh/Learning/statistics/index.html")
@SaCheckPermission("learning.statistics")
public void statistics() {
}
/**
* 移动端课程列表页面,权限按 H5 规则增加 h5. 前缀。
*/
@At("/course/h5")
@Ok("beetl:/platform/zhghh5/learning/course/index.html")
@SaCheckPermission(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
public void courseH5() {
}
/**
* 移动端课程学习页面,权限按 H5 规则增加 h5. 前缀。
*/
@At("/course/h5/study")
@Ok("beetl:/platform/zhghh5/learning/course/study.html")
@SaCheckPermission(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
public void courseH5Study() {
}
}
/**
* PC 端我的学习记录页面。
*/
@@ -0,0 +1,333 @@
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.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;
@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) {
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,
IFNULL(COUNT(uc.userId), 0) AS learnerCount,
IFNULL(SUM(CASE WHEN target.targetCount > 0 AND uc.completedOutlineCount >= target.targetCount THEN 1 ELSE 0 END), 0) AS completedCount,
IFNULL(ROUND(AVG(uc.studySeconds)), 0) AS avgStudySeconds
FROM learning_course c
LEFT JOIN (
SELECT
courseId,
CASE
WHEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) > 0
THEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END)
ELSE COUNT(1)
END AS targetCount,
SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount
FROM learning_course_outline
WHERE status = 'enabled'
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'
AND ((target.requiredCount > 0 AND o.required = 1) OR target.requiredCount = 0)
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,
CASE
WHEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) > 0
THEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END)
ELSE COUNT(1)
END AS targetCount,
SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount
FROM learning_course_outline
WHERE status = 'enabled'
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))));
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), countSql.getInt(), rows));
}
@At
@ApiOperation("分工会统计")
@SaCheckPermission("learning.statistics")
public Result unionPageData(LearningStatisticsPageForm 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(uc.userId) AS learnerCount,
IFNULL(SUM(uc.studySeconds), 0) AS studySeconds,
IFNULL(SUM(CASE WHEN target.targetCount > 0 AND uc.completedOutlineCount >= target.targetCount THEN 1 ELSE 0 END), 0) 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'
AND ((target.requiredCount > 0 AND o.required = 1) OR target.requiredCount = 0)
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,
CASE
WHEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) > 0
THEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END)
ELSE COUNT(1)
END AS targetCount,
SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount
FROM learning_course_outline
WHERE status = 'enabled'
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,
CASE
WHEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) > 0
THEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END)
ELSE COUNT(1)
END AS targetCount,
SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount
FROM learning_course_outline
WHERE status = 'enabled'
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", learnerCount == 0 ? "0%" : BigDecimal.valueOf(completedCount * 100.0 / learnerCount).setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() + "%");
});
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 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 1 = 1");
if (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");
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");
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 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,501 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaMode;
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 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.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 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;
@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) {
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);
appendScope(cnd);
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(value = {"learning.my.record", "h5.learning.my.record"}, mode = SaMode.OR)
public Result h5Summary() {
String userId = SecurityUtil.getUserId();
// 移动端个人中心汇总当前用户学习时长、课程数和章节完成情况。
Sql sql = Sqls.create("""
SELECT IFNULL(SUM(IFNULL(studySeconds, 0)), 0) AS studySeconds,
COUNT(DISTINCT courseId) AS courseCount,
COUNT(DISTINCT CASE WHEN completeStatus = 'completed' THEN courseId END) AS completedCourseCount,
COUNT(DISTINCT outlineId) AS outlineCount,
COUNT(DISTINCT CASE WHEN completeStatus = 'completed' THEN outlineId END) AS completedOutlineCount
FROM learning_study_record
WHERE userId = @userId
""");
sql.params().set("userId", userId);
sql.setCallback(Sqls.callback.map());
dao.execute(sql);
NutMap data = sql.getObject(NutMap.class);
if (data == null) {
data = NutMap.NEW();
}
int studySeconds = data.getInt("studySeconds", 0);
data.put("studyHour", studySeconds / 3600);
data.put("studyMinute", studySeconds % 3600 / 60);
return Result.success(data);
}
@At
@ApiOperation("移动端我的学习课程")
@SaCheckPermission(value = {"learning.my.record", "h5.learning.my.record"}, mode = SaMode.OR)
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
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.put("studyTimeText", formatStudySeconds(row.getInt("studySeconds", 0))));
return Result.success(rows);
}
@At
@ApiOperation("开始学习")
@SaCheckPermission(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
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("课程资料不存在");
}
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);
record.setLastPositionSeconds(sanitizePosition(positionSeconds, 0));
dao.insert(record);
} else {
int requiredSeconds = requiredSeconds(outline.getId());
dao.update(LearningStudyRecord.class, Chain.make("latestStudyTime", now)
.add("courseName", course.getCourseName())
.add("outlineName", outline.getTitle())
.add("lastPositionSeconds", sanitizePosition(positionSeconds, record.getLastPositionSeconds()))
.add("requiredSeconds", requiredSeconds)
.add("progressPercent", progress(record.getStudySeconds(), requiredSeconds))
.add("completeStatus", "completed".equals(record.getCompleteStatus()) ? "completed" : "studying"),
Cnd.where("id", "=", record.getId()));
record = dao.fetch(LearningStudyRecord.class, 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);
return Result.success(NutMap.NEW()
.addv("recordId", record.getId())
.addv("segmentId", segment.getId())
.addv("studySeconds", record.getStudySeconds())
.addv("lastPositionSeconds", record.getLastPositionSeconds())
.addv("requiredSeconds", record.getRequiredSeconds())
.addv("progressPercent", record.getProgressPercent())
.addv("completeStatus", record.getCompleteStatus()));
}
@At
@ApiOperation("学习心跳")
@SaCheckPermission(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
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, positionSeconds));
}
@At
@ApiOperation("结束学习")
@SaCheckPermission(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
public Result finish(@Param("segmentId") String segmentId,
@Param("activeSeconds") Integer activeSeconds,
@Param("positionSeconds") Integer positionSeconds) {
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, positionSeconds);
dao.update(LearningStudySegment.class, Chain.make("endTime", new Date()).add("state", "finished"), Cnd.where("id", "=", segmentId));
return Result.success(result);
}
@At
@ApiOperation("保存播放位置")
@SaCheckPermission(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
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();
}
dao.update(LearningStudyRecord.class,
Chain.make("lastPositionSeconds", positionSeconds).add("latestStudyTime", new Date()),
Cnd.where("userId", "=", SecurityUtil.getUserId())
.and("courseId", "=", courseId)
.and("outlineId", "=", resource.getOutlineId()));
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.setRequiredSeconds(requiredSeconds);
record.setProgressPercent(0);
record.setCompleteStatus("studying");
return record;
}
private NutMap addActiveSeconds(LearningStudySegment segment, int activeSeconds, boolean finish, Integer positionSeconds) {
Date now = new Date();
int segmentSeconds = value(segment.getActiveSeconds()) + activeSeconds;
dao.update(LearningStudySegment.class, Chain.make("activeSeconds", segmentSeconds).add("lastHeartbeatTime", now), Cnd.where("id", "=", segment.getId()));
LearningStudyRecord record = dao.fetch(LearningStudyRecord.class, segment.getRecordId());
int studySeconds = value(record.getStudySeconds()) + activeSeconds;
int requiredSeconds = requiredSeconds(record.getOutlineId());
int progressPercent = progress(studySeconds, requiredSeconds);
String completeStatus = completeStatus(studySeconds, requiredSeconds, finish);
if ("completed".equals(completeStatus)) {
progressPercent = 100;
}
Chain chain = Chain.make("latestStudyTime", now)
.add("studySeconds", studySeconds)
.add("requiredSeconds", requiredSeconds)
.add("progressPercent", progressPercent)
.add("completeStatus", completeStatus);
int lastPositionSeconds = value(record.getLastPositionSeconds());
if (positionSeconds != null && positionSeconds >= 0) {
lastPositionSeconds = positionSeconds;
chain.add("lastPositionSeconds", positionSeconds);
}
if ("completed".equals(completeStatus) && record.getCompletedAt() == null) {
chain.add("completedAt", now);
}
dao.update(LearningStudyRecord.class, chain, Cnd.where("id", "=", record.getId()));
return NutMap.NEW()
.addv("studySeconds", studySeconds)
.addv("studyTimeText", formatStudySeconds(studySeconds))
.addv("lastPositionSeconds", lastPositionSeconds)
.addv("requiredSeconds", requiredSeconds)
.addv("progressPercent", progressPercent)
.addv("completeStatus", completeStatus);
}
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) {
LearningStudyRule rule = dao.fetch(LearningStudyRule.class, Cnd.where("targetType", "=", "outline")
.and("targetId", "=", outlineId)
.and("status", "=", "enabled"));
if (rule != null && value(rule.getMinStudySeconds()) > 0) {
return rule.getMinStudySeconds();
}
Sql sql = Sqls.create("""
SELECT IFNULL(SUM(IFNULL(durationSeconds, 0)), 0)
FROM learning_outline_resource
WHERE outlineId = @outlineId AND status = 'enabled'
""");
sql.params().set("outlineId", outlineId);
sql.setCallback(Sqls.callback.integer());
dao.execute(sql);
return Math.max(0, sql.getInt());
}
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,117 @@
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.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,119 @@
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("要求学习时长(秒)")
@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,16 @@
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;
}
@@ -0,0 +1,72 @@
CREATE TABLE IF NOT EXISTS learning_course_outline (
id VARCHAR(32) NOT NULL COMMENT 'ID',
courseId VARCHAR(32) NULL COMMENT '所属课程ID',
courseName VARCHAR(100) NULL COMMENT '所属课程名称',
parentId VARCHAR(32) NULL COMMENT '父节点ID',
nodeType VARCHAR(20) NULL COMMENT '节点类型',
title VARCHAR(100) NULL COMMENT '章/节标题',
subtitle VARCHAR(200) NULL COMMENT '副标题',
description LONGTEXT NULL COMMENT '简介',
sortOrder INT NULL COMMENT '排序值',
required TINYINT(1) NULL COMMENT '是否必学',
status VARCHAR(20) NULL COMMENT '状态',
createdBy VARCHAR(32) NULL COMMENT '创建人',
createdAt BIGINT NULL COMMENT '创建时间',
updatedBy VARCHAR(32) NULL COMMENT '修改人',
updatedAt BIGINT NULL COMMENT '修改时间',
delFlag TINYINT(1) NULL COMMENT '删除标记',
PRIMARY KEY (id),
INDEX idx_learning_outline_course (courseId),
INDEX idx_learning_outline_parent (parentId)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学习教育课程大纲';
CREATE TABLE IF NOT EXISTS learning_outline_resource (
id VARCHAR(32) NOT NULL COMMENT 'ID',
courseId VARCHAR(32) NULL COMMENT '所属课程ID',
outlineId VARCHAR(32) NULL COMMENT '所属章/节ID',
resourceTitle VARCHAR(100) NULL COMMENT '资料标题',
resourceType VARCHAR(20) NULL COMMENT '资料类型',
fileExt VARCHAR(20) NULL COMMENT '文件扩展名',
fileData LONGTEXT NULL COMMENT '附件',
durationSeconds INT NULL COMMENT '视频/音频时长',
sortOrder INT NULL COMMENT '排序值',
required TINYINT(1) NULL COMMENT '是否必学',
allowPreview TINYINT(1) NULL COMMENT '是否允许预览',
allowDownload TINYINT(1) NULL COMMENT '是否允许下载',
status VARCHAR(20) NULL COMMENT '状态',
createdBy VARCHAR(32) NULL COMMENT '创建人',
createdAt BIGINT NULL COMMENT '创建时间',
updatedBy VARCHAR(32) NULL COMMENT '修改人',
updatedAt BIGINT NULL COMMENT '修改时间',
delFlag TINYINT(1) NULL COMMENT '删除标记',
PRIMARY KEY (id),
INDEX idx_learning_resource_course (courseId),
INDEX idx_learning_resource_outline (outlineId)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学习教育大纲资料';
CREATE TABLE IF NOT EXISTS learning_study_rule (
id VARCHAR(32) NOT NULL COMMENT 'ID',
courseId VARCHAR(32) NULL COMMENT '所属课程ID',
targetType VARCHAR(20) NULL COMMENT '规则对象类型',
targetId VARCHAR(32) NULL COMMENT '规则对象ID',
required TINYINT(1) NULL COMMENT '是否必学',
studyMode VARCHAR(20) NULL COMMENT '学习方式',
completionRule VARCHAR(50) NULL COMMENT '完成规则',
completePercent INT NULL COMMENT '完成比例',
minStudySeconds INT NULL COMMENT '最少学习时长',
unlockRule VARCHAR(20) NULL COMMENT '解锁规则',
allowSkip TINYINT(1) NULL COMMENT '是否允许跳过',
allowDrag TINYINT(1) NULL COMMENT '是否允许拖动',
pauseCountTime TINYINT(1) NULL COMMENT '暂停是否计时',
hiddenCountTime TINYINT(1) NULL COMMENT '页面隐藏是否计时',
inactiveCountTime TINYINT(1) NULL COMMENT '长时间无操作是否计时',
status VARCHAR(20) NULL COMMENT '状态',
createdBy VARCHAR(32) NULL COMMENT '创建人',
createdAt BIGINT NULL COMMENT '创建时间',
updatedBy VARCHAR(32) NULL COMMENT '修改人',
updatedAt BIGINT NULL COMMENT '修改时间',
delFlag TINYINT(1) NULL COMMENT '删除标记',
PRIMARY KEY (id),
INDEX idx_learning_rule_target (targetType, targetId),
INDEX idx_learning_rule_course (courseId)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学习教育学习规则';
@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS learning_course (
id VARCHAR(32) NOT NULL COMMENT 'ID',
courseName VARCHAR(100) NULL COMMENT '课程名称',
courseTypeId VARCHAR(32) NULL COMMENT '课程类型',
lecturerName VARCHAR(100) NULL COMMENT '授课讲师',
lecturerInfo VARCHAR(500) NULL COMMENT '讲师基本信息',
courseIntro LONGTEXT NULL COMMENT '课程简介',
suitablePeople VARCHAR(500) NULL COMMENT '适合人群',
learningGoal VARCHAR(500) NULL COMMENT '学习目标',
coursePeriod VARCHAR(50) NULL COMMENT '课程周期',
openType VARCHAR(20) NULL COMMENT '开课类型',
startTime DATETIME NULL COMMENT '开始时间',
endTime DATETIME NULL COMMENT '结束时间',
status VARCHAR(20) NULL COMMENT '课程状态',
recommendFlags VARCHAR(500) NULL COMMENT '推荐标识',
courseTags VARCHAR(500) NULL COMMENT '课程标签',
targetType VARCHAR(20) NULL COMMENT '学习对象',
targetOrgText VARCHAR(500) NULL COMMENT '指定组织',
sortNum INT NULL COMMENT '排序编码',
cover VARCHAR(500) NULL COMMENT '课程封面',
createdBy VARCHAR(32) NULL COMMENT '创建人',
createdAt BIGINT NULL COMMENT '创建时间',
updatedBy VARCHAR(32) NULL COMMENT '修改人',
updatedAt BIGINT NULL COMMENT '修改时间',
delFlag TINYINT(1) NULL COMMENT '删除标记',
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学习教育课程';
@@ -0,0 +1,2 @@
ALTER TABLE learning_course_type
ADD COLUMN remark VARCHAR(500) NULL COMMENT '备注';
@@ -0,0 +1,2 @@
ALTER TABLE learning_study_record
ADD COLUMN lastPositionSeconds INT NULL COMMENT '最近播放位置(秒)' AFTER studySeconds;
@@ -0,0 +1,52 @@
CREATE TABLE IF NOT EXISTS learning_study_record (
id VARCHAR(32) NOT NULL COMMENT 'ID',
userId VARCHAR(32) NULL COMMENT '学习人ID',
loginName VARCHAR(120) NULL COMMENT '工号',
userName VARCHAR(100) NULL COMMENT '姓名',
unionId VARCHAR(32) NULL COMMENT '所属分工会ID',
unionName VARCHAR(100) NULL COMMENT '所属分工会',
courseId VARCHAR(32) NULL COMMENT '课程ID',
courseName VARCHAR(100) NULL COMMENT '课程名称',
outlineId VARCHAR(32) NULL COMMENT '章节ID',
outlineName VARCHAR(100) NULL COMMENT '章节名称',
firstStudyTime DATETIME NULL COMMENT '第一次进入时间',
latestStudyTime DATETIME NULL COMMENT '最近学习时间',
studySeconds INT NULL COMMENT '累计有效学习时长(秒)',
lastPositionSeconds INT NULL COMMENT '最近播放位置(秒)',
requiredSeconds INT NULL COMMENT '要求学习时长(秒)',
progressPercent INT NULL COMMENT '学习进度',
completeStatus VARCHAR(20) NULL COMMENT '完成状态',
completedAt DATETIME NULL COMMENT '完成时间',
createdBy VARCHAR(32) NULL COMMENT '创建人',
createdAt BIGINT NULL COMMENT '创建时间',
updatedBy VARCHAR(32) NULL COMMENT '修改人',
updatedAt BIGINT NULL COMMENT '修改时间',
delFlag TINYINT(1) NULL COMMENT '删除标记',
PRIMARY KEY (id),
UNIQUE KEY idx_learning_record_user_course_outline (userId, courseId, outlineId),
INDEX idx_learning_record_union (unionId),
INDEX idx_learning_record_course (courseId)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学习教育学习记录';
CREATE TABLE IF NOT EXISTS learning_study_segment (
id VARCHAR(32) NOT NULL COMMENT 'ID',
recordId VARCHAR(32) NULL COMMENT '学习记录ID',
userId VARCHAR(32) NULL COMMENT '学习人ID',
courseId VARCHAR(32) NULL COMMENT '课程ID',
outlineId VARCHAR(32) NULL COMMENT '章节ID',
resourceId VARCHAR(32) NULL COMMENT '资源ID',
resourceName VARCHAR(100) NULL COMMENT '资源名称',
startTime DATETIME NULL COMMENT '开始时间',
endTime DATETIME NULL COMMENT '结束时间',
lastHeartbeatTime DATETIME NULL COMMENT '最近心跳时间',
activeSeconds INT NULL COMMENT '本时段有效学习时长(秒)',
state VARCHAR(20) NULL COMMENT '状态',
createdBy VARCHAR(32) NULL COMMENT '创建人',
createdAt BIGINT NULL COMMENT '创建时间',
updatedBy VARCHAR(32) NULL COMMENT '修改人',
updatedAt BIGINT NULL COMMENT '修改时间',
delFlag TINYINT(1) NULL COMMENT '删除标记',
PRIMARY KEY (id),
INDEX idx_learning_segment_record (recordId),
INDEX idx_learning_segment_user_state (userId, state)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学习教育学习时段';
@@ -0,0 +1,148 @@
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT
'a0f47f6a6d734c8b9c6ce5c0dfd80001',
'',
LPAD(IFNULL(MAX(CAST(path AS UNSIGNED)), 0) + 1, 4, '0'),
'学习教育平台',
'Learning',
'menu',
'',
'',
'ti-book',
1,
0,
'learning',
NULL,
990,
1,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
0,
'PC',
NULL,
NULL,
'x',
0,
0
FROM sys_menu
WHERE (parentId = '' OR parentId IS NULL)
AND CHAR_LENGTH(path) = 4
HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'learning') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'a0f47f6a6d734c8b9c6ce5c0dfd80002', p.id, CONCAT(p.path, '0001'), '课程类型设置', 'Course Type', 'menu', '/platform/learning/course/type', 'data-pjax', '', 1, 0, 'learning.course.type', NULL, 1, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'k', 0, 0
FROM sys_menu p
WHERE p.permission = 'learning'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'learning.course.type') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'a0f47f6a6d734c8b9c6ce5c0dfd80003', p.id, CONCAT(p.path, '0002'), '课程管理', 'Course Manage', 'menu', '/platform/learning/course/manage', 'data-pjax', '', 1, 0, 'learning.course.manage', NULL, 2, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'k', 0, 0
FROM sys_menu p
WHERE p.permission = 'learning'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'learning.course.manage') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'a0f47f6a6d734c8b9c6ce5c0dfd80004', p.id, CONCAT(p.path, '0003'), '章节内容管理', 'Chapter Content', 'menu', '/platform/learning/chapter/content', 'data-pjax', '', 1, 0, 'learning.chapter.content', NULL, 3, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'z', 0, 0
FROM sys_menu p
WHERE p.permission = 'learning'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'learning.chapter.content') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'a0f47f6a6d734c8b9c6ce5c0dfd80005', p.id, CONCAT(p.path, '0004'), '课程展示', 'Course Display', 'menu', '/platform/learning/course/display', 'data-pjax', '', 1, 0, 'learning.course.display', NULL, 4, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'k', 0, 0
FROM sys_menu p
WHERE p.permission = 'learning'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'learning.course.display') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'a0f47f6a6d734c8b9c6ce5c0dfd80006', p.id, CONCAT(p.path, '0005'), '学习活动管理', 'Activity Manage', 'menu', '/platform/learning/activity/manage', 'data-pjax', '', 1, 0, 'learning.activity.manage', NULL, 5, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'x', 0, 0
FROM sys_menu p
WHERE p.permission = 'learning'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'learning.activity.manage') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'a0f47f6a6d734c8b9c6ce5c0dfd80007', p.id, CONCAT(p.path, '0006'), '学习统计', 'Learning Statistics', 'menu', '/platform/learning/statistics', 'data-pjax', '', 1, 0, 'learning.statistics', NULL, 6, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'x', 0, 0
FROM sys_menu p
WHERE p.permission = 'learning'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'learning.statistics') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'a0f47f6a6d734c8b9c6ce5c0dfd80008', p.id, CONCAT(p.path, '0007'), '我的学习记录', 'My Learning Record', 'menu', '/platform/learning/my/record', 'data-pjax', '', 1, 0, 'learning.my.record', NULL, 7, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'w', 0, 0
FROM sys_menu p
WHERE p.permission = 'learning'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'learning.my.record') t);
INSERT INTO sys_role_menu (roleId, menuId)
SELECT r.id, m.id
FROM sys_role r
JOIN sys_menu m ON m.permission IN (
'learning',
'learning.course.type',
'learning.course.manage',
'learning.chapter.content',
'learning.course.display',
'learning.activity.manage',
'learning.statistics',
'learning.my.record'
)
LEFT JOIN sys_role_menu rm ON rm.roleId = r.id AND rm.menuId = m.id
WHERE r.code = 'SYSADMIN'
AND rm.roleId IS NULL;
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT
'a0f47f6a6d734c8b9c6ce5c0dfd81001',
'',
LPAD(IFNULL(MAX(CAST(path AS UNSIGNED)), 0) + 1, 4, '0'),
'学习教育',
'Learning H5',
'menu',
'',
'',
'orders-o',
1,
0,
'h5.learning',
NULL,
990,
1,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
0,
'H5',
NULL,
'orders-o',
'x',
1,
0
FROM sys_menu
WHERE (parentId = '' OR parentId IS NULL)
AND CHAR_LENGTH(path) = 4
HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'h5.learning') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'a0f47f6a6d734c8b9c6ce5c0dfd81002', p.id, CONCAT(p.path, '0001'), '课程学习', 'Course H5', 'menu', '/platform/learning/course/h5', 'data-pjax', '', 1, 0, 'h5.learning.course.display', NULL, 1, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'H5', NULL, 'play-circle-o', 'k', 1, 0
FROM sys_menu p
WHERE p.permission = 'h5.learning'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'h5.learning.course.display') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'a0f47f6a6d734c8b9c6ce5c0dfd81003', p.id, CONCAT(p.path, '0002'), '我的学习记录', 'My Record H5', 'menu', '/platform/learning/my/record/h5', 'data-pjax', '', 1, 0, 'h5.learning.my.record', NULL, 2, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'H5', NULL, 'records-o', 'w', 0, 0
FROM sys_menu p
WHERE p.permission = 'h5.learning'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'h5.learning.my.record') t);
INSERT INTO sys_role_menu (roleId, menuId)
SELECT r.id, m.id
FROM sys_role r
JOIN sys_menu m ON m.permission IN (
'h5.learning',
'h5.learning.course.display',
'h5.learning.my.record'
)
LEFT JOIN sys_role_menu rm ON rm.roleId = r.id AND rm.menuId = m.id
WHERE r.code = 'SYSADMIN'
AND rm.roleId IS NULL;
@@ -0,0 +1,9 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<el-card shadow="never">学习活动管理模块待完善</el-card>
</div>
<!--#
}
#-->
@@ -0,0 +1,897 @@
<!--#
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>
<search-item label="课程类型">
<el-select v-model="courseQuery.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-input v-model="courseQuery.lecturerName" clearable placeholder="请输入授课讲师" style="width: 100%" @keyup.enter.native="loadCourses"></el-input>
</search-item>
<search-item label="课程状态">
<el-select v-model="courseQuery.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>
<div class="search-query">
<el-button size="medium" type="primary" icon="el-icon-search" @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">
<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}">{{formatDurationMinutes(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">
<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">
<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.durationMinutes" :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"></el-switch></el-form-item></el-col>
<el-col :span="8"><el-form-item label="允许预览"><el-switch v-model="resourceForm.allowPreview"></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 axiosRequest1 = this.$axios.post(loc() + "/courseTypes")
const resp = await axiosRequest1
if (resp.code === 0) {
this.courseTypeOptions = resp.data
}
},
async loadCourses(keepCurrent = false) {
const axiosRequest2 = this.$axios.post(loc() + "/courses", this.courseQuery)
const resp = await axiosRequest2
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 axiosRequest3 = this.$axios.post(loc() + "/tree", { courseId: this.currentCourseId })
const resp = await axiosRequest3
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 axiosRequest4 = this.$axios.post(loc() + "/saveNode", this.nodeDialogForm)
const resp = await axiosRequest4
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 axiosRequest5 = this.$axios.post(loc() + "/saveNode", this.nodeForm)
const resp = await axiosRequest5
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 axiosRequest6 = this.$axios.post(loc() + "/deleteNode", { id: node.id })
const resp = await axiosRequest6
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 axiosRequest7 = this.$axios.post(loc() + "/toggleNode", { id: node.id, status })
const resp = await axiosRequest7
if (resp.code === 0) {
this.$message.success(resp.msg)
await this.loadTree()
}
},
async moveNode(node, direction) {
const axiosRequest8 = this.$axios.post(loc() + "/moveNode", { id: node.id, direction })
const resp = await axiosRequest8
if (resp.code === 0) {
await this.loadTree()
}
},
async setRequired(node) {
const next = !node.required
const data = Object.assign({}, node, { required: next })
const axiosRequest9 = this.$axios.post(loc() + "/saveNode", data)
const resp = await axiosRequest9
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 axiosRequest10 = this.$axios.post(loc() + "/resourcePage", Object.assign({}, this.resourcePageForm, { outlineId: this.selectedNode.id }))
const resp = await axiosRequest10
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),
durationMinutes: this.formatDurationMinutes(row.durationSeconds)
})
} else {
this.resourceForm = {
courseId: target.courseId,
outlineId: target.id,
outlineName: target.title,
resourceTitle: "",
resourceType: "video",
fileExt: "",
fileData: [],
durationSeconds: 0,
durationMinutes: 0,
sortOrder: 1,
required: false,
allowPreview: true,
allowDownload: false,
status: "enabled"
}
}
this.resourceTypeChange(this.resourceForm.resourceType)
this.resourceDialogVisible = true
this.$nextTick(() => this.$refs.resourceForm && this.$refs.resourceForm.clearValidate())
},
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.durationSeconds = Math.round((data.durationMinutes || 0) * 60)
data.fileExt = this.getFileExt(data.fileData)
if (!this.validResourceExt(data.resourceType, data.fileExt)) {
this.$message.warning("当前资料类型不支持上传 ." + data.fileExt + " 格式文件")
return
}
delete data.outlineName
delete data.durationMinutes
data.fileData = JSON.stringify(data.fileData || [])
const axiosRequest11 = this.$axios.post(loc() + "/saveResource", data)
const resp = await axiosRequest11
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 axiosRequest12 = this.$axios.post(loc() + "/deleteResource", { id: row.id })
const resp = await axiosRequest12
if (resp.code === 0) {
this.$message.success(resp.msg)
this.loadResources()
}
})
},
async loadRule() {
if (!this.selectedNode.id) return
const axiosRequest13 = this.$axios.post(loc() + "/getRule", {
courseId: this.selectedNode.courseId,
targetType: "outline",
targetId: this.selectedNode.id
})
const resp = await axiosRequest13
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 axiosRequest14 = this.$axios.post(loc() + "/saveRule", data)
const resp = await axiosRequest14
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
},
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, "durationMinutes", Math.ceil(media.duration / 60))
}
}
media.onerror = () => {
window.URL.revokeObjectURL(media.src)
}
media.src = 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,353 @@
<!--#
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 axiosRequest1 = this.$axios.post(loc() + "/pageData", Object.assign({}, this.query, {
pageNumber: this.pageForm.pageNumber,
pageSize: this.pageForm.pageSize
}))
const resp = await axiosRequest1
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 axiosRequest2 = this.$axios.post(loc() + "/courseTypes")
const typeResp = await axiosRequest2
if (typeResp.code === 0) {
this.courseTypeOptions = typeResp.data
}
const axiosRequest3 = this.$axios.post(loc() + "/recommendOptions")
const recommendResp = await axiosRequest3
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,761 @@
<!--#
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 axiosRequest1 = this.$axios.post(this.apiBase + "/courseInfo", { id: this.courseId })
const resp = await axiosRequest1
if (resp.code === 0) {
this.course = resp.data
} else {
this.$message.warning(resp.msg)
}
},
async loadTree() {
const axiosRequest2 = this.$axios.post(this.apiBase + "/studyTree", { courseId: this.courseId })
const resp = await axiosRequest2
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 axiosRequest3 = this.$axios.post(this.apiBase + "/recommendOptions")
const resp = await axiosRequest3
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 {
const axiosRequest4 = this.$axios.post(this.recordApi + "/start", {
courseId: this.courseId,
resourceId: this.selectedResource.id,
positionSeconds: typeof config.seekTo === "number" ? Math.floor(config.seekTo) : this.getMediaPosition()
})
resp = await axiosRequest4
} 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 axiosRequest5 = this.$axios.post(this.recordApi + "/heartbeat", {
segmentId: this.currentSegmentId,
activeSeconds: seconds,
positionSeconds: this.getMediaPosition()
})
const resp = await axiosRequest5
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 axiosRequest6 = this.$axios.post(this.recordApi + "/finish", {
segmentId: segmentId,
activeSeconds: seconds,
positionSeconds: this.getMediaPosition()
})
const resp = await axiosRequest6
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,400 @@
<!--#
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" icon="el-icon-search" @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="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="12">
<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-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"] }],
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) => {
this.tableLoading = false
if (res.code === 0) {
this.tableData = res.data.list
this.pageForm.totalCount = res.data.totalCount
}
})
},
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,
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)
})
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(async (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
const axiosRequest1 = this.$axios.post(loc() + method, data)
const resp = await axiosRequest1
this.subDis = false
if (resp.code === 0) {
this.dialogVisible = false
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.warning(resp.msg)
}
})
},
doDelete(row) {
this.$confirm("确定要删除该课程吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(async () => {
const axiosRequest2 = this.$axios.post(loc() + "/doDelete", { id: row.id })
const resp = await axiosRequest2
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.error(resp.msg)
}
})
},
async getCourseTypes() {
const axiosRequest3 = this.$axios.post(loc() + "/courseTypes")
const resp = await axiosRequest3
if (resp.code === 0) {
this.courseTypeOptions = resp.data
}
},
async getLearningDict(name) {
const axiosRequest4 = this.$axios.post(loc() + "/learningDictOptions", { name })
const resp = await axiosRequest4
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") : ""
}
},
async created() {
this.pageForm.courseTime = []
await this.getCourseTypes()
this.recommendOptions = await this.getLearningDict("推荐标识")
this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,192 @@
<!--#
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" icon="el-icon-search" @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 axiosRequest1 = this.$axios.post(loc() + method, this.formData)
const resp = await axiosRequest1
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 axiosRequest2 = this.$axios.post(loc() + "/doEdit", row)
const resp = await axiosRequest2
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 axiosRequest3 = this.$axios.post(loc() + "/doDelete", { id: row.id })
const resp = await axiosRequest3
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.error(resp.msg)
}
})
}
},
created() {
this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,183 @@
<!--#
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.keyword"
clearable
placeholder="请输入工号或姓名"
style="width: 100%"
@keyup.enter.native="doSearch">
</el-input>
</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="课程名称">
<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" icon="el-icon-search" @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: [],
unionOptions: [],
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.keyword = ""
this.pageForm.unionId = ""
this.pageForm.courseId = ""
this.pageForm.completeStatus = ""
this.pageForm.pageNumber = 1
this.pageData()
},
async loadOptions() {
const axiosRequest1 = this.$axios.post(this.apiBase + "/courseOptions")
const courseResp = await axiosRequest1
if (courseResp.code === 0) {
this.courseOptions = courseResp.data || []
}
const axiosRequest2 = this.$axios.post(this.apiBase + "/unionOptions")
const unionResp = await axiosRequest2
if (unionResp.code === 0) {
this.unionOptions = unionResp.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 axiosRequest3 = this.$axios.post(this.apiBase + "/delete", { id: row.id })
const resp = await axiosRequest3
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.warning(resp.msg)
}
})
}
},
async created() {
this.$set(this.pageForm, "keyword", "")
this.$set(this.pageForm, "unionId", "")
this.$set(this.pageForm, "courseId", "")
this.$set(this.pageForm, "completeStatus", "")
await this.loadOptions()
this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,169 @@
<!--#
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
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>
<div class="search-query">
<el-button size="medium" type="primary" icon="el-icon-search" @click="doSearch">搜索</el-button>
<el-button size="medium" @click="resetSearch">重置</el-button>
</div>
</search>
</el-card>
<el-card class="mt10" shadow="never">
<el-tabs v-model="activeTab" @tab-click="tabChange">
<el-tab-pane label="课程统计" name="course"></el-tab-pane>
<el-tab-pane label="分工会统计" name="union"></el-tab-pane>
</el-tabs>
<table-tool :app="this" :label="activeTab === 'course' ? '课程统计' : '分工会统计'"></table-tool>
<el-table
v-if="activeTab === 'course'"
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" width="360"></el-table-column>
<el-table-column align="center" header-align="center" label="学习人数" prop="learnerCount" sortable="custom" width="130"></el-table-column>
<el-table-column align="center" header-align="center" label="完成人数" prop="completedCount" sortable="custom" width="130"></el-table-column>
<el-table-column align="center" header-align="center" label="平均学习时长" prop="avgStudySeconds" sortable="custom" width="170">
<template slot-scope="{row}">{{row.avgStudyTimeText}}</template>
</el-table-column>
</el-table>
<el-table
v-else
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="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>
<!--#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: []
}
},
methods: {
pageData() {
this.tableLoading = true
const url = this.activeTab === "course" ? "/coursePageData" : "/unionPageData"
this.$axios.post(this.apiBase + url, 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()
},
async doSearch() {
this.pageForm.pageNumber = 1
await this.ensureUnionDefaultCourse()
this.pageData()
},
async resetSearch() {
this.pageForm.courseId = ""
this.pageForm.unionId = ""
this.pageForm.pageOrderName = ""
this.pageForm.pageOrderBy = ""
this.pageForm.pageNumber = 1
await this.queryCourseOptions("")
await this.ensureUnionDefaultCourse()
this.pageData()
},
async tabChange() {
this.tableData = []
this.pageForm.pageNumber = 1
this.pageForm.pageOrderName = ""
this.pageForm.pageOrderBy = ""
await this.ensureUnionDefaultCourse()
this.pageData()
},
async queryCourseOptions(query) {
const axiosRequest1 = this.$axios.post(this.apiBase + "/courseOptions", { courseName: query || "" })
const resp = await axiosRequest1
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 axiosRequest2 = this.$axios.post(this.apiBase + "/unionOptions")
const resp = await axiosRequest2
if (resp.code === 0) {
this.unionOptions = resp.data || []
}
}
},
async created() {
this.$set(this.pageForm, "courseId", "")
this.$set(this.pageForm, "unionId", "")
await this.queryCourseOptions("")
await this.loadUnionOptions()
this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,612 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<style>
.learning-course-page {
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
background: #f4f7fb;
padding-bottom: env(safe-area-inset-bottom);
box-sizing: border-box;
}
.learning-course-page .van-nav-bar {
background: #f4f7fb;
}
.learning-banner-wrap {
flex-shrink: 0;
padding: 10px 14px 0;
}
.learning-banner {
height: 128px;
overflow: hidden;
border-radius: 6px;
background: #dce7f5;
}
.learning-banner-item {
position: relative;
height: 128px;
overflow: hidden;
background: linear-gradient(135deg, #0b75bd, #33b3a6);
}
.learning-banner-item img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.learning-banner-fallback {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
padding: 0 22px;
color: #ffffff;
box-sizing: border-box;
background:
radial-gradient(circle at 14% 20%, rgba(255,255,255,.26), transparent 22%),
linear-gradient(135deg, #0b75bd, #35b6a8 55%, #f6b24d);
}
.learning-banner-title {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 28px 14px 12px;
color: #ffffff;
font-size: 16px;
font-weight: 700;
line-height: 1.35;
background: linear-gradient(to top, rgba(15, 23, 42, .64), transparent);
box-sizing: border-box;
}
.learning-banner-fallback .learning-banner-title {
position: static;
padding: 0;
background: transparent;
}
.learning-filter-card {
flex-shrink: 0;
margin: 14px 14px 0;
padding: 10px;
border-radius: 8px;
background: #ffffff;
}
.learning-list-scroll {
min-height: 0;
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
box-sizing: border-box;
}
.learning-filter-group {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
min-height: 72px;
overflow: hidden;
border-radius: 6px;
background: #eef6ff;
}
.learning-filter-group + .learning-filter-group {
margin-top: 8px;
}
.learning-filter-category {
display: flex;
align-items: center;
padding: 12px;
color: #ffffff;
box-sizing: border-box;
}
.learning-filter-category-text {
min-width: 0;
}
.learning-filter-category-title {
font-size: 15px;
font-weight: 800;
line-height: 1.2;
}
.learning-filter-options {
display: flex;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
background: rgba(255, 255, 255, .55);
}
.learning-filter-options::-webkit-scrollbar {
display: none;
}
.learning-filter-item {
flex: 0 0 72px;
min-width: 72px;
padding: 8px 2px 7px;
border-left: 1px solid rgba(255, 255, 255, .72);
text-align: center;
color: #5f6b7a;
font-size: 11px;
box-sizing: border-box;
}
.learning-filter-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin: 0 auto 5px;
border-radius: 50%;
color: #4f87c8;
font-size: 18px;
background: rgba(255, 255, 255, .78);
}
.learning-filter-item.no-icon {
display: flex;
align-items: center;
justify-content: center;
min-height: 58px;
color: #0b75bd;
font-weight: 700;
}
.learning-filter-item.active {
color: #0b75bd;
font-weight: 700;
background: rgba(255, 255, 255, .86);
}
.learning-filter-item.active .learning-filter-icon {
color: #ffffff;
background: #0b75bd;
}
.learning-filter-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.learning-section-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 16px 10px;
}
.learning-section-title {
color: #202733;
font-size: 20px;
font-weight: 800;
line-height: 1.2;
}
.learning-section-total {
color: #8b96a6;
font-size: 13px;
}
.learning-course-list {
padding: 0 12px 18px;
}
.learning-course-card {
display: flex;
gap: 14px;
min-height: 104px;
margin-bottom: 12px;
padding: 14px;
border-radius: 8px;
background: #ffffff;
box-sizing: border-box;
}
.learning-course-cover {
position: relative;
flex: 0 0 142px;
width: 142px;
height: 82px;
overflow: hidden;
border-radius: 4px;
background: #e6edf6;
}
.learning-course-cover img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.learning-course-cover-empty {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 8px;
color: #ffffff;
font-size: 13px;
text-align: center;
box-sizing: border-box;
background: linear-gradient(135deg, #3a7bd5, #8b5cf6);
}
.learning-course-info {
flex: 1;
min-width: 0;
}
.learning-course-title {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
}
.learning-course-title-text {
min-width: 0;
color: #202733;
font-size: 17px;
font-weight: 700;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.learning-course-badge {
flex-shrink: 0;
max-width: 72px;
padding: 1px 6px;
border: 1px solid #ffd1a8;
border-radius: 4px;
color: #f28a35;
font-size: 11px;
font-weight: 500;
line-height: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: #fff7ee;
}
.learning-course-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 9px;
}
.learning-course-intro {
margin-top: 8px;
color: #7a8798;
font-size: 12px;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.learning-empty {
padding: 22px 0 36px;
}
@media (max-width: 360px) {
.learning-filter-card {
margin-left: 10px;
margin-right: 10px;
padding: 8px;
}
.learning-filter-group {
grid-template-columns: 94px minmax(0, 1fr);
}
.learning-filter-category {
padding: 10px 8px;
}
.learning-filter-category-title {
font-size: 13px;
}
.learning-filter-icon {
width: 26px;
height: 26px;
font-size: 17px;
}
.learning-filter-item {
font-size: 11px;
}
.learning-course-cover {
flex-basis: 120px;
width: 120px;
}
}
</style>
<div id="app" class="learning-course-page" v-cloak>
<van-nav-bar title="学习教育" left-text="返回" left-arrow @click-left="historyBack" fixed placeholder></van-nav-bar>
<div class="learning-banner-wrap">
<van-swipe class="learning-banner" :autoplay="3500" indicator-color="#ffffff">
<van-swipe-item v-for="course in bannerCourses" :key="course.id" @click="openCourse(course)">
<div class="learning-banner-item">
<img v-if="getCoverUrl(course.cover)" :src="getCoverUrl(course.cover)" :alt="course.courseName">
<div v-else class="learning-banner-fallback">
<div class="learning-banner-title">{{ course.courseName || '学习教育' }}</div>
</div>
<div v-if="getCoverUrl(course.cover)" class="learning-banner-title">{{ course.courseName || '学习教育' }}</div>
</div>
</van-swipe-item>
<van-swipe-item v-if="!bannerCourses.length">
<div class="learning-banner-item learning-banner-fallback">
<div class="learning-banner-title">学习教育</div>
</div>
</van-swipe-item>
</van-swipe>
</div>
<div class="learning-filter-card">
<div class="learning-filter-group" v-for="group in filterGroups" :key="group.key">
<div class="learning-filter-category" :style="{background: group.color}">
<div class="learning-filter-category-text">
<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), 'no-icon': !item.icon}"
@click="selectFilter(item)">
<div v-if="item.icon" class="learning-filter-icon">
<van-icon :name="item.icon"></van-icon>
</div>
<div class="learning-filter-name">{{ item.name }}</div>
</div>
</div>
</div>
</div>
<div class="learning-list-scroll">
<div class="learning-section-head">
<div class="learning-section-title">{{ listTitle }}</div>
<div class="learning-section-total">共 {{ pageForm.totalCount || 0 }} 门</div>
</div>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model="loading"
:finished="finished"
:immediate-check="false"
finished-text="没有更多了"
@load="onLoad">
<div class="learning-course-list">
<div
class="learning-course-card"
v-for="course in courseList"
:key="course.id"
@click="openCourse(course)">
<div class="learning-course-cover">
<img v-if="getCoverUrl(course.cover)" :src="getCoverUrl(course.cover)" :alt="course.courseName">
<div v-else class="learning-course-cover-empty">{{ course.courseTypeName || '课程' }}</div>
</div>
<div class="learning-course-info">
<div class="learning-course-title">
<div class="learning-course-title-text">{{ course.courseName || '未命名课程' }}</div>
<div v-if="firstRecommendName(course.recommendFlags)" class="learning-course-badge">{{ firstRecommendName(course.recommendFlags) }}</div>
</div>
<div class="learning-course-meta">
<van-tag v-if="course.courseTypeName" plain type="primary">{{ course.courseTypeName }}</van-tag>
<van-tag v-if="course.lecturerName" plain type="success">{{ course.lecturerName }}</van-tag>
</div>
<div class="learning-course-intro">{{ course.courseIntro || '暂无课程介绍' }}</div>
</div>
</div>
</div>
</van-list>
</van-pull-refresh>
<van-empty v-if="!loading && !courseList.length" class="learning-empty" description="暂无相关课程"></van-empty>
</div>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
store,
data() {
return {
apiBase: "/platform/learning/course/display",
query: {
keyword: "",
courseTypeId: "",
recommendFlag: ""
},
pageForm: {
pageNumber: 1,
pageSize: 10,
totalCount: 0
},
courseList: [],
bannerCourses: [],
courseTypeOptions: [],
recommendOptions: [],
loading: false,
finished: false,
refreshing: false,
requestSeq: 0
}
},
computed: {
filterGroups() {
const recommendItems = this.recommendOptions.map((item, index) => ({
key: "recommend_" + item.code,
type: "recommend",
value: item.code,
name: item.name,
icon: this.recommendIcon(index)
}))
const typeItems = this.courseTypeOptions.map((item, index) => ({
key: "type_" + item.id,
type: "type",
value: item.id,
name: item.typeName,
icon: this.typeIcon(index)
}))
return [
{
key: "recommend",
name: "推荐标识",
color: "linear-gradient(135deg, #ff8a3d, #ffbf5e)",
items: recommendItems
},
{
key: "type",
name: "课程类型",
color: "linear-gradient(135deg, #2878d7, #64a8f4)",
items: typeItems
}
]
},
listTitle() {
const recommend = this.recommendOptions.find(item => item.code === this.query.recommendFlag)
const type = this.courseTypeOptions.find(item => item.id === this.query.courseTypeId)
if (recommend && type) {
return recommend.name + " · " + type.typeName
}
if (recommend) {
return recommend.name
}
if (type) {
return type.typeName
}
return "我的课堂"
}
},
methods: {
historyBack,
async loadOptions() {
const [typeResp, recommendResp] = await Promise.all([
this.$axios.post(this.apiBase + "/courseTypes"),
this.$axios.post(this.apiBase + "/recommendOptions")
])
if (typeResp.code === 0) {
this.courseTypeOptions = typeResp.data || []
}
if (recommendResp.code === 0) {
this.recommendOptions = recommendResp.data || []
}
},
onRefresh() {
this.finished = false
this.pageForm.pageNumber = 1
this.courseList = []
this.onLoad()
},
onLoad() {
this.fetchCourses()
},
fetchCourses() {
this.loading = true
const requestSeq = ++this.requestSeq
this.$axios.post(this.apiBase + "/pageData", {
keyword: this.query.keyword,
courseTypeId: this.query.courseTypeId,
recommendFlag: this.query.recommendFlag,
pageNumber: this.pageForm.pageNumber,
pageSize: this.pageForm.pageSize
}).then((res) => {
if (requestSeq !== this.requestSeq) return
if (res.code === 0) {
const data = res.data || {}
const list = data.list || []
if (this.refreshing || this.pageForm.pageNumber === 1) {
this.courseList = []
}
this.courseList = this.courseList.concat(list)
this.pageForm.totalCount = data.totalCount || 0
this.pageForm.pageNumber += 1
this.finished = this.courseList.length >= this.pageForm.totalCount
this.syncBannerCourses()
} else {
this.finished = true
this.$toast(res.msg || "查询失败")
}
}).finally(() => {
if (requestSeq === this.requestSeq) {
this.loading = false
this.refreshing = false
}
})
},
doSearch() {
this.pageForm.pageNumber = 1
this.pageForm.totalCount = 0
this.courseList = []
this.finished = false
this.loading = true
this.onLoad()
},
selectFilter(item) {
if (!item) return
if (item.type === "recommend") {
this.query.recommendFlag = item.value || ""
} else if (item.type === "type") {
this.query.courseTypeId = item.value || ""
}
this.doSearch()
},
isFilterActive(item) {
if (!item) return false
if (item.type === "recommend") {
return (this.query.recommendFlag || "") === (item.value || "")
}
if (item.type === "type") {
return (this.query.courseTypeId || "") === (item.value || "")
}
return false
},
syncBannerCourses() {
const withCover = this.courseList.filter(item => this.getCoverUrl(item.cover)).slice(0, 5)
this.bannerCourses = (withCover.length ? withCover : this.courseList.slice(0, 5))
},
getCoverUrl(cover) {
if (!cover) return ""
if (Array.isArray(cover)) {
return cover.length ? (cover[0].url || (cover[0].response && cover[0].response.data) || cover[0].data || "") : ""
}
if (typeof cover === "string") {
const text = cover.trim()
if (!text) return ""
if (text.startsWith("[")) {
try {
const files = JSON.parse(text)
return files.length ? (files[0].url || (files[0].response && files[0].response.data) || files[0].data || "") : ""
} catch (e) {
return ""
}
}
return text
}
return ""
},
openCourse(course) {
if (!course || !course.id) return
pjaxReplace("/platform/learning/course/h5/study?id=" + course.id)
},
splitFlags(flags) {
return flags ? flags.split(",").filter(Boolean) : []
},
getRecommendName(code) {
const item = this.recommendOptions.find(v => v.code === code)
return item ? item.name : code
},
firstRecommendName(flags) {
const codes = this.splitFlags(flags)
return codes.length ? this.getRecommendName(codes[0]) : ""
},
recommendIcon(index) {
const icons = ["fire-o", "star-o", "award-o", "gem-o", "flag-o", "good-job-o"]
return icons[index % icons.length]
},
typeIcon(index) {
const icons = ["video-o", "records-o", "description-o", "bookmark-o", "cluster-o", "desktop-o"]
return icons[index % icons.length]
},
palette(index) {
const colors = ["#28a6df", "#f2b84b", "#f46d5f", "#7b8fda", "#59bbb2", "#2d7db8", "#ee755b", "#f3c256", "#ef9461", "#8ba4dc"]
return colors[index % colors.length]
}
},
async mounted() {
await this.loadOptions()
this.onLoad()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,742 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<style>
.learning-study-page {
height: 100vh;
height: 100dvh;
overflow: hidden;
display: flex;
flex-direction: column;
background: #f5f6f8;
}
.study-hero {
position: relative;
flex-shrink: 0;
height: 195px;
background: linear-gradient(135deg, #7554e8, #48b7f0);
overflow: hidden;
}
.study-hero img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.study-hero-empty {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 28px;
color: #ffffff;
font-size: 26px;
font-weight: 800;
line-height: 1.35;
text-align: center;
box-sizing: border-box;
}
.study-back {
position: absolute;
top: 10px;
left: 8px;
z-index: 2;
width: 38px;
height: 38px;
border-radius: 50%;
color: #ffffff;
font-size: 24px;
background: rgba(15, 23, 42, .22);
display: flex;
align-items: center;
justify-content: center;
}
.study-title-block {
flex-shrink: 0;
padding: 16px 18px 10px;
background: #ffffff;
}
.study-title {
color: #202733;
font-size: 20px;
font-weight: 800;
line-height: 1.4;
}
.study-meta {
display: flex;
align-items: center;
gap: 18px;
margin-top: 14px;
color: #8a93a3;
font-size: 14px;
}
.study-meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.study-tabs {
min-height: 0;
flex: 1;
display: flex;
flex-direction: column;
background: #ffffff;
}
.study-tabs .van-tabs__content {
min-height: 0;
flex: 1;
overflow: hidden;
}
.study-tabs .van-tabs__wrap {
flex-shrink: 0;
}
.study-tabs .van-tabs__track {
height: 100%;
}
.study-tabs .van-tab__pane {
height: 100%;
overflow: hidden;
}
.study-intro-scroll,
.study-catalog-scroll {
height: 100%;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
background: #f5f6f8;
}
.study-summary {
display: flex;
justify-content: space-between;
padding: 18px;
color: #8b94a3;
font-size: 16px;
}
.study-summary strong {
color: #202733;
font-size: 20px;
}
.study-card {
margin: 18px;
padding: 14px 0;
border-radius: 10px;
background: #ffffff;
box-shadow: 0 8px 20px rgba(15, 23, 42, .04);
overflow: hidden;
}
.study-chapter {
padding: 0 18px;
}
.study-chapter-head {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0 16px;
color: #202733;
font-size: 17px;
font-weight: 800;
}
.study-chapter-dot {
width: 14px;
height: 14px;
border: 2px solid #ff9b2f;
border-radius: 50%;
box-sizing: border-box;
flex-shrink: 0;
}
.study-resource {
display: flex;
gap: 10px;
padding: 14px 0 12px 18px;
border-top: 1px solid #f0f2f5;
}
.study-resource-icon {
width: 21px;
height: 21px;
margin-top: 2px;
border-radius: 3px;
color: #ffffff;
font-size: 15px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.study-resource-icon.video {
background: #ffbd2e;
}
.study-resource-icon.audio {
background: #33b3a6;
}
.study-resource-icon.image {
background: #5a94f2;
}
.study-resource-icon.doc {
background: #ff6868;
}
.study-resource-main {
min-width: 0;
flex: 1;
}
.study-resource-title {
color: #5c6470;
font-size: 15px;
line-height: 1.45;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.study-resource-status {
margin-top: 8px;
color: #a2a9b5;
font-size: 15px;
}
.study-intro {
padding: 18px;
color: #4e5969;
font-size: 15px;
line-height: 1.8;
background: #ffffff;
min-height: 180px;
}
.study-player-popup {
width: 100%;
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
border-radius: 0;
background: #000000;
}
.study-player-head {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
color: #ffffff;
background: #101828;
border-bottom: 1px solid rgba(255, 255, 255, .08);
}
.study-player-title {
min-width: 0;
flex: 1;
color: #ffffff;
font-size: 16px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.study-player-body {
min-height: 0;
flex: 1;
overflow: auto;
padding: 0;
box-sizing: border-box;
background: #000000;
}
.study-media {
width: 100%;
height: 100%;
max-height: none;
background: #000000;
border-radius: 0;
object-fit: contain;
}
.study-audio-wrap {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 220px;
border-radius: 0;
background: linear-gradient(135deg, #10233f, #0f766e);
}
.study-audio-wrap audio {
width: 88%;
}
.study-image-preview {
display: block;
max-width: 100%;
margin: 0 auto;
border-radius: 0;
background: #ffffff;
}
.study-doc-frame {
width: 100%;
height: 100%;
border: 0;
border-radius: 0;
background: #ffffff;
}
.study-file-fallback {
padding: 46px 18px;
color: #7a8798;
font-size: 15px;
text-align: center;
background: #ffffff;
border-radius: 0;
}
.study-player-exit {
flex-shrink: 0;
margin-left: 12px;
color: #ffffff;
font-size: 14px;
}
.study-player-actions {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 14px;
margin-left: 12px;
color: #ffffff;
font-size: 14px;
}
</style>
<div id="app" class="learning-study-page" v-cloak>
<div class="study-hero">
<div class="study-back" @click="goBack"><van-icon name="arrow-left"></van-icon></div>
<img v-if="coverUrl" :src="coverUrl" :alt="course.courseName">
<div v-else class="study-hero-empty">{{ course.courseName || '课程学习' }}</div>
</div>
<div class="study-title-block">
<div class="study-title">{{ course.courseName || '课程学习' }}</div>
<div class="study-meta">
<span class="study-meta-item"><van-icon name="eye-o"></van-icon>{{ studyCountText }}</span>
<span class="study-meta-item"><van-icon name="bookmark-o"></van-icon>收藏</span>
<span class="study-meta-item"><van-icon name="share-o"></van-icon>分享</span>
</div>
</div>
<van-tabs v-model="activeTab" class="study-tabs" color="#2d9cff" line-width="34px" title-active-color="#202733">
<van-tab title="课程介绍" name="intro">
<div class="study-intro-scroll">
<div class="study-intro">{{ course.courseIntro || '暂无课程介绍' }}</div>
</div>
</van-tab>
<van-tab title="课程目录" name="catalog">
<div class="study-catalog-scroll">
<div class="study-summary">
<div>共 <strong>{{ chapterCount }}</strong> 个章节,<strong>{{ totalMinutes }}</strong> 分钟</div>
<div>总学习进度 <strong>{{ totalProgress }}%</strong></div>
</div>
<div v-for="chapter in catalogList" :key="chapter.id" class="study-card">
<div class="study-chapter">
<div class="study-chapter-head" @click="selectChapter(chapter)">
<span class="study-chapter-dot"></span>
<span>{{ chapter.title }}</span>
</div>
<div
v-for="resource in chapter.resources"
:key="resource.id"
class="study-resource"
@click="openResource(resource)">
<div class="study-resource-icon" :class="resourceIconClass(resource)">
<van-icon :name="resourceIcon(resource)"></van-icon>
</div>
<div class="study-resource-main">
<div class="study-resource-title">{{ resource.title }}</div>
<div class="study-resource-status">{{ statusText(resource) }}</div>
</div>
</div>
</div>
</div>
<van-empty v-if="!catalogList.length" description="暂无课程目录"></van-empty>
</div>
</van-tab>
</van-tabs>
<van-popup v-model="playerVisible" position="bottom" class="study-player-popup" :overlay="false">
<div class="study-player-head">
<div class="study-player-title">{{ selectedResource.title || '学习资料' }}</div>
<div class="study-player-actions">
<span v-if="selectedResource.resourceType === 'video'" @click="enterVideoFullscreen">横屏</span>
<span @click="closePlayer">退出</span>
</div>
</div>
<div class="study-player-body">
<video
v-if="selectedResource.resourceType === 'video'"
ref="mediaPlayer"
class="study-media"
:src="fileUrl"
controls
playsinline
webkit-playsinline
x5-video-player-type="h5"
x5-video-orientation="landscape|portrait"
x-webkit-airplay="allow"
@play="startStudy"
@timeupdate="saveProgress"
@ended="finishStudy(false)">
</video>
<div v-else-if="selectedResource.resourceType === 'audio'" class="study-audio-wrap">
<audio
ref="mediaPlayer"
:src="fileUrl"
controls
@play="startStudy"
@timeupdate="saveProgress"
@ended="finishStudy(false)">
</audio>
</div>
<img v-else-if="selectedResource.resourceType === 'image'" class="study-image-preview" :src="fileUrl" :alt="selectedResource.title">
<iframe v-else-if="canInlinePreview(selectedResource)" class="study-doc-frame" :src="inlinePreviewUrl"></iframe>
<div v-else class="study-file-fallback">
<p>该资料暂不支持内嵌预览</p>
<van-button type="info" size="small" @click="openFile">打开资料</van-button>
</div>
</div>
</van-popup>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
courseId: "",
apiBase: "/platform/learning/course/display",
recordApi: "/platform/learning/study/record",
activeTab: "catalog",
course: {},
treeData: [],
selectedResource: {},
playerVisible: false,
currentSegmentId: "",
studying: false,
pendingSeconds: 0,
heartbeatTimer: null,
navigatingBack: false
}
},
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
},
catalogList() {
const result = []
;(this.treeData || []).forEach(node => this.appendCatalogNode(node, result))
return result
},
chapterCount() {
return this.catalogList.length
},
totalMinutes() {
const seconds = this.catalogList.reduce((sum, chapter) => {
return sum + chapter.resources.reduce((value, item) => value + Number(item.durationSeconds || 0), 0)
}, 0)
return Math.max(0, Math.ceil(seconds / 60))
},
totalProgress() {
const resources = []
this.catalogList.forEach(chapter => resources.push.apply(resources, chapter.resources))
if (!resources.length) return 0
const total = resources.reduce((sum, item) => sum + Number(item.progressPercent || 0), 0)
return Math.floor(total / resources.length)
},
studyCountText() {
return this.course.lecturerName ? this.course.lecturerName : "开始学习"
}
},
methods: {
getQuery(name) {
return new URLSearchParams(window.location.search).get(name) || ""
},
async loadCourse() {
// 加载课程基础信息,用独立请求变量承接接口请求。
const courseRequest = this.$axios.post(this.apiBase + "/courseInfo", { id: this.courseId })
const resp = await courseRequest
if (resp.code === 0) {
this.course = resp.data || {}
} else {
this.$toast(resp.msg || "课程不存在")
}
},
async loadTree() {
// 加载课程章节与资源树,供移动端目录和播放器使用。
const treeRequest = this.$axios.post(this.apiBase + "/studyTree", { courseId: this.courseId })
const resp = await treeRequest
if (resp.code === 0) {
this.treeData = resp.data || []
}
},
appendCatalogNode(node, result) {
if (!node) return
if (node.type === "resource") return
const resources = this.collectResources(node.children || [])
if (node.nodeType === "chapter" || resources.length) {
result.push({
id: node.id,
title: node.title,
resources: resources
})
}
;(node.children || []).forEach(child => {
if (child.type !== "resource" && child.nodeType === "chapter") {
this.appendCatalogNode(child, result)
}
})
},
collectResources(nodes) {
const resources = []
;(nodes || []).forEach(node => {
if (node.type === "resource") {
resources.push(node)
} else {
resources.push.apply(resources, this.collectResources(node.children || []))
}
})
return resources
},
selectChapter(chapter) {
if (chapter && chapter.resources && chapter.resources.length) {
this.openResource(chapter.resources[0])
}
},
openResource(resource) {
if (!resource || !resource.id) return
this.pauseMedia()
this.finishStudy(false)
this.selectedResource = resource
this.playerVisible = true
if (!["video", "audio"].includes(resource.resourceType)) {
this.$nextTick(() => this.startStudy())
} else {
this.$nextTick(() => this.resumeMediaPosition())
}
},
resumeMediaPosition() {
const player = this.$refs.mediaPlayer
const position = Number(this.selectedResource.lastPositionSeconds || 0)
if (player && position > 0) {
player.currentTime = position
}
},
async startStudy() {
if (!this.selectedResource.id || this.studying) return
// 创建学习片段并记录当前媒体播放位置,用于续学和学习时长统计。
const startRequest = this.$axios.post(this.recordApi + "/start", {
courseId: this.courseId,
resourceId: this.selectedResource.id,
positionSeconds: this.getMediaPosition()
})
const resp = await startRequest
if (resp.code !== 0) {
this.$toast(resp.msg || "开始学习失败")
return
}
this.currentSegmentId = resp.data.segmentId
this.studying = true
this.pendingSeconds = 0
this.applyRecordState(resp.data)
this.startHeartbeatTimer()
},
async heartbeat() {
if (!this.currentSegmentId || this.pendingSeconds <= 0) return
const seconds = this.pendingSeconds
this.pendingSeconds = 0
// 周期性上报有效学习秒数和播放位置,后端据此刷新课程进度。
const heartbeatRequest = this.$axios.post(this.recordApi + "/heartbeat", {
segmentId: this.currentSegmentId,
activeSeconds: seconds,
positionSeconds: this.getMediaPosition()
})
const resp = await heartbeatRequest
if (resp.code === 0) {
this.applyRecordState(resp.data)
}
},
async finishStudy(force) {
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 finishRequest = this.$axios.post(this.recordApi + "/finish", {
segmentId: segmentId,
activeSeconds: seconds,
positionSeconds: this.getMediaPosition()
})
await finishRequest
if (force) return
this.loadTree()
},
startHeartbeatTimer() {
this.stopHeartbeatTimer()
this.heartbeatTimer = setInterval(() => {
if (!this.studying || document.hidden) return
if (["video", "audio"].includes(this.selectedResource.resourceType)) {
const player = this.$refs.mediaPlayer
if (!player || player.paused || player.ended) return
}
this.pendingSeconds += 1
if (this.pendingSeconds >= 15) {
this.heartbeat()
}
}, 1000)
},
stopHeartbeatTimer() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
},
saveProgress() {
const position = this.getMediaPosition()
if (!position || !this.selectedResource.id) return
this.$set(this.selectedResource, "lastPositionSeconds", position)
},
applyRecordState(data) {
if (!this.selectedResource.id || !data) return
this.$set(this.selectedResource, "progressPercent", Number(data.progressPercent || 0))
this.$set(this.selectedResource, "studySeconds", Number(data.studySeconds || 0))
this.$set(this.selectedResource, "completeStatus", data.completeStatus || "studying")
if (data.lastPositionSeconds !== undefined) {
this.$set(this.selectedResource, "lastPositionSeconds", Number(data.lastPositionSeconds || 0))
}
},
getMediaPosition() {
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return 0
const player = this.$refs.mediaPlayer
return player ? Math.floor(player.currentTime || 0) : Number(this.selectedResource.lastPositionSeconds || 0)
},
statusText(resource) {
if (!resource || resource.completeStatus === "not_started") return "未学习"
if (resource.completeStatus === "completed") return "已完成"
return "学习中 " + Number(resource.progressPercent || 0) + "%"
},
resourceIcon(resource) {
if (resource.resourceType === "video") return "video"
if (resource.resourceType === "audio") return "music-o"
if (resource.resourceType === "image") return "photo-o"
return "description"
},
resourceIconClass(resource) {
if (resource.resourceType === "video") return "video"
if (resource.resourceType === "audio") return "audio"
if (resource.resourceType === "image") return "image"
return "doc"
},
getFileUrl(value) {
if (!value) return ""
if (Array.isArray(value)) {
return value.length ? (value[0].url || (value[0].response && value[0].response.data) || value[0].data || "") : ""
}
if (typeof value === "string") {
const text = value.trim()
if (!text) return ""
if (text.startsWith("[")) {
try {
const files = JSON.parse(text)
return files.length ? (files[0].url || (files[0].response && files[0].response.data) || files[0].data || "") : ""
} catch (e) {
return ""
}
}
return text
}
return ""
},
getFileId(value) {
const url = this.getFileUrl(value)
if (!url) return ""
const matched = url.match(/[?&]id=([^&]+)/)
if (matched) return decodeURIComponent(matched[1])
if (!url.includes("/") && !url.includes(".")) return url
return ""
},
canInlinePreview(resource) {
const type = resource.resourceType || ""
const ext = (resource.fileExt || "").toLowerCase()
return ["pdf", "ppt", "word"].includes(type) || ["pdf", "ppt", "pptx", "doc", "docx"].includes(ext)
},
openFile() {
if (this.fileUrl) {
window.open(this.fileUrl)
}
},
pauseMedia() {
const player = this.$refs.mediaPlayer
if (player && player.pause) {
player.pause()
}
},
enterVideoFullscreen() {
const player = this.$refs.mediaPlayer
if (!player) return
const requestFullscreen = player.requestFullscreen || player.webkitRequestFullscreen || player.mozRequestFullScreen || player.msRequestFullscreen
if (requestFullscreen) {
requestFullscreen.call(player)
} else if (player.webkitEnterFullscreen) {
player.webkitEnterFullscreen()
}
if (screen.orientation && screen.orientation.lock) {
screen.orientation.lock("landscape").catch(() => {})
}
},
closePlayer() {
this.pauseMedia()
this.finishStudy(false)
this.playerVisible = false
},
async goBack() {
if (this.navigatingBack) return
this.navigatingBack = true
try {
await this.finishStudy(true)
} finally {
this.navigateBack()
}
},
navigateBack() {
if (window.history.length > 1) {
window.history.back()
return
}
$.pjax({
url: "/platform/learning/course/h5",
container: "#container",
maxCacheLength: 0,
push: false,
replace: true,
fragment: "#container",
timeout: 8000
})
}
},
async mounted() {
this.courseId = this.getQuery("id")
if (!this.courseId) {
this.$toast("请选择课程")
return
}
await this.loadCourse()
await this.loadTree()
},
beforeDestroy() {
this.finishStudy(true)
this.stopHeartbeatTimer()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,371 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<style>
.my-study-page {
min-height: 100vh;
background: #f3f7fb;
color: #202733;
}
.my-study-hero {
position: relative;
padding: 86px 20px 72px;
color: #ffffff;
background: linear-gradient(135deg, #0ca7dd 0%, #0699d6 58%, #0f8fd7 100%);
overflow: hidden;
}
.my-study-hero::after {
content: "";
position: absolute;
right: -74px;
bottom: -108px;
width: 260px;
height: 260px;
border-radius: 50%;
background: rgba(255, 255, 255, .08);
}
.my-study-status {
display: block;
position: relative;
z-index: 1;
}
.my-study-time {
font-size: 15px;
color: rgba(255, 255, 255, .82);
}
.my-study-time strong {
margin: 0 3px;
color: #ffffff;
font-size: 26px;
font-weight: 800;
}
.my-study-stats {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0;
margin-top: 26px;
text-align: center;
}
.my-study-stat-value {
font-size: 23px;
font-weight: 800;
line-height: 1.2;
}
.my-study-stat-label {
margin-top: 7px;
color: rgba(255, 255, 255, .78);
font-size: 13px;
line-height: 1.25;
}
.my-study-main {
position: relative;
margin-top: -36px;
padding: 0 20px 28px;
z-index: 2;
}
.my-study-assistant {
display: flex;
align-items: center;
gap: 14px;
min-height: 70px;
padding: 13px 14px;
border-radius: 8px;
background: #eef6ff;
box-sizing: border-box;
box-shadow: 0 8px 18px rgba(17, 101, 166, .08);
}
.my-study-ai {
flex-shrink: 0;
font-size: 27px;
font-weight: 900;
font-style: italic;
color: #1a75ff;
letter-spacing: 0;
}
.my-study-ai span {
color: #6b46ff;
}
.my-study-assistant-text {
flex: 1;
min-width: 0;
color: #30445c;
font-size: 15px;
line-height: 1.35;
}
.my-study-assistant-btn {
flex-shrink: 0;
padding: 7px 16px;
border-radius: 18px;
color: #5d8fd8;
font-size: 14px;
font-weight: 700;
background: #ffffff;
}
.my-study-section {
margin-top: 20px;
padding: 16px 0;
border-radius: 8px;
background: #ffffff;
}
.my-study-section-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px 14px;
}
.my-study-section-title {
font-size: 20px;
font-weight: 800;
}
.my-study-all {
display: inline-flex;
align-items: center;
color: #9aa3b1;
font-size: 13px;
}
.my-study-course-scroll {
display: flex;
gap: 14px;
overflow-x: auto;
overflow-y: hidden;
padding: 0 14px;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.my-study-course-scroll::-webkit-scrollbar {
display: none;
}
.my-study-course-card {
flex: 0 0 154px;
width: 154px;
overflow: hidden;
border-radius: 5px;
background: #f7fbff;
}
.my-study-cover {
width: 154px;
height: 88px;
background: linear-gradient(135deg, #8657f2, #56c4ec);
}
.my-study-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%;
padding: 10px;
color: #ffffff;
font-size: 14px;
font-weight: 700;
text-align: center;
box-sizing: border-box;
}
.my-study-course-name {
height: 48px;
padding: 8px 10px 0;
color: #293241;
font-size: 15px;
font-weight: 700;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.my-study-course-foot {
padding: 8px 10px 10px;
color: #b5bdc8;
font-size: 13px;
text-align: right;
}
.my-study-list {
margin-top: 18px;
}
.my-study-list-card {
display: flex;
gap: 12px;
margin-bottom: 12px;
padding: 12px;
border-radius: 8px;
background: #ffffff;
}
.my-study-list-cover {
flex: 0 0 108px;
width: 108px;
height: 66px;
border-radius: 5px;
overflow: hidden;
background: linear-gradient(135deg, #8657f2, #56c4ec);
}
.my-study-list-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.my-study-list-info {
min-width: 0;
flex: 1;
}
.my-study-list-title {
color: #202733;
font-size: 16px;
font-weight: 700;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.my-study-list-meta {
margin-top: 10px;
color: #8f99a8;
font-size: 13px;
}
</style>
<div id="app" class="my-study-page" v-cloak>
<div class="my-study-hero">
<div class="my-study-status">
<div class="my-study-time">
累计学习时长 <strong>{{ summary.studyHour || 0 }}</strong> 小时 <strong>{{ summary.studyMinute || 0 }}</strong> 分钟
</div>
</div>
<div class="my-study-stats">
<div v-for="item in statItems" :key="item.label">
<div class="my-study-stat-value">{{ item.value }}</div>
<div class="my-study-stat-label">{{ item.label }}</div>
</div>
</div>
</div>
<div class="my-study-main">
<div class="my-study-assistant">
<div class="my-study-ai"><span>AI</span>学助手</div>
<div class="my-study-assistant-text">来问问AI学习助手,获取专属学习计划</div>
<div class="my-study-assistant-btn">查看</div>
</div>
<div class="my-study-section">
<div class="my-study-section-head">
<div class="my-study-section-title">我学习的课程</div>
<div class="my-study-all" @click="scrollToList">全部 <van-icon name="arrow"></van-icon></div>
</div>
<div class="my-study-course-scroll">
<div
v-for="course in courses"
:key="course.courseId"
class="my-study-course-card"
@click="openCourse(course)">
<div class="my-study-cover">
<img v-if="getCoverUrl(course.cover)" :src="getCoverUrl(course.cover)" :alt="course.courseName">
<div v-else class="my-study-cover-empty">{{ course.courseName || '课程' }}</div>
</div>
<div class="my-study-course-name">{{ course.courseName || '未命名课程' }}</div>
<div class="my-study-course-foot">已学完{{ course.completedOutlineCount || 0 }}讲</div>
</div>
</div>
<van-empty v-if="!loading && !courses.length" description="暂无学习课程"></van-empty>
</div>
<div ref="courseList" class="my-study-list">
<div
v-for="course in courses"
:key="'list_' + course.courseId"
class="my-study-list-card"
@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">{{ course.courseName || '课程' }}</div>
</div>
<div class="my-study-list-info">
<div class="my-study-list-title">{{ course.courseName || '未命名课程' }}</div>
<div class="my-study-list-meta">学习进度 {{ course.progressPercent || 0 }}% · {{ course.studyTimeText || '0秒' }}</div>
</div>
</div>
</div>
</div>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
apiBase: "/platform/learning/study/record",
summary: {},
courses: [],
loading: false
}
},
computed: {
statItems() {
return [
{ label: "已学完课程", value: this.summary.completedCourseCount || 0 },
{ label: "学习专题", value: 0 }
]
}
},
methods: {
async loadData() {
this.loading = true
try {
const [summaryResp, courseResp] = await Promise.all([
this.$axios.post(this.apiBase + "/h5Summary"),
this.$axios.post(this.apiBase + "/h5Courses")
])
if (summaryResp.code === 0) {
this.summary = summaryResp.data || {}
}
if (courseResp.code === 0) {
this.courses = courseResp.data || []
}
} finally {
this.loading = 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.courseId) return
pjaxReplace("/platform/learning/course/h5/study?id=" + course.courseId)
},
scrollToList() {
this.$refs.courseList && this.$refs.courseList.scrollIntoView({ behavior: "smooth", block: "start" })
}
},
mounted() {
this.loadData()
}
})
</script>
<!--#
}
#-->