feat: 学习平台优化
This commit is contained in:
@@ -15,6 +15,7 @@ import com.budwk.app.sys.services.SysConfigService;
|
|||||||
import com.budwk.app.sys.services.SysFileService;
|
import com.budwk.app.sys.services.SysFileService;
|
||||||
import com.budwk.app.sys.utils.SysFileMinIoUtil;
|
import com.budwk.app.sys.utils.SysFileMinIoUtil;
|
||||||
import com.google.common.net.HttpHeaders;
|
import com.google.common.net.HttpHeaders;
|
||||||
|
import com.budwk.app.zhgh.learning.service.LearningFileAccessService;
|
||||||
import io.swagger.annotations.Api;
|
import io.swagger.annotations.Api;
|
||||||
import io.swagger.annotations.ApiOperation;
|
import io.swagger.annotations.ApiOperation;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -52,6 +53,9 @@ public class SysFileController {
|
|||||||
private SysConfigService sysConfigService;
|
private SysConfigService sysConfigService;
|
||||||
private SysFileMinIoUtil sysFileMinIoUtil;
|
private SysFileMinIoUtil sysFileMinIoUtil;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private LearningFileAccessService learningFileAccessService;
|
||||||
|
|
||||||
@At("")
|
@At("")
|
||||||
@Ok("beetl:/platform/sys/file/index.html")
|
@Ok("beetl:/platform/sys/file/index.html")
|
||||||
@SaCheckPermission("sys.manager.file")
|
@SaCheckPermission("sys.manager.file")
|
||||||
@@ -124,6 +128,7 @@ public class SysFileController {
|
|||||||
@Ok("void")
|
@Ok("void")
|
||||||
@SaCheckLogin
|
@SaCheckLogin
|
||||||
public void download(String id, HttpServletRequest request, HttpServletResponse response) throws IOException {
|
public void download(String id, HttpServletRequest request, HttpServletResponse response) throws IOException {
|
||||||
|
if (!learningFileAccessService.check(id, true, response)) return;
|
||||||
sysFileService.download(id, request, response);
|
sysFileService.download(id, request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +137,7 @@ public class SysFileController {
|
|||||||
@SaCheckLogin
|
@SaCheckLogin
|
||||||
@ApiOperation("文件管理-转换PDF")
|
@ApiOperation("文件管理-转换PDF")
|
||||||
public void convertPDF(String id, HttpServletRequest request, HttpServletResponse response) throws IOException {
|
public void convertPDF(String id, HttpServletRequest request, HttpServletResponse response) throws IOException {
|
||||||
|
if (!learningFileAccessService.check(id, true, response)) return;
|
||||||
sysFileService.convertPDF(id, request, response);
|
sysFileService.convertPDF(id, request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,11 +176,82 @@ public class SysFileController {
|
|||||||
return Result.success(files);
|
return Result.success(files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@At
|
||||||
|
@Ok("void")
|
||||||
|
@SaCheckLogin
|
||||||
|
public void preview(String id, HttpServletRequest request, HttpServletResponse response) throws IOException {
|
||||||
|
if (!learningFileAccessService.check(id, false, response)) return;
|
||||||
|
Sys_file file = sysFileService.detail(id);
|
||||||
|
String contentType = switch (StrUtil.blankToDefault(file.getSuffix(), "").toLowerCase()) {
|
||||||
|
case "jpg", "jpeg" -> "image/jpeg";
|
||||||
|
case "png" -> "image/png";
|
||||||
|
case "gif" -> "image/gif";
|
||||||
|
case "webp" -> "image/webp";
|
||||||
|
case "bmp" -> "image/bmp";
|
||||||
|
case "mp3" -> "audio/mpeg";
|
||||||
|
case "aac" -> "audio/aac";
|
||||||
|
case "wav" -> "audio/wav";
|
||||||
|
case "ogg" -> "audio/ogg";
|
||||||
|
case "m4a" -> "audio/mp4";
|
||||||
|
case "pdf" -> "application/pdf";
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
if (contentType == null) {
|
||||||
|
response.sendError(415, "该资料不支持在线预览");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
byte[] bytes = SysFileEngineTypeEnum.LOCAL.getValue().equals(file.getEngine())
|
||||||
|
? FileUtil.readBytes(file.getStoragePath())
|
||||||
|
: SysFileMinIoUtil.getFileBytes(file.getBucket(), file.getStoragePath());
|
||||||
|
response.setContentType(contentType);
|
||||||
|
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "inline");
|
||||||
|
if (contentType.startsWith("audio/")) {
|
||||||
|
response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
|
||||||
|
String range = request.getHeader(HttpHeaders.RANGE);
|
||||||
|
if (StrUtil.isNotBlank(range) && range.startsWith("bytes=") && !range.contains(",")) {
|
||||||
|
long start;
|
||||||
|
long end;
|
||||||
|
try {
|
||||||
|
String specification = range.substring(6).trim();
|
||||||
|
if (!specification.matches("[0-9]*-[0-9]*")) throw new NumberFormatException();
|
||||||
|
String[] bounds = specification.split("-", -1);
|
||||||
|
if (bounds[0].isEmpty()) {
|
||||||
|
long length = Long.parseLong(bounds[1]);
|
||||||
|
if (length <= 0) throw new NumberFormatException();
|
||||||
|
start = Math.max(0, bytes.length - length);
|
||||||
|
end = bytes.length - 1;
|
||||||
|
} else {
|
||||||
|
start = Long.parseLong(bounds[0]);
|
||||||
|
end = bounds[1].isEmpty() ? bytes.length - 1 : Math.min(Long.parseLong(bounds[1]), bytes.length - 1);
|
||||||
|
}
|
||||||
|
if (start >= bytes.length || end < start) throw new NumberFormatException();
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes */" + bytes.length);
|
||||||
|
response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int length = (int) (end - start + 1);
|
||||||
|
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
|
||||||
|
response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes " + start + "-" + end + "/" + bytes.length);
|
||||||
|
response.setContentLength(length);
|
||||||
|
try (OutputStream out = response.getOutputStream()) {
|
||||||
|
out.write(bytes, (int) start, length);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.setContentLength(bytes.length);
|
||||||
|
try (OutputStream out = response.getOutputStream()) {
|
||||||
|
out.write(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@At
|
@At
|
||||||
@SaCheckLogin
|
@SaCheckLogin
|
||||||
@ApiOperation("文件管理-视频播放")
|
@ApiOperation("文件管理-视频播放")
|
||||||
@Ok("void")
|
@Ok("void")
|
||||||
public void videoPlay(String id, HttpServletRequest request, HttpServletResponse response) {
|
public void videoPlay(String id, HttpServletRequest request, HttpServletResponse response) throws IOException {
|
||||||
|
if (!learningFileAccessService.check(id, false, response)) return;
|
||||||
Sys_file sys_file = sysFileService.detail(id);
|
Sys_file sys_file = sysFileService.detail(id);
|
||||||
byte[] bytes = SysFileMinIoUtil.getFileBytes(sys_file.getBucket(), sys_file.getStoragePath());
|
byte[] bytes = SysFileMinIoUtil.getFileBytes(sys_file.getBucket(), sys_file.getStoragePath());
|
||||||
|
|
||||||
|
|||||||
+30
-3
@@ -11,6 +11,7 @@ import com.budwk.app.zhgh.learning.models.LearningCourseOutline;
|
|||||||
import com.budwk.app.zhgh.learning.models.LearningCourseType;
|
import com.budwk.app.zhgh.learning.models.LearningCourseType;
|
||||||
import com.budwk.app.zhgh.learning.models.LearningOutlineResource;
|
import com.budwk.app.zhgh.learning.models.LearningOutlineResource;
|
||||||
import com.budwk.app.zhgh.learning.models.LearningStudyRule;
|
import com.budwk.app.zhgh.learning.models.LearningStudyRule;
|
||||||
|
import com.budwk.app.zhgh.learning.service.LearningResourceProgressService;
|
||||||
import io.swagger.annotations.Api;
|
import io.swagger.annotations.Api;
|
||||||
import io.swagger.annotations.ApiOperation;
|
import io.swagger.annotations.ApiOperation;
|
||||||
import org.nutz.dao.Chain;
|
import org.nutz.dao.Chain;
|
||||||
@@ -37,6 +38,9 @@ public class LearningChapterContentController {
|
|||||||
@Inject
|
@Inject
|
||||||
private Dao dao;
|
private Dao dao;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private LearningResourceProgressService resourceProgressService;
|
||||||
|
|
||||||
@At("")
|
@At("")
|
||||||
@Ok("beetl:/platform/zhgh/Learning/chapterContent/index.html")
|
@Ok("beetl:/platform/zhgh/Learning/chapterContent/index.html")
|
||||||
@SaCheckPermission("learning.chapter.content")
|
@SaCheckPermission("learning.chapter.content")
|
||||||
@@ -132,7 +136,8 @@ public class LearningChapterContentController {
|
|||||||
} else {
|
} else {
|
||||||
dao.updateIgnoreNull(outline);
|
dao.updateIgnoreNull(outline);
|
||||||
}
|
}
|
||||||
ensureRule(outline.getCourseId(), "outline", outline.getId(), outline.getRequired(), "enabled");
|
ensureRule(outline.getCourseId(), "outline", outline.getId(), outline.getRequired(), outline.getStatus());
|
||||||
|
resourceProgressService.refreshOutline(outline.getId());
|
||||||
return Result.success();
|
return Result.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +168,7 @@ public class LearningChapterContentController {
|
|||||||
}
|
}
|
||||||
dao.update(LearningCourseOutline.class, Chain.make("status", status), Cnd.where("id", "=", id));
|
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"));
|
dao.update(LearningStudyRule.class, Chain.make("status", status), Cnd.where("targetId", "=", id).and("targetType", "=", "outline"));
|
||||||
|
resourceProgressService.refreshOutline(id);
|
||||||
return Result.success();
|
return Result.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,7 +229,7 @@ public class LearningChapterContentController {
|
|||||||
if (resource.getRequired() == null) {
|
if (resource.getRequired() == null) {
|
||||||
resource.setRequired(false);
|
resource.setRequired(false);
|
||||||
}
|
}
|
||||||
if (resource.getAllowPreview() == null) {
|
if (Boolean.TRUE.equals(resource.getRequired()) || resource.getAllowPreview() == null) {
|
||||||
resource.setAllowPreview(true);
|
resource.setAllowPreview(true);
|
||||||
}
|
}
|
||||||
if (resource.getAllowDownload() == null) {
|
if (resource.getAllowDownload() == null) {
|
||||||
@@ -241,6 +247,7 @@ public class LearningChapterContentController {
|
|||||||
dao.updateIgnoreNull(resource);
|
dao.updateIgnoreNull(resource);
|
||||||
}
|
}
|
||||||
ensureRule(resource.getCourseId(), "resource", resource.getId(), resource.getRequired(), resource.getStatus());
|
ensureRule(resource.getCourseId(), "resource", resource.getId(), resource.getRequired(), resource.getStatus());
|
||||||
|
resourceProgressService.refreshOutline(resource.getOutlineId());
|
||||||
return Result.success();
|
return Result.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,8 +259,10 @@ public class LearningChapterContentController {
|
|||||||
if (StrUtil.isBlank(id)) {
|
if (StrUtil.isBlank(id)) {
|
||||||
return Result.error("请选择要删除的数据");
|
return Result.error("请选择要删除的数据");
|
||||||
}
|
}
|
||||||
|
LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, id);
|
||||||
dao.clear(LearningStudyRule.class, Cnd.where("targetId", "=", id).and("targetType", "=", "resource"));
|
dao.clear(LearningStudyRule.class, Cnd.where("targetId", "=", id).and("targetType", "=", "resource"));
|
||||||
dao.clear(LearningOutlineResource.class, Cnd.where("id", "=", id));
|
dao.clear(LearningOutlineResource.class, Cnd.where("id", "=", id));
|
||||||
|
if (resource != null) resourceProgressService.refreshOutline(resource.getOutlineId());
|
||||||
return Result.success();
|
return Result.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +274,7 @@ public class LearningChapterContentController {
|
|||||||
if (rule == null) {
|
if (rule == null) {
|
||||||
rule = defaultRule(courseId, targetType, targetId, false, "enabled");
|
rule = defaultRule(courseId, targetType, targetId, false, "enabled");
|
||||||
}
|
}
|
||||||
|
if ("manual".equals(rule.getCompletionRule())) rule.setCompletionRule("all_required_resource");
|
||||||
return Result.success(rule);
|
return Result.success(rule);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +288,18 @@ public class LearningChapterContentController {
|
|||||||
if (StrUtil.isBlank(rule.getStatus())) {
|
if (StrUtil.isBlank(rule.getStatus())) {
|
||||||
rule.setStatus("enabled");
|
rule.setStatus("enabled");
|
||||||
}
|
}
|
||||||
|
if (!List.of("all_required_resource", "resource_progress", "min_study_time").contains(
|
||||||
|
rule.getCompletionRule() == null ? "" : rule.getCompletionRule())) {
|
||||||
|
return Result.error("请选择有效的完成规则");
|
||||||
|
}
|
||||||
|
if ("min_study_time".equals(rule.getCompletionRule())
|
||||||
|
&& (rule.getMinStudySeconds() == null || rule.getMinStudySeconds() <= 0)) {
|
||||||
|
return Result.error("最少学习时长必须大于0");
|
||||||
|
}
|
||||||
|
if ("resource_progress".equals(rule.getCompletionRule())
|
||||||
|
&& (rule.getCompletePercent() == null || rule.getCompletePercent() <= 0 || rule.getCompletePercent() > 100)) {
|
||||||
|
return Result.error("完成比例必须大于0且不超过100");
|
||||||
|
}
|
||||||
LearningStudyRule old = dao.fetch(LearningStudyRule.class, Cnd.where("courseId", "=", rule.getCourseId()).and("targetType", "=", rule.getTargetType()).and("targetId", "=", rule.getTargetId()));
|
LearningStudyRule old = dao.fetch(LearningStudyRule.class, Cnd.where("courseId", "=", rule.getCourseId()).and("targetType", "=", rule.getTargetType()).and("targetId", "=", rule.getTargetId()));
|
||||||
if (old == null) {
|
if (old == null) {
|
||||||
dao.insert(rule);
|
dao.insert(rule);
|
||||||
@@ -287,9 +309,14 @@ public class LearningChapterContentController {
|
|||||||
}
|
}
|
||||||
if ("outline".equals(rule.getTargetType())) {
|
if ("outline".equals(rule.getTargetType())) {
|
||||||
dao.update(LearningCourseOutline.class, Chain.make("required", rule.getRequired()).add("status", rule.getStatus()), Cnd.where("id", "=", rule.getTargetId()));
|
dao.update(LearningCourseOutline.class, Chain.make("required", rule.getRequired()).add("status", rule.getStatus()), Cnd.where("id", "=", rule.getTargetId()));
|
||||||
|
resourceProgressService.refreshOutline(rule.getTargetId());
|
||||||
}
|
}
|
||||||
if ("resource".equals(rule.getTargetType())) {
|
if ("resource".equals(rule.getTargetType())) {
|
||||||
dao.update(LearningOutlineResource.class, Chain.make("required", rule.getRequired()).add("status", rule.getStatus()), Cnd.where("id", "=", rule.getTargetId()));
|
Chain resourceChain = Chain.make("required", rule.getRequired()).add("status", rule.getStatus());
|
||||||
|
if (Boolean.TRUE.equals(rule.getRequired())) resourceChain.add("allowPreview", true);
|
||||||
|
dao.update(LearningOutlineResource.class, resourceChain, Cnd.where("id", "=", rule.getTargetId()));
|
||||||
|
LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, rule.getTargetId());
|
||||||
|
if (resource != null) resourceProgressService.refreshOutline(resource.getOutlineId());
|
||||||
}
|
}
|
||||||
return Result.success();
|
return Result.success();
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-14
@@ -21,6 +21,8 @@ import com.budwk.app.zhgh.learning.models.LearningCourseType;
|
|||||||
import com.budwk.app.zhgh.learning.models.LearningOutlineResource;
|
import com.budwk.app.zhgh.learning.models.LearningOutlineResource;
|
||||||
import com.budwk.app.zhgh.learning.models.LearningStudyRecord;
|
import com.budwk.app.zhgh.learning.models.LearningStudyRecord;
|
||||||
import com.budwk.app.zhgh.learning.models.LearningStudyRule;
|
import com.budwk.app.zhgh.learning.models.LearningStudyRule;
|
||||||
|
import com.budwk.app.zhgh.learning.service.LearningFileAccessService;
|
||||||
|
import com.budwk.app.zhgh.learning.service.LearningResourceProgressService;
|
||||||
import com.google.common.net.HttpHeaders;
|
import com.google.common.net.HttpHeaders;
|
||||||
import io.swagger.annotations.Api;
|
import io.swagger.annotations.Api;
|
||||||
import io.swagger.annotations.ApiOperation;
|
import io.swagger.annotations.ApiOperation;
|
||||||
@@ -60,6 +62,12 @@ public class LearningCourseDisplayController {
|
|||||||
@Inject
|
@Inject
|
||||||
private SysDictService sysDictService;
|
private SysDictService sysDictService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private LearningFileAccessService learningFileAccessService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private LearningResourceProgressService resourceProgressService;
|
||||||
|
|
||||||
@At("")
|
@At("")
|
||||||
@Ok("beetl:/platform/zhgh/Learning/courseDisplay/indexV2.html")
|
@Ok("beetl:/platform/zhgh/Learning/courseDisplay/indexV2.html")
|
||||||
@SaCheckPermission("learning.course.display")
|
@SaCheckPermission("learning.course.display")
|
||||||
@@ -86,15 +94,19 @@ public class LearningCourseDisplayController {
|
|||||||
* pageForm 传分页页码和每页条数;keyword、courseTypeId、recommendFlag 分别用于关键词、分类和推荐标识筛选;
|
* pageForm 传分页页码和每页条数;keyword、courseTypeId、recommendFlag 分别用于关键词、分类和推荐标识筛选;
|
||||||
* sortType 支持 default(综合排序)和 view_count(观看次数倒序)。返回值为 Pagination,list 中包含课程基础字段、课节数、总时长、观看次数和当前用户学习进度。
|
* sortType 支持 default(综合排序)和 view_count(观看次数倒序)。返回值为 Pagination,list 中包含课程基础字段、课节数、总时长、观看次数和当前用户学习进度。
|
||||||
*/
|
*/
|
||||||
public Result pageData(PageForm pageForm, String keyword, String courseTypeId, String recommendFlag, String sortType) {
|
public Result pageData(PageForm pageForm, String keyword, String courseTypeId, String recommendFlag, String sortType, String excludeCourseId) {
|
||||||
Cnd cnd = Cnd.where("c.status", "=", "published");
|
Cnd cnd = Cnd.where("c.status", "=", "published");
|
||||||
if (StrUtil.isNotBlank(keyword)) {
|
if (StrUtil.isNotBlank(keyword)) {
|
||||||
cnd.and(Cnd.exps("c.courseName", "like", "%" + keyword + "%")
|
cnd.and(Cnd.exps("c.courseName", "like", "%" + keyword + "%")
|
||||||
.or("c.courseIntro", "like", "%" + keyword + "%"));
|
.or("c.courseIntro", "like", "%" + keyword + "%"));
|
||||||
}
|
}
|
||||||
cnd.andEX("c.courseTypeId", "=", courseTypeId);
|
cnd.andEX("c.courseTypeId", "=", courseTypeId);
|
||||||
|
cnd.andEX("c.id", "!=", excludeCourseId);
|
||||||
if (StrUtil.isNotBlank(recommendFlag)) {
|
if (StrUtil.isNotBlank(recommendFlag)) {
|
||||||
cnd.and("c.recommendFlags", "like", "%" + recommendFlag + "%");
|
cnd.and(Cnd.exps("c.recommendFlags", "=", recommendFlag)
|
||||||
|
.or("c.recommendFlags", "like", recommendFlag + ",%")
|
||||||
|
.or("c.recommendFlags", "like", "%," + recommendFlag)
|
||||||
|
.or("c.recommendFlags", "like", "%," + recommendFlag + ",%"));
|
||||||
}
|
}
|
||||||
|
|
||||||
Sql countSql = Sqls.create("""
|
Sql countSql = Sqls.create("""
|
||||||
@@ -114,11 +126,13 @@ public class LearningCourseDisplayController {
|
|||||||
COALESCE(c.viewCount, 0) AS viewCount, t.typeName AS courseTypeName,
|
COALESCE(c.viewCount, 0) AS viewCount, t.typeName AS courseTypeName,
|
||||||
COALESCE((SELECT ROUND(AVG(IFNULL(sr.progressPercent, 0)))
|
COALESCE((SELECT ROUND(AVG(IFNULL(sr.progressPercent, 0)))
|
||||||
FROM learning_study_record sr
|
FROM learning_study_record sr
|
||||||
WHERE sr.courseId = c.id AND sr.userId = @userId), 0) AS progressPercent,
|
WHERE sr.courseId = c.id AND sr.userId = @userId
|
||||||
|
AND EXISTS (SELECT 1 FROM learning_outline_resource pr WHERE pr.outlineId = sr.outlineId
|
||||||
|
AND pr.status = 'enabled' AND COALESCE(pr.allowPreview, 1) = 1)), 0) AS progressPercent,
|
||||||
(SELECT COUNT(1) FROM learning_outline_resource r
|
(SELECT COUNT(1) FROM learning_outline_resource r
|
||||||
WHERE r.courseId = c.id AND r.status = 'enabled') AS resourceCount,
|
WHERE r.courseId = c.id AND r.status = 'enabled' AND COALESCE(r.allowPreview, 1) = 1) AS resourceCount,
|
||||||
COALESCE((SELECT SUM(r.durationSeconds) FROM learning_outline_resource r
|
COALESCE((SELECT SUM(r.durationSeconds) FROM learning_outline_resource r
|
||||||
WHERE r.courseId = c.id AND r.status = 'enabled'), 0) AS totalDurationSeconds
|
WHERE r.courseId = c.id AND r.status = 'enabled' AND COALESCE(r.allowPreview, 1) = 1), 0) AS totalDurationSeconds
|
||||||
FROM learning_course c
|
FROM learning_course c
|
||||||
LEFT JOIN learning_course_type t ON t.id = c.courseTypeId
|
LEFT JOIN learning_course_type t ON t.id = c.courseTypeId
|
||||||
$condition
|
$condition
|
||||||
@@ -127,7 +141,7 @@ public class LearningCourseDisplayController {
|
|||||||
listSql.setCondition(cnd);
|
listSql.setCondition(cnd);
|
||||||
listSql.params().set("userId", SecurityUtil.getUserId());
|
listSql.params().set("userId", SecurityUtil.getUserId());
|
||||||
// 排序字段由固定选项映射,禁止将前端参数直接拼接进SQL。
|
// 排序字段由固定选项映射,禁止将前端参数直接拼接进SQL。
|
||||||
listSql.setVar("orderBy", "view_count".equals(sortType)
|
listSql.setVar("orderBy", "random".equals(sortType) ? "RAND()" : "view_count".equals(sortType)
|
||||||
? "COALESCE(c.viewCount, 0) DESC, c.sortNum ASC, c.createdAt DESC"
|
? "COALESCE(c.viewCount, 0) DESC, c.sortNum ASC, c.createdAt DESC"
|
||||||
: "c.sortNum ASC, c.createdAt DESC");
|
: "c.sortNum ASC, c.createdAt DESC");
|
||||||
listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
|
listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
|
||||||
@@ -135,6 +149,7 @@ public class LearningCourseDisplayController {
|
|||||||
dao.execute(listSql);
|
dao.execute(listSql);
|
||||||
List<NutMap> rows = listSql.getList(NutMap.class);
|
List<NutMap> rows = listSql.getList(NutMap.class);
|
||||||
rows.forEach(row -> {
|
rows.forEach(row -> {
|
||||||
|
row.putAll(resourceProgressService.courseState(row.getString("id"), SecurityUtil.getUserId()));
|
||||||
Object startTime = row.get("startTime");
|
Object startTime = row.get("startTime");
|
||||||
Object endTime = row.get("endTime");
|
Object endTime = row.get("endTime");
|
||||||
row.put("startTimeText", startTime == null ? "" : DateUtil.formatDateTime(DateUtil.date((java.util.Date) startTime)));
|
row.put("startTimeText", startTime == null ? "" : DateUtil.formatDateTime(DateUtil.date((java.util.Date) startTime)));
|
||||||
@@ -161,9 +176,9 @@ public class LearningCourseDisplayController {
|
|||||||
c.lecturerName, c.lecturerInfo, COALESCE(c.viewCount, 0) AS viewCount,
|
c.lecturerName, c.lecturerInfo, COALESCE(c.viewCount, 0) AS viewCount,
|
||||||
t.typeName AS courseTypeName,
|
t.typeName AS courseTypeName,
|
||||||
(SELECT COUNT(1) FROM learning_outline_resource r
|
(SELECT COUNT(1) FROM learning_outline_resource r
|
||||||
WHERE r.courseId = c.id AND r.status = 'enabled') AS resourceCount,
|
WHERE r.courseId = c.id AND r.status = 'enabled' AND COALESCE(r.allowPreview, 1) = 1) AS resourceCount,
|
||||||
COALESCE((SELECT SUM(r.durationSeconds) FROM learning_outline_resource r
|
COALESCE((SELECT SUM(r.durationSeconds) FROM learning_outline_resource r
|
||||||
WHERE r.courseId = c.id AND r.status = 'enabled'), 0) AS totalDurationSeconds
|
WHERE r.courseId = c.id AND r.status = 'enabled' AND COALESCE(r.allowPreview, 1) = 1), 0) AS totalDurationSeconds
|
||||||
FROM learning_course c
|
FROM learning_course c
|
||||||
LEFT JOIN learning_course_type t ON t.id = c.courseTypeId
|
LEFT JOIN learning_course_type t ON t.id = c.courseTypeId
|
||||||
WHERE c.id = @id
|
WHERE c.id = @id
|
||||||
@@ -179,6 +194,7 @@ public class LearningCourseDisplayController {
|
|||||||
Object endTime = course.get("endTime");
|
Object endTime = course.get("endTime");
|
||||||
course.put("startTimeText", startTime == null ? "" : DateUtil.formatDateTime(DateUtil.date((java.util.Date) startTime)));
|
course.put("startTimeText", startTime == null ? "" : DateUtil.formatDateTime(DateUtil.date((java.util.Date) startTime)));
|
||||||
course.put("endTimeText", endTime == null ? "" : DateUtil.formatDateTime(DateUtil.date((java.util.Date) endTime)));
|
course.put("endTimeText", endTime == null ? "" : DateUtil.formatDateTime(DateUtil.date((java.util.Date) endTime)));
|
||||||
|
course.putAll(resourceProgressService.courseState(id, SecurityUtil.getUserId()));
|
||||||
return Result.success(course);
|
return Result.success(course);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,12 +228,14 @@ public class LearningCourseDisplayController {
|
|||||||
.addv("type", "outline")
|
.addv("type", "outline")
|
||||||
.addv("nodeType", outline.getNodeType())
|
.addv("nodeType", outline.getNodeType())
|
||||||
.addv("title", outline.getTitle())
|
.addv("title", outline.getTitle())
|
||||||
|
.addv("description", outline.getDescription())
|
||||||
.addv("required", outline.getRequired())
|
.addv("required", outline.getRequired())
|
||||||
.addv("progressPercent", recordMap.containsKey(outline.getId()) ? recordMap.get(outline.getId()).getProgressPercent() : 0)
|
.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("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("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("completeStatus", recordMap.containsKey(outline.getId()) ? recordMap.get(outline.getId()).getCompleteStatus() : "not_started")
|
||||||
.addv("children", children);
|
.addv("children", children);
|
||||||
|
node.putAll(resourceProgressService.outlineState(recordMap.get(outline.getId()), outline));
|
||||||
outlineNodeMap.put(outline.getId(), node);
|
outlineNodeMap.put(outline.getId(), node);
|
||||||
childrenMap.put(outline.getId(), children);
|
childrenMap.put(outline.getId(), children);
|
||||||
}
|
}
|
||||||
@@ -233,22 +251,26 @@ public class LearningCourseDisplayController {
|
|||||||
// 章节学习规则控制其下媒体播放行为;未配置规则时保持原有的可拖动行为。
|
// 章节学习规则控制其下媒体播放行为;未配置规则时保持原有的可拖动行为。
|
||||||
LearningStudyRule outlineRule = outlineRuleMap.get(resource.getOutlineId());
|
LearningStudyRule outlineRule = outlineRuleMap.get(resource.getOutlineId());
|
||||||
boolean allowDrag = outlineRule == null || !Boolean.FALSE.equals(outlineRule.getAllowDrag());
|
boolean allowDrag = outlineRule == null || !Boolean.FALSE.equals(outlineRule.getAllowDrag());
|
||||||
|
NutMap resourceState = resourceProgressService.state(recordMap.get(resource.getOutlineId()), resource);
|
||||||
NutMap node = NutMap.NEW()
|
NutMap node = NutMap.NEW()
|
||||||
.addv("id", resource.getId())
|
.addv("id", resource.getId())
|
||||||
.addv("type", "resource")
|
.addv("type", "resource")
|
||||||
.addv("outlineId", resource.getOutlineId())
|
.addv("outlineId", resource.getOutlineId())
|
||||||
.addv("title", resource.getResourceTitle())
|
.addv("title", resource.getResourceTitle())
|
||||||
.addv("resourceType", resource.getResourceType())
|
.addv("resourceType", resource.getResourceType())
|
||||||
|
.addv("studied", resourceState.getBoolean("studied", false))
|
||||||
.addv("fileExt", resource.getFileExt())
|
.addv("fileExt", resource.getFileExt())
|
||||||
.addv("fileData", resource.getFileData())
|
.addv("fileData", !Boolean.FALSE.equals(resource.getAllowPreview()) || Boolean.TRUE.equals(resource.getAllowDownload()) ? resource.getFileData() : null)
|
||||||
|
.addv("allowPreview", !Boolean.FALSE.equals(resource.getAllowPreview()))
|
||||||
|
.addv("allowDownload", Boolean.TRUE.equals(resource.getAllowDownload()))
|
||||||
.addv("durationSeconds", resource.getDurationSeconds())
|
.addv("durationSeconds", resource.getDurationSeconds())
|
||||||
.addv("required", resource.getRequired())
|
.addv("required", resource.getRequired())
|
||||||
.addv("progressPercent", recordMap.containsKey(resource.getOutlineId()) ? recordMap.get(resource.getOutlineId()).getProgressPercent() : 0)
|
.addv("progressPercent", resourceState.getInt("progressPercent", 0))
|
||||||
.addv("studySeconds", recordMap.containsKey(resource.getOutlineId()) ? recordMap.get(resource.getOutlineId()).getStudySeconds() : 0)
|
.addv("studySeconds", resourceState.getInt("studySeconds", 0))
|
||||||
.addv("lastPositionSeconds", recordMap.containsKey(resource.getOutlineId()) ? value(recordMap.get(resource.getOutlineId()).getLastPositionSeconds()) : 0)
|
.addv("lastPositionSeconds", resourceState.getInt("lastPositionSeconds", 0))
|
||||||
.addv("maxPositionSeconds", resourceMaxPosition(recordMap.get(resource.getOutlineId()), resource.getId()))
|
.addv("maxPositionSeconds", resourceState.getInt("maxPositionSeconds", 0))
|
||||||
.addv("allowDrag", allowDrag)
|
.addv("allowDrag", allowDrag)
|
||||||
.addv("completeStatus", recordMap.containsKey(resource.getOutlineId()) ? recordMap.get(resource.getOutlineId()).getCompleteStatus() : "not_started");
|
.addv("completeStatus", resourceState.getString("completeStatus", "not_started"));
|
||||||
childrenMap.computeIfAbsent(resource.getOutlineId(), key -> new ArrayList<>()).add(node);
|
childrenMap.computeIfAbsent(resource.getOutlineId(), key -> new ArrayList<>()).add(node);
|
||||||
}
|
}
|
||||||
return Result.success(roots);
|
return Result.success(roots);
|
||||||
@@ -259,6 +281,7 @@ public class LearningCourseDisplayController {
|
|||||||
@ApiOperation("PDF内联预览")
|
@ApiOperation("PDF内联预览")
|
||||||
@SaCheckPermission("learning.course.display")
|
@SaCheckPermission("learning.course.display")
|
||||||
public void pdfPreview(String id, HttpServletResponse response) throws IOException {
|
public void pdfPreview(String id, HttpServletResponse response) throws IOException {
|
||||||
|
if (!learningFileAccessService.check(id, false, response)) return;
|
||||||
if (StrUtil.isBlank(id)) {
|
if (StrUtil.isBlank(id)) {
|
||||||
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "文件ID不能为空");
|
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "文件ID不能为空");
|
||||||
return;
|
return;
|
||||||
|
|||||||
+113
-31
@@ -10,6 +10,7 @@ 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.AuthUtil;
|
||||||
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
|
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
|
||||||
import com.budwk.app.zhgh.learning.models.LearningCourse;
|
import com.budwk.app.zhgh.learning.models.LearningCourse;
|
||||||
|
import com.budwk.app.zhgh.learning.service.LearningResourceProgressService;
|
||||||
import com.budwk.app.zhgh.learning.param.LearningStatisticsPageForm;
|
import com.budwk.app.zhgh.learning.param.LearningStatisticsPageForm;
|
||||||
import io.swagger.annotations.Api;
|
import io.swagger.annotations.Api;
|
||||||
import io.swagger.annotations.ApiOperation;
|
import io.swagger.annotations.ApiOperation;
|
||||||
@@ -39,6 +40,9 @@ public class LearningStatisticsController {
|
|||||||
@Inject
|
@Inject
|
||||||
private Dao dao;
|
private Dao dao;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private LearningResourceProgressService resourceProgressService;
|
||||||
|
|
||||||
@At("")
|
@At("")
|
||||||
@Ok("beetl:/platform/zhgh/Learning/statistics/index.html")
|
@Ok("beetl:/platform/zhgh/Learning/statistics/index.html")
|
||||||
@SaCheckPermission("learning.statistics")
|
@SaCheckPermission("learning.statistics")
|
||||||
@@ -49,6 +53,7 @@ public class LearningStatisticsController {
|
|||||||
@ApiOperation("课程统计")
|
@ApiOperation("课程统计")
|
||||||
@SaCheckPermission("learning.statistics")
|
@SaCheckPermission("learning.statistics")
|
||||||
public Result coursePageData(LearningStatisticsPageForm pageForm) {
|
public Result coursePageData(LearningStatisticsPageForm pageForm) {
|
||||||
|
refreshProgress(pageForm);
|
||||||
Cnd cnd = courseCondition(pageForm);
|
Cnd cnd = courseCondition(pageForm);
|
||||||
Sql countSql = Sqls.create("""
|
Sql countSql = Sqls.create("""
|
||||||
SELECT COUNT(1)
|
SELECT COUNT(1)
|
||||||
@@ -63,21 +68,21 @@ public class LearningStatisticsController {
|
|||||||
SELECT
|
SELECT
|
||||||
c.id AS courseId,
|
c.id AS courseId,
|
||||||
c.courseName,
|
c.courseName,
|
||||||
IFNULL(COUNT(uc.userId), 0) AS learnerCount,
|
COUNT(DISTINCT uc.userId) AS learnerCount,
|
||||||
IFNULL(SUM(CASE WHEN target.targetCount > 0 AND uc.completedOutlineCount >= target.targetCount THEN 1 ELSE 0 END), 0) AS completedCount,
|
COUNT(DISTINCT CASE WHEN target.targetCount > 0 AND uc.completedOutlineCount >= target.targetCount THEN uc.userId END) AS completedCount,
|
||||||
IFNULL(ROUND(AVG(uc.studySeconds)), 0) AS avgStudySeconds
|
IFNULL(ROUND(AVG(uc.studySeconds)), 0) AS avgStudySeconds
|
||||||
FROM learning_course c
|
FROM learning_course c
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT
|
SELECT
|
||||||
courseId,
|
courseId,
|
||||||
CASE
|
COUNT(1) AS targetCount,
|
||||||
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
|
SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount
|
||||||
FROM learning_course_outline
|
FROM learning_course_outline
|
||||||
WHERE status = 'enabled'
|
WHERE status = 'enabled'
|
||||||
|
AND EXISTS (SELECT 1 FROM learning_outline_resource lr
|
||||||
|
WHERE lr.outlineId = learning_course_outline.id
|
||||||
|
AND lr.courseId = learning_course_outline.courseId
|
||||||
|
AND lr.status = 'enabled' AND COALESCE(lr.allowPreview, 1) = 1)
|
||||||
GROUP BY courseId
|
GROUP BY courseId
|
||||||
) target ON target.courseId = c.id
|
) target ON target.courseId = c.id
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
@@ -87,7 +92,6 @@ public class LearningStatisticsController {
|
|||||||
SUM(IFNULL(r.studySeconds, 0)) AS studySeconds,
|
SUM(IFNULL(r.studySeconds, 0)) AS studySeconds,
|
||||||
COUNT(DISTINCT CASE
|
COUNT(DISTINCT CASE
|
||||||
WHEN r.completeStatus = 'completed'
|
WHEN r.completeStatus = 'completed'
|
||||||
AND ((target.requiredCount > 0 AND o.required = 1) OR target.requiredCount = 0)
|
|
||||||
THEN r.outlineId
|
THEN r.outlineId
|
||||||
ELSE NULL
|
ELSE NULL
|
||||||
END) AS completedOutlineCount
|
END) AS completedOutlineCount
|
||||||
@@ -96,14 +100,14 @@ public class LearningStatisticsController {
|
|||||||
JOIN (
|
JOIN (
|
||||||
SELECT
|
SELECT
|
||||||
courseId,
|
courseId,
|
||||||
CASE
|
COUNT(1) AS targetCount,
|
||||||
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
|
SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount
|
||||||
FROM learning_course_outline
|
FROM learning_course_outline
|
||||||
WHERE status = 'enabled'
|
WHERE status = 'enabled'
|
||||||
|
AND EXISTS (SELECT 1 FROM learning_outline_resource lr
|
||||||
|
WHERE lr.outlineId = learning_course_outline.id
|
||||||
|
AND lr.courseId = learning_course_outline.courseId
|
||||||
|
AND lr.status = 'enabled' AND COALESCE(lr.allowPreview, 1) = 1)
|
||||||
GROUP BY courseId
|
GROUP BY courseId
|
||||||
) target ON target.courseId = r.courseId
|
) target ON target.courseId = r.courseId
|
||||||
$recordCondition
|
$recordCondition
|
||||||
@@ -120,7 +124,10 @@ public class LearningStatisticsController {
|
|||||||
listSql.setCallback(Sqls.callback.maps());
|
listSql.setCallback(Sqls.callback.maps());
|
||||||
dao.execute(listSql);
|
dao.execute(listSql);
|
||||||
List<NutMap> rows = listSql.getList(NutMap.class);
|
List<NutMap> rows = listSql.getList(NutMap.class);
|
||||||
rows.forEach(row -> row.put("avgStudyTimeText", formatStudySeconds(row.getInt("avgStudySeconds", 0))));
|
rows.forEach(row -> {
|
||||||
|
row.put("avgStudyTimeText", formatStudySeconds(row.getInt("avgStudySeconds", 0)));
|
||||||
|
row.put("completeRate", completeRate(row.getInt("completedCount", 0), row.getInt("learnerCount", 0)));
|
||||||
|
});
|
||||||
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), countSql.getInt(), rows));
|
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), countSql.getInt(), rows));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +135,7 @@ public class LearningStatisticsController {
|
|||||||
@ApiOperation("分工会统计")
|
@ApiOperation("分工会统计")
|
||||||
@SaCheckPermission("learning.statistics")
|
@SaCheckPermission("learning.statistics")
|
||||||
public Result unionPageData(LearningStatisticsPageForm pageForm) {
|
public Result unionPageData(LearningStatisticsPageForm pageForm) {
|
||||||
|
refreshProgress(pageForm);
|
||||||
Cnd cnd = unionCondition(pageForm);
|
Cnd cnd = unionCondition(pageForm);
|
||||||
Sql countSql = Sqls.create("""
|
Sql countSql = Sqls.create("""
|
||||||
SELECT COUNT(1)
|
SELECT COUNT(1)
|
||||||
@@ -152,9 +160,9 @@ public class LearningStatisticsController {
|
|||||||
c.courseName,
|
c.courseName,
|
||||||
un.id AS unionId,
|
un.id AS unionId,
|
||||||
IFNULL(un.name, uc.unionName) AS unionName,
|
IFNULL(un.name, uc.unionName) AS unionName,
|
||||||
COUNT(uc.userId) AS learnerCount,
|
COUNT(DISTINCT uc.userId) AS learnerCount,
|
||||||
IFNULL(SUM(uc.studySeconds), 0) AS studySeconds,
|
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
|
COUNT(DISTINCT CASE WHEN target.targetCount > 0 AND uc.completedOutlineCount >= target.targetCount THEN uc.userId END) AS completedCount
|
||||||
FROM learning_course c
|
FROM learning_course c
|
||||||
JOIN (
|
JOIN (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -165,7 +173,6 @@ public class LearningStatisticsController {
|
|||||||
SUM(IFNULL(r.studySeconds, 0)) AS studySeconds,
|
SUM(IFNULL(r.studySeconds, 0)) AS studySeconds,
|
||||||
COUNT(DISTINCT CASE
|
COUNT(DISTINCT CASE
|
||||||
WHEN r.completeStatus = 'completed'
|
WHEN r.completeStatus = 'completed'
|
||||||
AND ((target.requiredCount > 0 AND o.required = 1) OR target.requiredCount = 0)
|
|
||||||
THEN r.outlineId
|
THEN r.outlineId
|
||||||
ELSE NULL
|
ELSE NULL
|
||||||
END) AS completedOutlineCount
|
END) AS completedOutlineCount
|
||||||
@@ -174,14 +181,14 @@ public class LearningStatisticsController {
|
|||||||
JOIN (
|
JOIN (
|
||||||
SELECT
|
SELECT
|
||||||
courseId,
|
courseId,
|
||||||
CASE
|
COUNT(1) AS targetCount,
|
||||||
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
|
SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount
|
||||||
FROM learning_course_outline
|
FROM learning_course_outline
|
||||||
WHERE status = 'enabled'
|
WHERE status = 'enabled'
|
||||||
|
AND EXISTS (SELECT 1 FROM learning_outline_resource lr
|
||||||
|
WHERE lr.outlineId = learning_course_outline.id
|
||||||
|
AND lr.courseId = learning_course_outline.courseId
|
||||||
|
AND lr.status = 'enabled' AND COALESCE(lr.allowPreview, 1) = 1)
|
||||||
GROUP BY courseId
|
GROUP BY courseId
|
||||||
) target ON target.courseId = r.courseId
|
) target ON target.courseId = r.courseId
|
||||||
$recordCondition
|
$recordCondition
|
||||||
@@ -191,14 +198,14 @@ public class LearningStatisticsController {
|
|||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT
|
SELECT
|
||||||
courseId,
|
courseId,
|
||||||
CASE
|
COUNT(1) AS targetCount,
|
||||||
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
|
SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount
|
||||||
FROM learning_course_outline
|
FROM learning_course_outline
|
||||||
WHERE status = 'enabled'
|
WHERE status = 'enabled'
|
||||||
|
AND EXISTS (SELECT 1 FROM learning_outline_resource lr
|
||||||
|
WHERE lr.outlineId = learning_course_outline.id
|
||||||
|
AND lr.courseId = learning_course_outline.courseId
|
||||||
|
AND lr.status = 'enabled' AND COALESCE(lr.allowPreview, 1) = 1)
|
||||||
GROUP BY courseId
|
GROUP BY courseId
|
||||||
) target ON target.courseId = c.id
|
) target ON target.courseId = c.id
|
||||||
$condition
|
$condition
|
||||||
@@ -216,11 +223,65 @@ public class LearningStatisticsController {
|
|||||||
int learnerCount = row.getInt("learnerCount", 0);
|
int learnerCount = row.getInt("learnerCount", 0);
|
||||||
int completedCount = row.getInt("completedCount", 0);
|
int completedCount = row.getInt("completedCount", 0);
|
||||||
row.put("studyTimeText", formatStudySeconds(row.getInt("studySeconds", 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() + "%");
|
row.put("completeRate", completeRate(completedCount, learnerCount));
|
||||||
});
|
});
|
||||||
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), countSql.getInt(), rows));
|
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), countSql.getInt(), rows));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@At
|
||||||
|
@ApiOperation("学习列表")
|
||||||
|
@SaCheckPermission("learning.statistics")
|
||||||
|
public Result recordPageData(LearningStatisticsPageForm pageForm) {
|
||||||
|
refreshProgress(pageForm);
|
||||||
|
Cnd cnd = Cnd.NEW();
|
||||||
|
cnd.andEX("r.courseId", "=", pageForm.getCourseId());
|
||||||
|
cnd.andEX("r.unionId", "=", pageForm.getUnionId());
|
||||||
|
cnd.and(Cnd.likeEX("r.loginName", pageForm.getLoginName()));
|
||||||
|
cnd.and(Cnd.likeEX("r.userName", pageForm.getUserName()));
|
||||||
|
if ("completed".equals(pageForm.getCompleteStatus())) {
|
||||||
|
cnd.and("r.completeStatus", "=", "completed");
|
||||||
|
} else if ("incomplete".equals(pageForm.getCompleteStatus())) {
|
||||||
|
cnd.and(Cnd.exps("r.completeStatus", "!=", "completed").or("r.completeStatus", "is", null));
|
||||||
|
}
|
||||||
|
if (!hasSchoolScope()) {
|
||||||
|
if (hasBranchScope()) {
|
||||||
|
cnd.and("r.unionId", "=", SecurityUtil.getUnionId());
|
||||||
|
} else {
|
||||||
|
cnd.and("r.userId", "=", SecurityUtil.getUserId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String from = """
|
||||||
|
FROM learning_study_record r
|
||||||
|
JOIN learning_course_outline o ON o.id = r.outlineId AND o.courseId = r.courseId AND o.status = 'enabled'
|
||||||
|
JOIN (SELECT DISTINCT outlineId, courseId FROM learning_outline_resource
|
||||||
|
WHERE status = 'enabled' AND COALESCE(allowPreview, 1) = 1) lr
|
||||||
|
ON lr.outlineId = r.outlineId AND lr.courseId = r.courseId
|
||||||
|
""";
|
||||||
|
Sql countSql = Sqls.create("SELECT COUNT(1) " + from + " $condition");
|
||||||
|
countSql.setCondition(cnd);
|
||||||
|
countSql.setCallback(Sqls.callback.integer());
|
||||||
|
dao.execute(countSql);
|
||||||
|
Map<String, String> columns = new HashMap<>();
|
||||||
|
for (String column : List.of("loginName", "userName", "unionName", "courseName", "outlineName",
|
||||||
|
"firstStudyTime", "latestStudyTime", "studySeconds", "progressPercent", "completeStatus")) {
|
||||||
|
columns.put(column, "r." + column);
|
||||||
|
}
|
||||||
|
String orderColumn = columns.get(pageForm.getPageOrderName());
|
||||||
|
if (StrUtil.isNotBlank(orderColumn) && StrUtil.isNotBlank(pageForm.getPageOrderBy())) {
|
||||||
|
cnd.orderBy(orderColumn, PageUtil.getOrder(pageForm.getPageOrderBy()));
|
||||||
|
} else {
|
||||||
|
cnd.desc("r.latestStudyTime");
|
||||||
|
}
|
||||||
|
Sql listSql = Sqls.create("SELECT r.* " + from + " $condition");
|
||||||
|
listSql.setCondition(cnd);
|
||||||
|
listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
|
||||||
|
listSql.setCallback(Sqls.callback.maps());
|
||||||
|
dao.execute(listSql);
|
||||||
|
List<NutMap> rows = listSql.getList(NutMap.class);
|
||||||
|
rows.forEach(row -> row.put("studyTimeText", formatStudySeconds(row.getInt("studySeconds", 0))));
|
||||||
|
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), countSql.getInt(), rows));
|
||||||
|
}
|
||||||
|
|
||||||
@At
|
@At
|
||||||
@ApiOperation("可选课程")
|
@ApiOperation("可选课程")
|
||||||
@SaCheckPermission("learning.statistics")
|
@SaCheckPermission("learning.statistics")
|
||||||
@@ -244,6 +305,17 @@ public class LearningStatisticsController {
|
|||||||
return Result.success(List.of());
|
return Result.success(List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void refreshProgress(LearningStatisticsPageForm pageForm) {
|
||||||
|
Cnd cnd = Cnd.NEW();
|
||||||
|
cnd.andEX("courseId", "=", pageForm.getCourseId());
|
||||||
|
cnd.andEX("unionId", "=", pageForm.getUnionId());
|
||||||
|
if (!hasSchoolScope()) {
|
||||||
|
if (hasBranchScope()) cnd.and("unionId", "=", SecurityUtil.getUnionId());
|
||||||
|
else cnd.and("userId", "=", SecurityUtil.getUserId());
|
||||||
|
}
|
||||||
|
resourceProgressService.refreshRecords(cnd);
|
||||||
|
}
|
||||||
|
|
||||||
private Cnd courseCondition(LearningStatisticsPageForm pageForm) {
|
private Cnd courseCondition(LearningStatisticsPageForm pageForm) {
|
||||||
Cnd cnd = Cnd.NEW();
|
Cnd cnd = Cnd.NEW();
|
||||||
cnd.andEX("c.id", "=", pageForm.getCourseId());
|
cnd.andEX("c.id", "=", pageForm.getCourseId());
|
||||||
@@ -258,8 +330,11 @@ public class LearningStatisticsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String recordWhere(LearningStatisticsPageForm pageForm) {
|
private String recordWhere(LearningStatisticsPageForm pageForm) {
|
||||||
StringBuilder where = new StringBuilder("WHERE 1 = 1");
|
StringBuilder where = new StringBuilder("WHERE EXISTS (SELECT 1 FROM learning_course_outline so "
|
||||||
if (hasBranchScope()) {
|
+ "JOIN learning_outline_resource sr ON sr.outlineId = so.id AND sr.courseId = so.courseId "
|
||||||
|
+ "WHERE so.id = r.outlineId AND so.courseId = r.courseId AND so.status = 'enabled' "
|
||||||
|
+ "AND sr.status = 'enabled' AND COALESCE(sr.allowPreview, 1) = 1)");
|
||||||
|
if (!hasSchoolScope() && hasBranchScope()) {
|
||||||
where.append(" AND r.unionId = '").append(escapeSql(SecurityUtil.getUnionId())).append("'");
|
where.append(" AND r.unionId = '").append(escapeSql(SecurityUtil.getUnionId())).append("'");
|
||||||
} else if (!hasSchoolScope()) {
|
} else if (!hasSchoolScope()) {
|
||||||
where.append(" AND r.userId = '").append(escapeSql(SecurityUtil.getUserId())).append("'");
|
where.append(" AND r.userId = '").append(escapeSql(SecurityUtil.getUserId())).append("'");
|
||||||
@@ -289,6 +364,7 @@ public class LearningStatisticsController {
|
|||||||
columns.put("learnerCount", "learnerCount");
|
columns.put("learnerCount", "learnerCount");
|
||||||
columns.put("completedCount", "completedCount");
|
columns.put("completedCount", "completedCount");
|
||||||
columns.put("avgStudySeconds", "avgStudySeconds");
|
columns.put("avgStudySeconds", "avgStudySeconds");
|
||||||
|
columns.put("completeRate", "completedCount / NULLIF(learnerCount, 0)");
|
||||||
return columns.get(prop);
|
return columns.get(prop);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +374,8 @@ public class LearningStatisticsController {
|
|||||||
columns.put("unionName", "unionName");
|
columns.put("unionName", "unionName");
|
||||||
columns.put("learnerCount", "learnerCount");
|
columns.put("learnerCount", "learnerCount");
|
||||||
columns.put("studySeconds", "studySeconds");
|
columns.put("studySeconds", "studySeconds");
|
||||||
columns.put("completeRate", "completedCount");
|
columns.put("completeRate", "completedCount / NULLIF(learnerCount, 0)");
|
||||||
|
columns.put("completedCount", "completedCount");
|
||||||
return columns.get(prop);
|
return columns.get(prop);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +391,11 @@ public class LearningStatisticsController {
|
|||||||
return value == null ? "" : value.replace("'", "''");
|
return value == null ? "" : value.replace("'", "''");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String completeRate(int completedCount, int learnerCount) {
|
||||||
|
return learnerCount == 0 ? "0%" : BigDecimal.valueOf(completedCount).multiply(BigDecimal.valueOf(100))
|
||||||
|
.divide(BigDecimal.valueOf(learnerCount), 2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() + "%";
|
||||||
|
}
|
||||||
|
|
||||||
private String formatStudySeconds(Integer seconds) {
|
private String formatStudySeconds(Integer seconds) {
|
||||||
int value = seconds == null ? 0 : seconds;
|
int value = seconds == null ? 0 : seconds;
|
||||||
int hour = value / 3600;
|
int hour = value / 3600;
|
||||||
|
|||||||
+102
-111
@@ -16,6 +16,7 @@ import com.budwk.app.zhgh.learning.models.LearningOutlineResource;
|
|||||||
import com.budwk.app.zhgh.learning.models.LearningStudyRecord;
|
import com.budwk.app.zhgh.learning.models.LearningStudyRecord;
|
||||||
import com.budwk.app.zhgh.learning.models.LearningStudyRule;
|
import com.budwk.app.zhgh.learning.models.LearningStudyRule;
|
||||||
import com.budwk.app.zhgh.learning.models.LearningStudySegment;
|
import com.budwk.app.zhgh.learning.models.LearningStudySegment;
|
||||||
|
import com.budwk.app.zhgh.learning.service.LearningResourceProgressService;
|
||||||
import io.swagger.annotations.Api;
|
import io.swagger.annotations.Api;
|
||||||
import io.swagger.annotations.ApiOperation;
|
import io.swagger.annotations.ApiOperation;
|
||||||
import org.nutz.dao.Chain;
|
import org.nutz.dao.Chain;
|
||||||
@@ -50,6 +51,9 @@ public class LearningStudyRecordController {
|
|||||||
@Inject
|
@Inject
|
||||||
private Dao dao;
|
private Dao dao;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private LearningResourceProgressService resourceProgressService;
|
||||||
|
|
||||||
@At
|
@At
|
||||||
@ApiOperation("学习记录分页")
|
@ApiOperation("学习记录分页")
|
||||||
@SaCheckPermission("learning.my.record")
|
@SaCheckPermission("learning.my.record")
|
||||||
@@ -58,6 +62,7 @@ public class LearningStudyRecordController {
|
|||||||
@Param("unionId") String unionId,
|
@Param("unionId") String unionId,
|
||||||
@Param("courseId") String courseId,
|
@Param("courseId") String courseId,
|
||||||
@Param("completeStatus") String completeStatus) {
|
@Param("completeStatus") String completeStatus) {
|
||||||
|
resourceProgressService.refreshRecords(Cnd.where("userId", "=", SecurityUtil.getUserId()));
|
||||||
Cnd cnd = Cnd.NEW();
|
Cnd cnd = Cnd.NEW();
|
||||||
if (StrUtil.isNotBlank(keyword)) {
|
if (StrUtil.isNotBlank(keyword)) {
|
||||||
SqlExpressionGroup seg = new SqlExpressionGroup();
|
SqlExpressionGroup seg = new SqlExpressionGroup();
|
||||||
@@ -68,7 +73,7 @@ public class LearningStudyRecordController {
|
|||||||
cnd.andEX("r.unionId", "=", unionId);
|
cnd.andEX("r.unionId", "=", unionId);
|
||||||
cnd.andEX("r.courseId", "=", courseId);
|
cnd.andEX("r.courseId", "=", courseId);
|
||||||
cnd.andEX("r.completeStatus", "=", completeStatus);
|
cnd.andEX("r.completeStatus", "=", completeStatus);
|
||||||
appendScope(cnd);
|
cnd.and("r.userId", "=", SecurityUtil.getUserId());
|
||||||
|
|
||||||
String orderColumn = getOrderColumn(pageForm.getPageOrderName());
|
String orderColumn = getOrderColumn(pageForm.getPageOrderName());
|
||||||
if (StrUtil.isNotBlank(orderColumn) && StrUtil.isNotBlank(pageForm.getPageOrderBy())) {
|
if (StrUtil.isNotBlank(orderColumn) && StrUtil.isNotBlank(pageForm.getPageOrderBy())) {
|
||||||
@@ -101,22 +106,21 @@ public class LearningStudyRecordController {
|
|||||||
@SaCheckPermission("learning.my.record")
|
@SaCheckPermission("learning.my.record")
|
||||||
public Result h5Summary() {
|
public Result h5Summary() {
|
||||||
String userId = SecurityUtil.getUserId();
|
String userId = SecurityUtil.getUserId();
|
||||||
Sql sql = Sqls.create("""
|
java.util.Set<String> courseIds = new java.util.HashSet<>();
|
||||||
SELECT IFNULL(SUM(IFNULL(studySeconds, 0)), 0) AS studySeconds,
|
for (LearningStudyRecord record : dao.query(LearningStudyRecord.class, Cnd.where("userId", "=", userId))) {
|
||||||
COUNT(DISTINCT courseId) AS courseCount,
|
courseIds.add(record.getCourseId());
|
||||||
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 seconds = 0, outlines = 0, completedOutlines = 0, completedCourses = 0;
|
||||||
|
for (String courseId : courseIds) {
|
||||||
|
NutMap state = resourceProgressService.courseState(courseId, userId);
|
||||||
|
seconds += actualStudySeconds(courseId, userId);
|
||||||
|
outlines += state.getInt("outlineCount", 0);
|
||||||
|
completedOutlines += state.getInt("completedOutlineCount", 0);
|
||||||
|
if (state.getInt("outlineCount", 0) > 0 && state.getInt("progressPercent", 0) == 100) completedCourses++;
|
||||||
|
}
|
||||||
|
NutMap data = NutMap.NEW().addv("studySeconds", seconds).addv("courseCount", courseIds.size())
|
||||||
|
.addv("completedCourseCount", completedCourses).addv("outlineCount", outlines)
|
||||||
|
.addv("completedOutlineCount", completedOutlines);
|
||||||
int studySeconds = data.getInt("studySeconds", 0);
|
int studySeconds = data.getInt("studySeconds", 0);
|
||||||
data.put("studyHour", studySeconds / 3600);
|
data.put("studyHour", studySeconds / 3600);
|
||||||
data.put("studyMinute", studySeconds % 3600 / 60);
|
data.put("studyMinute", studySeconds % 3600 / 60);
|
||||||
@@ -140,6 +144,8 @@ public class LearningStudyRecordController {
|
|||||||
FROM learning_study_record r
|
FROM learning_study_record r
|
||||||
LEFT JOIN learning_course c ON c.id = r.courseId
|
LEFT JOIN learning_course c ON c.id = r.courseId
|
||||||
WHERE r.userId = @userId
|
WHERE r.userId = @userId
|
||||||
|
AND EXISTS (SELECT 1 FROM learning_outline_resource pr WHERE pr.outlineId = r.outlineId
|
||||||
|
AND pr.status = 'enabled' AND COALESCE(pr.allowPreview, 1) = 1)
|
||||||
GROUP BY r.courseId
|
GROUP BY r.courseId
|
||||||
ORDER BY latestStudyTime DESC
|
ORDER BY latestStudyTime DESC
|
||||||
""");
|
""");
|
||||||
@@ -147,7 +153,14 @@ public class LearningStudyRecordController {
|
|||||||
sql.setCallback(Sqls.callback.maps());
|
sql.setCallback(Sqls.callback.maps());
|
||||||
dao.execute(sql);
|
dao.execute(sql);
|
||||||
List<NutMap> rows = sql.getList(NutMap.class);
|
List<NutMap> rows = sql.getList(NutMap.class);
|
||||||
rows.forEach(row -> row.put("studyTimeText", formatStudySeconds(row.getInt("studySeconds", 0))));
|
rows.forEach(row -> {
|
||||||
|
row.putAll(resourceProgressService.courseState(row.getString("courseId"), userId));
|
||||||
|
row.put("studySeconds", actualStudySeconds(row.getString("courseId"), userId));
|
||||||
|
row.put("studyTimeText", formatStudySeconds(row.getInt("studySeconds", 0)));
|
||||||
|
});
|
||||||
|
rows.sort(java.util.Comparator.comparingInt((NutMap row) ->
|
||||||
|
row.getInt("progressPercent", 0) == 100 && row.getInt("outlineCount", 0) > 0
|
||||||
|
&& row.getInt("completedOutlineCount", 0) >= row.getInt("outlineCount", 0) ? 1 : 0));
|
||||||
return Result.success(rows);
|
return Result.success(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +183,9 @@ public class LearningStudyRecordController {
|
|||||||
if (course == null || resource == null || !courseId.equals(resource.getCourseId())) {
|
if (course == null || resource == null || !courseId.equals(resource.getCourseId())) {
|
||||||
return Result.error("课程资料不存在");
|
return Result.error("课程资料不存在");
|
||||||
}
|
}
|
||||||
|
if (Boolean.FALSE.equals(resource.getAllowPreview()) || !"enabled".equals(resource.getStatus())) {
|
||||||
|
return Result.error("该资料不允许学习预览");
|
||||||
|
}
|
||||||
LearningCourseOutline outline = dao.fetch(LearningCourseOutline.class, resource.getOutlineId());
|
LearningCourseOutline outline = dao.fetch(LearningCourseOutline.class, resource.getOutlineId());
|
||||||
if (outline == null) {
|
if (outline == null) {
|
||||||
return Result.error("章节不存在");
|
return Result.error("章节不存在");
|
||||||
@@ -184,32 +200,17 @@ public class LearningStudyRecordController {
|
|||||||
.and("outlineId", "=", outline.getId()));
|
.and("outlineId", "=", outline.getId()));
|
||||||
if (record == null) {
|
if (record == null) {
|
||||||
record = buildRecord(userId, course, outline, now);
|
record = buildRecord(userId, course, outline, now);
|
||||||
int startPositionSeconds = sanitizePosition(positionSeconds, 0);
|
|
||||||
record.setLastPositionSeconds(startPositionSeconds);
|
|
||||||
record.setResourceMaxPositionData(resourceMaxPositionData(resourceId, startPositionSeconds));
|
|
||||||
dao.insert(record);
|
dao.insert(record);
|
||||||
} else {
|
} else {
|
||||||
int requiredSeconds = requiredSeconds(outline.getId());
|
record = resourceProgressService.lock(record.getId());
|
||||||
|
}
|
||||||
int startPositionSeconds = sanitizePosition(positionSeconds, 0);
|
int startPositionSeconds = sanitizePosition(positionSeconds, 0);
|
||||||
Map<String, Integer> resourceMaxPositions = resourceMaxPositions(record.getResourceMaxPositionData());
|
resourceProgressService.savePosition(record, resourceId, startPositionSeconds, null, false);
|
||||||
int maxPositionSeconds = value(resourceMaxPositions.get(resourceId));
|
dao.update(LearningStudyRecord.class, Chain.make("latestStudyTime", now)
|
||||||
boolean needUpdateMaxPosition = !resourceMaxPositions.containsKey(resourceId) || startPositionSeconds > maxPositionSeconds;
|
.add("courseName", course.getCourseName()).add("outlineName", outline.getTitle())
|
||||||
if (needUpdateMaxPosition) {
|
.add("lastPositionSeconds", startPositionSeconds)
|
||||||
resourceMaxPositions.put(resourceId, Math.max(startPositionSeconds, maxPositionSeconds));
|
.add("resourceMaxPositionData", record.getResourceMaxPositionData()),
|
||||||
}
|
Cnd.where("id", "=", record.getId()));
|
||||||
Chain chain = 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");
|
|
||||||
if (needUpdateMaxPosition) {
|
|
||||||
chain.add("resourceMaxPositionData", Json.toJson(resourceMaxPositions));
|
|
||||||
}
|
|
||||||
dao.update(LearningStudyRecord.class, chain, Cnd.where("id", "=", record.getId()));
|
|
||||||
record = dao.fetch(LearningStudyRecord.class, record.getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
LearningStudySegment segment = new LearningStudySegment();
|
LearningStudySegment segment = new LearningStudySegment();
|
||||||
segment.setRecordId(record.getId());
|
segment.setRecordId(record.getId());
|
||||||
@@ -230,16 +231,12 @@ public class LearningStudyRecordController {
|
|||||||
Cnd.where("id", "=", courseId));
|
Cnd.where("id", "=", courseId));
|
||||||
LearningCourse updatedCourse = dao.fetch(LearningCourse.class, courseId);
|
LearningCourse updatedCourse = dao.fetch(LearningCourse.class, courseId);
|
||||||
|
|
||||||
return Result.success(NutMap.NEW()
|
resourceProgressService.refreshOutline(record, requiredSeconds(outline.getId()));
|
||||||
.addv("recordId", record.getId())
|
NutMap resourceState = resourceProgressService.state(record, resource);
|
||||||
.addv("segmentId", segment.getId())
|
resourceState.addv("courseProgress", resourceProgressService.courseState(record.getCourseId(), record.getUserId()));
|
||||||
.addv("studySeconds", record.getStudySeconds())
|
resourceState.addv("recordId", record.getId()).addv("segmentId", segment.getId())
|
||||||
.addv("lastPositionSeconds", record.getLastPositionSeconds())
|
.addv("viewCount", value(updatedCourse == null ? null : updatedCourse.getViewCount()));
|
||||||
.addv("maxPositionSeconds", resourceMaxPosition(record, resourceId))
|
return Result.success(resourceState);
|
||||||
.addv("requiredSeconds", record.getRequiredSeconds())
|
|
||||||
.addv("progressPercent", record.getProgressPercent())
|
|
||||||
.addv("completeStatus", record.getCompleteStatus())
|
|
||||||
.addv("viewCount", value(updatedCourse == null ? null : updatedCourse.getViewCount())));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@At
|
@At
|
||||||
@@ -293,11 +290,15 @@ public class LearningStudyRecordController {
|
|||||||
if (resource == null || !courseId.equals(resource.getCourseId())) {
|
if (resource == null || !courseId.equals(resource.getCourseId())) {
|
||||||
return Result.success();
|
return Result.success();
|
||||||
}
|
}
|
||||||
dao.update(LearningStudyRecord.class,
|
LearningStudyRecord record = dao.fetch(LearningStudyRecord.class,
|
||||||
Chain.make("lastPositionSeconds", positionSeconds).add("latestStudyTime", new Date()),
|
Cnd.where("userId", "=", SecurityUtil.getUserId()).and("courseId", "=", courseId)
|
||||||
Cnd.where("userId", "=", SecurityUtil.getUserId())
|
|
||||||
.and("courseId", "=", courseId)
|
|
||||||
.and("outlineId", "=", resource.getOutlineId()));
|
.and("outlineId", "=", resource.getOutlineId()));
|
||||||
|
if (record == null) return Result.success();
|
||||||
|
record = resourceProgressService.lock(record.getId());
|
||||||
|
resourceProgressService.savePosition(record, resourceId, positionSeconds, null, false);
|
||||||
|
dao.update(LearningStudyRecord.class, Chain.make("lastPositionSeconds", positionSeconds)
|
||||||
|
.add("latestStudyTime", new Date()).add("resourceMaxPositionData", record.getResourceMaxPositionData()),
|
||||||
|
Cnd.where("id", "=", record.getId()));
|
||||||
return Result.success();
|
return Result.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,53 +367,57 @@ public class LearningStudyRecordController {
|
|||||||
|
|
||||||
private NutMap addActiveSeconds(LearningStudySegment segment, int activeSeconds, boolean finish, boolean completed, Integer positionSeconds) {
|
private NutMap addActiveSeconds(LearningStudySegment segment, int activeSeconds, boolean finish, boolean completed, Integer positionSeconds) {
|
||||||
Date now = new Date();
|
Date now = new Date();
|
||||||
LearningStudyRecord record = dao.fetch(LearningStudyRecord.class, segment.getRecordId());
|
LearningStudyRecord record = resourceProgressService.lock(segment.getRecordId());
|
||||||
LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, segment.getResourceId());
|
LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, segment.getResourceId());
|
||||||
int studyIncrementSeconds = effectiveStudySeconds(record, resource, segment.getResourceId(), activeSeconds, positionSeconds);
|
segment = dao.fetch(LearningStudySegment.class, segment.getId());
|
||||||
int segmentSeconds = value(segment.getActiveSeconds()) + studyIncrementSeconds;
|
if (!"studying".equals(segment.getState())) {
|
||||||
dao.update(LearningStudySegment.class, Chain.make("activeSeconds", segmentSeconds).add("lastHeartbeatTime", now), Cnd.where("id", "=", segment.getId()));
|
return resourceProgressService.state(record, resource);
|
||||||
|
|
||||||
int studySeconds = value(record.getStudySeconds()) + studyIncrementSeconds;
|
|
||||||
int requiredSeconds = requiredSeconds(record.getOutlineId());
|
|
||||||
int maxPositionSeconds = resourceMaxPosition(record, segment.getResourceId());
|
|
||||||
// 自然播放结束且当前章节仅有一个媒体资源时,补齐定时器未统计到的尾部秒数,避免跨端状态不一致。
|
|
||||||
if (completed && canCompleteByNaturalEnd(segment, positionSeconds, requiredSeconds)) {
|
|
||||||
studySeconds = Math.max(studySeconds, requiredSeconds);
|
|
||||||
}
|
}
|
||||||
int progressPercent = progress(studySeconds, requiredSeconds);
|
if (Boolean.FALSE.equals(resource.getAllowPreview()) || !"enabled".equals(resource.getStatus())) {
|
||||||
String completeStatus = completeStatus(studySeconds, requiredSeconds, finish);
|
dao.update(LearningStudySegment.class, Chain.make("state", "interrupted").add("endTime", now),
|
||||||
if ("completed".equals(completeStatus)) {
|
Cnd.where("id", "=", segment.getId()));
|
||||||
progressPercent = 100;
|
resourceProgressService.refreshOutline(record, requiredSeconds(record.getOutlineId()));
|
||||||
|
return resourceProgressService.state(record, resource);
|
||||||
}
|
}
|
||||||
Chain chain = Chain.make("latestStudyTime", now)
|
NutMap before = resourceProgressService.state(record, resource);
|
||||||
.add("studySeconds", studySeconds)
|
int increment = effectiveStudySeconds(record, resource, segment.getResourceId(), activeSeconds, positionSeconds);
|
||||||
.add("requiredSeconds", requiredSeconds)
|
LearningStudyRule rule = resourceProgressService.outlineRule(record.getOutlineId());
|
||||||
.add("progressPercent", progressPercent)
|
boolean timeRule = rule != null && "min_study_time".equals(rule.getCompletionRule());
|
||||||
.add("completeStatus", completeStatus);
|
if (timeRule) increment = activeSeconds;
|
||||||
int lastPositionSeconds = value(record.getLastPositionSeconds());
|
int duration = value(resource.getDurationSeconds());
|
||||||
if (positionSeconds != null && positionSeconds >= 0) {
|
// 自然播放结束以到达资源末尾为准,补齐计时器和心跳延迟遗漏的秒数。
|
||||||
lastPositionSeconds = positionSeconds;
|
boolean naturalEnd = completed && isMediaResource(resource) && positionSeconds != null && positionSeconds > 0
|
||||||
chain.add("lastPositionSeconds", positionSeconds);
|
&& (duration <= 0 || positionSeconds >= Math.max(0, duration - 2));
|
||||||
}
|
if (naturalEnd && duration > 0 && !timeRule) {
|
||||||
if (resource != null && isMediaResource(resource)) {
|
increment = Math.max(increment, duration - before.getInt("studySeconds", 0));
|
||||||
Map<String, Integer> resourceMaxPositions = resourceMaxPositions(record.getResourceMaxPositionData());
|
|
||||||
int currentPositionSeconds = Math.max(0, positionSeconds == null ? 0 : positionSeconds);
|
|
||||||
maxPositionSeconds = Math.max(value(resourceMaxPositions.get(segment.getResourceId())), currentPositionSeconds);
|
|
||||||
resourceMaxPositions.put(segment.getResourceId(), maxPositionSeconds);
|
|
||||||
chain.add("resourceMaxPositionData", Json.toJson(resourceMaxPositions));
|
|
||||||
}
|
|
||||||
if ("completed".equals(completeStatus) && record.getCompletedAt() == null) {
|
|
||||||
chain.add("completedAt", now);
|
|
||||||
}
|
}
|
||||||
|
Chain segmentChain = Chain.make("activeSeconds", value(segment.getActiveSeconds()) + increment)
|
||||||
|
.add("lastHeartbeatTime", now);
|
||||||
|
if (finish) segmentChain.add("state", "finished").add("endTime", now);
|
||||||
|
dao.update(LearningStudySegment.class, segmentChain, Cnd.where("id", "=", segment.getId()));
|
||||||
|
Integer maxPosition = isMediaResource(resource) && positionSeconds != null ? Math.max(0, positionSeconds) : null;
|
||||||
|
resourceProgressService.savePosition(record, resource.getId(), positionSeconds, maxPosition, naturalEnd);
|
||||||
|
Chain chain = Chain.make("latestStudyTime", now).add("resourceMaxPositionData", record.getResourceMaxPositionData());
|
||||||
|
if (positionSeconds != null && positionSeconds >= 0) chain.add("lastPositionSeconds", positionSeconds);
|
||||||
dao.update(LearningStudyRecord.class, chain, Cnd.where("id", "=", record.getId()));
|
dao.update(LearningStudyRecord.class, chain, Cnd.where("id", "=", record.getId()));
|
||||||
return NutMap.NEW()
|
resourceProgressService.refreshOutline(record, requiredSeconds(record.getOutlineId()));
|
||||||
.addv("studySeconds", studySeconds)
|
NutMap result = resourceProgressService.state(record, resource);
|
||||||
.addv("studyTimeText", formatStudySeconds(studySeconds))
|
result.addv("courseProgress", resourceProgressService.courseState(record.getCourseId(), record.getUserId()));
|
||||||
.addv("lastPositionSeconds", lastPositionSeconds)
|
result.addv("studyTimeText", formatStudySeconds(result.getInt("studySeconds", 0)));
|
||||||
.addv("maxPositionSeconds", maxPositionSeconds)
|
return result;
|
||||||
.addv("requiredSeconds", requiredSeconds)
|
}
|
||||||
.addv("progressPercent", progressPercent)
|
|
||||||
.addv("completeStatus", completeStatus);
|
private int actualStudySeconds(String courseId, String userId) {
|
||||||
|
Sql sql = Sqls.create("""
|
||||||
|
SELECT COALESCE(SUM(s.activeSeconds), 0)
|
||||||
|
FROM learning_study_segment s
|
||||||
|
JOIN learning_study_record r ON r.id = s.recordId AND r.userId = s.userId AND r.courseId = s.courseId
|
||||||
|
WHERE s.courseId = @courseId AND s.userId = @userId
|
||||||
|
""");
|
||||||
|
sql.params().set("courseId", courseId).set("userId", userId);
|
||||||
|
sql.setCallback(Sqls.callback.integer());
|
||||||
|
dao.execute(sql);
|
||||||
|
return Math.max(0, sql.getInt());
|
||||||
}
|
}
|
||||||
|
|
||||||
private LearningStudySegment fetchOwnStudyingSegment(String segmentId) {
|
private LearningStudySegment fetchOwnStudyingSegment(String segmentId) {
|
||||||
@@ -431,21 +436,7 @@ public class LearningStudyRecordController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private int requiredSeconds(String outlineId) {
|
private int requiredSeconds(String outlineId) {
|
||||||
LearningStudyRule rule = dao.fetch(LearningStudyRule.class, Cnd.where("targetType", "=", "outline")
|
return resourceProgressService.requiredSeconds(outlineId);
|
||||||
.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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,4 +13,10 @@ public class LearningStatisticsPageForm extends PageForm {
|
|||||||
private String courseName;
|
private String courseName;
|
||||||
|
|
||||||
private String unionId;
|
private String unionId;
|
||||||
|
|
||||||
|
private String loginName;
|
||||||
|
|
||||||
|
private String userName;
|
||||||
|
|
||||||
|
private String completeStatus;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.budwk.app.zhgh.learning.service;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.budwk.app.zhgh.learning.models.LearningOutlineResource;
|
||||||
|
import org.nutz.dao.Cnd;
|
||||||
|
import org.nutz.dao.Dao;
|
||||||
|
import org.nutz.ioc.loader.annotation.Inject;
|
||||||
|
import org.nutz.ioc.loader.annotation.IocBean;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
@IocBean
|
||||||
|
public class LearningFileAccessService {
|
||||||
|
@Inject
|
||||||
|
private Dao dao;
|
||||||
|
|
||||||
|
public boolean check(String fileId, boolean download, HttpServletResponse response) throws IOException {
|
||||||
|
if (StrUtil.isBlank(fileId)) {
|
||||||
|
response.sendError(400, "文件ID不能为空");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Pattern filePattern = Pattern.compile("(?<![a-zA-Z0-9])" + Pattern.quote(fileId) + "(?![a-zA-Z0-9])", Pattern.CASE_INSENSITIVE);
|
||||||
|
for (LearningOutlineResource resource : dao.query(LearningOutlineResource.class,
|
||||||
|
Cnd.where("fileData", "like", "%" + fileId + "%"))) {
|
||||||
|
if (!filePattern.matcher(resource.getFileData()).find()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
StpUtil.checkPermission("learning.course.display");
|
||||||
|
boolean allowed = download ? Boolean.TRUE.equals(resource.getAllowDownload())
|
||||||
|
: !Boolean.FALSE.equals(resource.getAllowPreview());
|
||||||
|
if (!"enabled".equals(resource.getStatus()) || !allowed) {
|
||||||
|
response.sendError(403, download ? "该资料不允许下载" : "该资料不允许预览");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.setHeader("Cache-Control", "no-store");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
package com.budwk.app.zhgh.learning.service;
|
||||||
|
|
||||||
|
import com.budwk.app.zhgh.learning.models.LearningCourseOutline;
|
||||||
|
import com.budwk.app.zhgh.learning.models.LearningOutlineResource;
|
||||||
|
import com.budwk.app.zhgh.learning.models.LearningStudyRecord;
|
||||||
|
import com.budwk.app.zhgh.learning.models.LearningStudyRule;
|
||||||
|
import org.nutz.dao.Chain;
|
||||||
|
import org.nutz.dao.Cnd;
|
||||||
|
import org.nutz.dao.Dao;
|
||||||
|
import org.nutz.dao.Sqls;
|
||||||
|
import org.nutz.dao.sql.Sql;
|
||||||
|
import org.nutz.ioc.loader.annotation.Inject;
|
||||||
|
import org.nutz.ioc.loader.annotation.IocBean;
|
||||||
|
import org.nutz.json.Json;
|
||||||
|
import org.nutz.lang.util.NutMap;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@IocBean
|
||||||
|
public class LearningResourceProgressService {
|
||||||
|
@Inject
|
||||||
|
private Dao dao;
|
||||||
|
|
||||||
|
public LearningStudyRecord lock(String id) {
|
||||||
|
Sql sql = Sqls.create("SELECT * FROM learning_study_record WHERE id = @id FOR UPDATE");
|
||||||
|
sql.params().set("id", id);
|
||||||
|
sql.setEntity(dao.getEntity(LearningStudyRecord.class));
|
||||||
|
sql.setCallback(Sqls.callback.entity());
|
||||||
|
dao.execute(sql);
|
||||||
|
return sql.getObject(LearningStudyRecord.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public NutMap state(LearningStudyRecord record, LearningOutlineResource resource) {
|
||||||
|
NutMap totals = NutMap.NEW();
|
||||||
|
if (record != null) {
|
||||||
|
Sql sql = Sqls.create("""
|
||||||
|
SELECT COUNT(1) AS visits, COALESCE(SUM(activeSeconds), 0) AS studySeconds,
|
||||||
|
COALESCE(SUM(CASE WHEN state = 'finished' THEN 1 ELSE 0 END), 0) AS finishes
|
||||||
|
FROM learning_study_segment WHERE recordId = @recordId AND resourceId = @resourceId
|
||||||
|
""");
|
||||||
|
sql.params().set("recordId", record.getId()).set("resourceId", resource.getId());
|
||||||
|
sql.setCallback(Sqls.callback.map());
|
||||||
|
dao.execute(sql);
|
||||||
|
totals = sql.getObject(NutMap.class);
|
||||||
|
}
|
||||||
|
if (totals == null) totals = NutMap.NEW();
|
||||||
|
NutMap positions = positions(record);
|
||||||
|
String id = resource.getId();
|
||||||
|
int seconds = Math.max(0, totals.getInt("studySeconds", 0));
|
||||||
|
int required = Math.max(0, resource.getDurationSeconds() == null ? 0 : resource.getDurationSeconds());
|
||||||
|
boolean studied = totals.getInt("visits", 0) > 0;
|
||||||
|
boolean media = "video".equals(resource.getResourceType()) || "audio".equals(resource.getResourceType());
|
||||||
|
boolean completed = positions.getInt("completed:" + id, 0) == 1
|
||||||
|
|| (required > 0 && seconds >= required)
|
||||||
|
|| (!media && required == 0 && totals.getInt("finishes", 0) > 0);
|
||||||
|
int percent = completed ? 100 : required > 0 ? Math.min(99, (int) (seconds * 100.0 / required)) : 0;
|
||||||
|
int maxPosition = Math.max(0, positions.getInt(id, 0));
|
||||||
|
return NutMap.NEW().addv("resourceId", id).addv("studied", studied)
|
||||||
|
.addv("studySeconds", seconds).addv("requiredSeconds", required)
|
||||||
|
.addv("progressPercent", percent)
|
||||||
|
.addv("completeStatus", completed ? "completed" : studied ? "studying" : "not_started")
|
||||||
|
.addv("lastPositionSeconds", Math.max(0, positions.getInt("last:" + id, maxPosition)))
|
||||||
|
.addv("maxPositionSeconds", maxPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留原有资源最高位置键,附加独立续播位置和完成标记,不修改数据库结构。
|
||||||
|
public void savePosition(LearningStudyRecord record, String resourceId, Integer position, Integer maxPosition, boolean completed) {
|
||||||
|
NutMap positions = positions(record);
|
||||||
|
if (position != null && position >= 0) positions.put("last:" + resourceId, position);
|
||||||
|
if (maxPosition != null) positions.put(resourceId, Math.max(positions.getInt(resourceId, 0), maxPosition));
|
||||||
|
if (completed) positions.put("completed:" + resourceId, 1);
|
||||||
|
record.setResourceMaxPositionData(Json.toJson(positions));
|
||||||
|
}
|
||||||
|
|
||||||
|
public LearningStudyRule outlineRule(String outlineId) {
|
||||||
|
return dao.fetch(LearningStudyRule.class, Cnd.where("targetType", "=", "outline")
|
||||||
|
.and("targetId", "=", outlineId).and("status", "=", "enabled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int requiredSeconds(String outlineId) {
|
||||||
|
LearningStudyRule rule = outlineRule(outlineId);
|
||||||
|
if (rule != null && "min_study_time".equals(rule.getCompletionRule())) {
|
||||||
|
return Math.max(0, rule.getMinStudySeconds() == null ? 0 : rule.getMinStudySeconds());
|
||||||
|
}
|
||||||
|
Sql sql = Sqls.create("SELECT COALESCE(SUM(durationSeconds), 0) FROM learning_outline_resource WHERE outlineId = @outlineId AND status = 'enabled' AND COALESCE(allowPreview, 1) = 1");
|
||||||
|
sql.params().set("outlineId", outlineId);
|
||||||
|
sql.setCallback(Sqls.callback.integer());
|
||||||
|
dao.execute(sql);
|
||||||
|
return Math.max(0, sql.getInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
public NutMap outlineState(LearningStudyRecord record, LearningCourseOutline outline) {
|
||||||
|
List<LearningOutlineResource> resources = outline != null
|
||||||
|
&& "enabled".equals(outline.getStatus())
|
||||||
|
? dao.query(LearningOutlineResource.class, Cnd.where("outlineId", "=", outline.getId())
|
||||||
|
.and("courseId", "=", outline.getCourseId()).and("status", "=", "enabled")
|
||||||
|
.and(Cnd.exps("allowPreview", "=", true).or("allowPreview", "is", null))) : List.of();
|
||||||
|
int seconds = 0;
|
||||||
|
int totalPercent = 0;
|
||||||
|
boolean studied = false;
|
||||||
|
boolean completed = !resources.isEmpty();
|
||||||
|
for (LearningOutlineResource resource : resources) {
|
||||||
|
NutMap state = state(record, resource);
|
||||||
|
seconds += state.getInt("studySeconds", 0);
|
||||||
|
totalPercent += state.getInt("progressPercent", 0);
|
||||||
|
studied |= state.getBoolean("studied", false);
|
||||||
|
completed &= "completed".equals(state.getString("completeStatus"));
|
||||||
|
}
|
||||||
|
int percent = resources.isEmpty() ? 0 : totalPercent / resources.size();
|
||||||
|
LearningStudyRule rule = outline == null ? null : outlineRule(outline.getId());
|
||||||
|
int required = resources.isEmpty() ? 0 : requiredSeconds(outline.getId());
|
||||||
|
String mode = rule == null ? "all_required_resource" : rule.getCompletionRule();
|
||||||
|
if (!resources.isEmpty() && "resource_progress".equals(mode)) {
|
||||||
|
int threshold = rule.getCompletePercent() == null ? 100 : Math.max(1, Math.min(100, rule.getCompletePercent()));
|
||||||
|
completed = studied && totalPercent >= threshold * resources.size();
|
||||||
|
percent = (int) Math.min(100, totalPercent * 100L / (resources.size() * threshold));
|
||||||
|
} else if (!resources.isEmpty() && "min_study_time".equals(mode)) {
|
||||||
|
completed = studied && required > 0 && seconds >= required;
|
||||||
|
percent = required > 0 ? (int) Math.min(100, seconds * 100L / required) : 0;
|
||||||
|
}
|
||||||
|
return NutMap.NEW().addv("counted", !resources.isEmpty()).addv("studySeconds", seconds)
|
||||||
|
.addv("requiredSeconds", required).addv("progressPercent", completed ? 100 : Math.min(99, percent))
|
||||||
|
.addv("completeStatus", completed ? "completed" : studied ? "studying" : "not_started");
|
||||||
|
}
|
||||||
|
|
||||||
|
public NutMap courseState(String courseId, String userId) {
|
||||||
|
List<LearningStudyRecord> records = dao.query(LearningStudyRecord.class,
|
||||||
|
Cnd.where("courseId", "=", courseId).and("userId", "=", userId));
|
||||||
|
java.util.Map<String, LearningStudyRecord> recordMap = new java.util.HashMap<>();
|
||||||
|
for (LearningStudyRecord record : records) recordMap.put(record.getOutlineId(), record);
|
||||||
|
int count = 0, completed = 0, percent = 0, seconds = 0;
|
||||||
|
for (LearningCourseOutline outline : dao.query(LearningCourseOutline.class,
|
||||||
|
Cnd.where("courseId", "=", courseId).and("status", "=", "enabled"))) {
|
||||||
|
NutMap state = outlineState(recordMap.get(outline.getId()), outline);
|
||||||
|
if (!state.getBoolean("counted", false)) continue;
|
||||||
|
count++;
|
||||||
|
percent += state.getInt("progressPercent", 0);
|
||||||
|
seconds += state.getInt("studySeconds", 0);
|
||||||
|
if ("completed".equals(state.getString("completeStatus"))) completed++;
|
||||||
|
}
|
||||||
|
return NutMap.NEW().addv("outlineCount", count).addv("completedOutlineCount", completed)
|
||||||
|
.addv("progressPercent", count == 0 ? 0 : percent / count).addv("studySeconds", seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void refreshRecords(Cnd condition) {
|
||||||
|
for (LearningStudyRecord record : dao.query(LearningStudyRecord.class, condition)) {
|
||||||
|
refreshOutline(record, requiredSeconds(record.getOutlineId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void refreshOutline(String outlineId) {
|
||||||
|
refreshRecords(Cnd.where("outlineId", "=", outlineId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void refreshOutline(LearningStudyRecord record, int requiredSeconds) {
|
||||||
|
NutMap state = outlineState(record, dao.fetch(LearningCourseOutline.class, record.getOutlineId()));
|
||||||
|
boolean completed = "completed".equals(state.getString("completeStatus"));
|
||||||
|
dao.update(LearningStudyRecord.class, Chain.make("studySeconds", state.getInt("studySeconds", 0))
|
||||||
|
.add("requiredSeconds", state.getInt("requiredSeconds", 0)).add("progressPercent", state.getInt("progressPercent", 0))
|
||||||
|
.add("completeStatus", state.getString("completeStatus"))
|
||||||
|
.add("completedAt", completed ? record.getCompletedAt() == null ? new Date() : record.getCompletedAt() : null),
|
||||||
|
Cnd.where("id", "=", record.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private NutMap positions(LearningStudyRecord record) {
|
||||||
|
if (record == null || record.getResourceMaxPositionData() == null) return NutMap.NEW();
|
||||||
|
try {
|
||||||
|
NutMap positions = Json.fromJson(NutMap.class, record.getResourceMaxPositionData());
|
||||||
|
return positions == null ? NutMap.NEW() : positions;
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return NutMap.NEW();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,21 +47,8 @@ layout("/layouts/platform.html"){
|
|||||||
<el-option v-for="item in courseOptions" :key="item.id" :label="item.courseName" :value="item.id"></el-option>
|
<el-option v-for="item in courseOptions" :key="item.id" :label="item.courseName" :value="item.id"></el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
</search-item>
|
</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">
|
<div class="search-query">
|
||||||
<el-button size="medium" type="primary" icon="el-icon-search" @click="loadCourses">搜索</el-button>
|
<el-button size="medium" type="primary" @click="loadCourses">搜索</el-button>
|
||||||
<el-button size="medium" @click="resetCourseQuery">重置</el-button>
|
<el-button size="medium" @click="resetCourseQuery">重置</el-button>
|
||||||
</div>
|
</div>
|
||||||
</search>
|
</search>
|
||||||
@@ -100,7 +87,7 @@ layout("/layouts/platform.html"){
|
|||||||
<el-card shadow="never" style="height: 100%; overflow: auto">
|
<el-card shadow="never" style="height: 100%; overflow: auto">
|
||||||
<el-empty v-if="!selectedNode.id" description="请选择左侧章/节节点"></el-empty>
|
<el-empty v-if="!selectedNode.id" description="请选择左侧章/节节点"></el-empty>
|
||||||
<el-tabs v-else v-model="activeTab">
|
<el-tabs v-else v-model="activeTab">
|
||||||
<el-tab-pane label="基础信息" name="basic">
|
<el-tab-pane label="基础信息" name="basic" style="padding-top: 12px">
|
||||||
<el-form :model="nodeForm" ref="nodeForm" label-width="110px" :rules="nodeRules">
|
<el-form :model="nodeForm" ref="nodeForm" label-width="110px" :rules="nodeRules">
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
@@ -179,7 +166,7 @@ layout("/layouts/platform.html"){
|
|||||||
</el-pagination>
|
</el-pagination>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="完成规则" name="rule">
|
<el-tab-pane label="完成规则" name="rule" style="padding-top: 12px">
|
||||||
<el-form :model="ruleForm" label-width="150px">
|
<el-form :model="ruleForm" label-width="150px">
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
@@ -215,6 +202,7 @@ layout("/layouts/platform.html"){
|
|||||||
<el-input-number v-model="ruleForm.minStudyMinutes" :min="0" :precision="0" style="width: 100%"></el-input-number>
|
<el-input-number v-model="ruleForm.minStudyMinutes" :min="0" :precision="0" style="width: 100%"></el-input-number>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
<!--
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="解锁规则">
|
<el-form-item label="解锁规则">
|
||||||
<el-select v-model="ruleForm.unlockRule" style="width: 100%">
|
<el-select v-model="ruleForm.unlockRule" style="width: 100%">
|
||||||
@@ -222,7 +210,9 @@ layout("/layouts/platform.html"){
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
-->
|
||||||
</el-row>
|
</el-row>
|
||||||
|
<!--
|
||||||
<el-row :gutter="20">
|
<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.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.allowDrag"></el-switch></el-form-item></el-col>
|
||||||
@@ -232,13 +222,14 @@ layout("/layouts/platform.html"){
|
|||||||
<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.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-col :span="8"><el-form-item label="无操作计时"><el-switch v-model="ruleForm.inactiveCountTime"></el-switch></el-form-item></el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
-->
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" @click="saveRule">保存完成规则</el-button>
|
<el-button type="primary" @click="saveRule">保存完成规则</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="状态设置" name="status">
|
<el-tab-pane label="状态设置" name="status" style="padding-top: 12px">
|
||||||
<el-form :model="nodeForm" label-width="110px">
|
<el-form :model="nodeForm" label-width="110px">
|
||||||
<el-form-item label="节点状态">
|
<el-form-item label="节点状态">
|
||||||
<el-radio-group v-model="nodeForm.status">
|
<el-radio-group v-model="nodeForm.status">
|
||||||
@@ -345,8 +336,8 @@ layout("/layouts/platform.html"){
|
|||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-row :gutter="20">
|
<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.required" @change="resourceRequiredChange"></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 :value="resourceForm.allowPreview" @input="resourcePreviewChange"></el-switch></el-form-item></el-col>
|
||||||
<el-col :span="8"><el-form-item label="允许下载"><el-switch v-model="resourceForm.allowDownload"></el-switch></el-form-item></el-col>
|
<el-col :span="8"><el-form-item label="允许下载"><el-switch v-model="resourceForm.allowDownload"></el-switch></el-form-item></el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-form-item label="附件" prop="fileData">
|
<el-form-item label="附件" prop="fileData">
|
||||||
@@ -433,7 +424,7 @@ layout("/layouts/platform.html"){
|
|||||||
{ name: "混合资料", code: "mixed" }
|
{ name: "混合资料", code: "mixed" }
|
||||||
],
|
],
|
||||||
completionRuleOptions: [
|
completionRuleOptions: [
|
||||||
{ name: "完成所有必学资料", code: "all_required_resource" },
|
{ name: "完成全部学习资料", code: "all_required_resource" },
|
||||||
{ name: "按资料学习进度", code: "resource_progress" },
|
{ name: "按资料学习进度", code: "resource_progress" },
|
||||||
{ name: "达到最少学习时长", code: "min_study_time" },
|
{ name: "达到最少学习时长", code: "min_study_time" },
|
||||||
{ name: "管理员手动确认", code: "manual" }
|
{ name: "管理员手动确认", code: "manual" }
|
||||||
@@ -727,10 +718,22 @@ layout("/layouts/platform.html"){
|
|||||||
status: "enabled"
|
status: "enabled"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (this.resourceForm.required) this.$set(this.resourceForm, "allowPreview", true)
|
||||||
this.resourceTypeChange(this.resourceForm.resourceType)
|
this.resourceTypeChange(this.resourceForm.resourceType)
|
||||||
this.resourceDialogVisible = true
|
this.resourceDialogVisible = true
|
||||||
this.$nextTick(() => this.$refs.resourceForm && this.$refs.resourceForm.clearValidate())
|
this.$nextTick(() => this.$refs.resourceForm && this.$refs.resourceForm.clearValidate())
|
||||||
},
|
},
|
||||||
|
resourceRequiredChange(required) {
|
||||||
|
if (required) this.$set(this.resourceForm, "allowPreview", true)
|
||||||
|
},
|
||||||
|
resourcePreviewChange(allowPreview) {
|
||||||
|
if (!allowPreview && this.resourceForm.required) {
|
||||||
|
this.$set(this.resourceForm, "allowPreview", true)
|
||||||
|
this.$message.warning("必学资料必须允许预览,请先关闭“是否必学”")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.$set(this.resourceForm, "allowPreview", allowPreview)
|
||||||
|
},
|
||||||
resourceTypeChange(val) {
|
resourceTypeChange(val) {
|
||||||
const type = this.resourceTypeOptions.find(v => v.code === val)
|
const type = this.resourceTypeOptions.find(v => v.code === val)
|
||||||
this.fileAccept = type ? type.accept : ""
|
this.fileAccept = type ? type.accept : ""
|
||||||
@@ -842,7 +845,9 @@ layout("/layouts/platform.html"){
|
|||||||
media.onerror = () => {
|
media.onerror = () => {
|
||||||
window.URL.revokeObjectURL(media.src)
|
window.URL.revokeObjectURL(media.src)
|
||||||
}
|
}
|
||||||
media.src = url
|
const fileId = url.match(/[?&]id=([^&]+)/)
|
||||||
|
media.src = this.resourceForm.resourceType === "audio" && fileId
|
||||||
|
? "/platform/sys/file/preview?id=" + fileId[1] : url
|
||||||
},
|
},
|
||||||
validResourceExt(resourceType, fileExt) {
|
validResourceExt(resourceType, fileExt) {
|
||||||
const item = this.resourceTypeOptions.find(v => v.code === resourceType)
|
const item = this.resourceTypeOptions.find(v => v.code === resourceType)
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ layout("/layouts/platform.html"){
|
|||||||
.course-list { display: flex; flex-direction: column; gap: 16px; }
|
.course-list { display: flex; flex-direction: column; gap: 16px; }
|
||||||
.course-card { min-height: 160px; display: grid; grid-template-columns: 236px minmax(0, 1fr) 112px; overflow: hidden; background: #fff; border: 0; border-radius: 10px; box-shadow: 0 5px 18px rgba(30, 66, 115, .08); transition: box-shadow .2s, transform .2s; }
|
.course-card { min-height: 160px; display: grid; grid-template-columns: 236px minmax(0, 1fr) 112px; overflow: hidden; background: #fff; border: 0; border-radius: 10px; box-shadow: 0 5px 18px rgba(30, 66, 115, .08); transition: box-shadow .2s, transform .2s; }
|
||||||
.course-card:hover { transform: translateY(-1px); box-shadow: 0 9px 24px rgba(30, 67, 119, .12); }
|
.course-card:hover { transform: translateY(-1px); box-shadow: 0 9px 24px rgba(30, 67, 119, .12); }
|
||||||
.course-cover { position: relative; min-height: 160px; overflow: hidden; background: #eaf5ee; }
|
.course-cover { position: relative; width: 100%; aspect-ratio: 16 / 9; align-self: center; overflow: hidden; background: #eaf5ee; }
|
||||||
.course-cover img { width: 100%; height: 100%; display: block; object-fit: cover; }
|
.course-cover img { position: absolute; inset: 0; width: 100%; height: 100%; display: block; object-fit: cover; }
|
||||||
.course-cover-empty { width: 100%; height: 100%; min-height: 160px; display: flex; align-items: center; justify-content: center; color: #77a28a; font-size: 18px; background: linear-gradient(135deg, #e5f5ea, #f3faf6); }
|
.course-cover-empty { position: absolute; inset: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: #77a28a; font-size: 18px; background: linear-gradient(135deg, #e5f5ea, #f3faf6); }
|
||||||
.course-cover-empty i { margin-right: 8px; font-size: 32px; }
|
.course-cover-empty i { margin-right: 8px; font-size: 32px; }
|
||||||
.course-card:nth-child(3n+2) .course-cover-empty { color: #7188ae; background: linear-gradient(135deg, #e7effc, #f3f7fe); }
|
.course-card:nth-child(3n+2) .course-cover-empty { color: #7188ae; background: linear-gradient(135deg, #e7effc, #f3f7fe); }
|
||||||
.course-card:nth-child(3n) .course-cover-empty { color: #bc8768; background: linear-gradient(135deg, #fff0e7, #fff8f3); }
|
.course-card:nth-child(3n) .course-cover-empty { color: #bc8768; background: linear-gradient(135deg, #fff0e7, #fff8f3); }
|
||||||
@@ -198,7 +198,7 @@ layout("/layouts/platform.html"){
|
|||||||
return minutes ? hours + "小时" + minutes + "分钟" : hours + "小时"
|
return minutes ? hours + "小时" + minutes + "分钟" : hours + "小时"
|
||||||
},
|
},
|
||||||
startStudy(course) {
|
startStudy(course) {
|
||||||
window.location.href = loc() + "/study?id=" + encodeURIComponent(course.id || "")
|
pjaxReplace("/platform/learning/course/display/study?id=" + encodeURIComponent(course.id || ""))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
|
|||||||
@@ -108,11 +108,12 @@ layout("/layouts/platform.html"){
|
|||||||
<section class="study-main">
|
<section class="study-main">
|
||||||
<div class="player-card">
|
<div class="player-card">
|
||||||
<template v-if="selectedResource.id">
|
<template v-if="selectedResource.id">
|
||||||
<video v-if="selectedResource.resourceType === 'video'" ref="mediaPlayer" class="media-player" :src="mediaUrl" controls controlslist="nodownload" @play="mediaPlay" @ended="mediaEnded" @timeupdate="saveProgress" @loadedmetadata="mediaReady" @seeking="handleMediaSeeking" @seeked="handleMediaSeeked"></video>
|
<div v-if="!canPreview" class="empty-resource">该资料不允许预览</div>
|
||||||
<div v-else-if="selectedResource.resourceType === 'audio'" class="audio-panel"><i class="el-icon-headset"></i><div class="mb20">{{selectedResource.title}}</div><audio ref="mediaPlayer" :src="fileUrl" controls controlslist="nodownload" @play="mediaPlay" @ended="mediaEnded" @timeupdate="saveProgress" @loadedmetadata="mediaReady" @seeking="handleMediaSeeking" @seeked="handleMediaSeeked"></audio></div>
|
<video v-else-if="selectedResource.resourceType === 'video'" ref="mediaPlayer" class="media-player" :src="mediaUrl" controls :controlslist="canDownload ? null : 'nodownload'" @play="mediaPlay" @ended="mediaEnded" @timeupdate="saveProgress" @loadedmetadata="mediaReady" @seeking="handleMediaSeeking" @seeked="handleMediaSeeked"></video>
|
||||||
|
<div v-else-if="selectedResource.resourceType === 'audio'" class="audio-panel"><i class="el-icon-headset"></i><div class="mb20">{{selectedResource.title}}</div><audio ref="mediaPlayer" :src="fileUrl" preload="metadata" controls :controlslist="canDownload ? null : 'nodownload'" @play="mediaPlay" @ended="mediaEnded" @timeupdate="saveProgress" @loadedmetadata="mediaReady" @seeking="handleMediaSeeking" @seeked="handleMediaSeeked"></audio></div>
|
||||||
<img v-else-if="selectedResource.resourceType === 'image'" class="image-preview" :src="fileUrl" :alt="selectedResource.title">
|
<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>
|
<iframe v-else-if="canInlinePreview(selectedResource)" class="doc-frame" :src="inlinePreviewUrl"></iframe>
|
||||||
<div v-else class="file-fallback"><file-preview :files="selectedResource.fileData" complete_result></file-preview><el-button v-if="fileUrl" class="mt20" type="primary" icon="el-icon-view" @click="openFile">打开学习资料</el-button></div>
|
<div v-else class="file-fallback">该资料暂不支持在线预览</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="empty-resource"><i class="el-icon-reading"></i><div>请从左侧目录选择需要学习的资料</div></div>
|
<div v-else class="empty-resource"><i class="el-icon-reading"></i><div>请从左侧目录选择需要学习的资料</div></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,7 +122,8 @@ layout("/layouts/platform.html"){
|
|||||||
<div class="course-detail-head">
|
<div class="course-detail-head">
|
||||||
<div><div><span class="detail-course-title">{{course.courseName}}</span><span class="detail-course-type">{{course.courseTypeName || '课程'}}</span></div><div class="detail-learning-state">正在学习:{{selectedResource.title || '请选择课节'}}</div></div>
|
<div><div><span class="detail-course-title">{{course.courseName}}</span><span class="detail-course-type">{{course.courseTypeName || '课程'}}</span></div><div class="detail-learning-state">正在学习:{{selectedResource.title || '请选择课节'}}</div></div>
|
||||||
<div class="detail-actions">
|
<div class="detail-actions">
|
||||||
<el-button v-if="fileUrl" size="mini" icon="el-icon-share" @click="openFile">打开资料</el-button>
|
<el-button v-if="canPreview && (isMediaResource || selectedResource.resourceType === 'image' || canInlinePreview(selectedResource))" size="mini" icon="el-icon-view" @click="openFile">预览资料</el-button>
|
||||||
|
<el-button v-if="canDownload" size="mini" icon="el-icon-download" @click="downloadFile">下载资料</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="course-statistics"><span><i class="el-icon-collection"></i> 共 {{resourceCount}} 课节</span><span><i class="el-icon-time"></i> 总时长 {{formatCourseDuration(course.totalDurationSeconds)}}</span><span><i class="el-icon-view"></i> {{course.viewCount || 0}} 次观看</span></div>
|
<div class="course-statistics"><span><i class="el-icon-collection"></i> 共 {{resourceCount}} 课节</span><span><i class="el-icon-time"></i> 总时长 {{formatCourseDuration(course.totalDurationSeconds)}}</span><span><i class="el-icon-view"></i> {{course.viewCount || 0}} 次观看</span></div>
|
||||||
@@ -130,7 +132,7 @@ layout("/layouts/platform.html"){
|
|||||||
<div class="study-bottom">
|
<div class="study-bottom">
|
||||||
<span>{{studying ? '正在记录有效学习时长:' + recordStudyTime : '播放课程后,系统将自动记录有效学习时长。'}}</span>
|
<span>{{studying ? '正在记录有效学习时长:' + recordStudyTime : '播放课程后,系统将自动记录有效学习时长。'}}</span>
|
||||||
<div>
|
<div>
|
||||||
<el-button v-if="!studying && selectedResource.id && !isMediaResource" size="small" type="primary" plain :loading="startingStudy" @click="startStudy">开始学习</el-button>
|
<el-button v-if="!studying && canPreview && !isMediaResource" size="small" type="primary" plain :loading="startingStudy" @click="startStudy">开始学习</el-button>
|
||||||
<el-button v-if="studying" size="small" type="danger" plain @click="finishStudy(false)">结束学习</el-button>
|
<el-button v-if="studying" size="small" type="danger" plain @click="finishStudy(false)">结束学习</el-button>
|
||||||
<el-button v-if="hasNextResource" size="small" type="primary" @click="nextResource">下一节 <i class="el-icon-arrow-right"></i></el-button>
|
<el-button v-if="hasNextResource" size="small" type="primary" @click="nextResource">下一节 <i class="el-icon-arrow-right"></i></el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,24 +145,36 @@ layout("/layouts/platform.html"){
|
|||||||
<script nonce="${cspNonce!}">
|
<script nonce="${cspNonce!}">
|
||||||
new Vue({
|
new Vue({
|
||||||
el: "#app",
|
el: "#app",
|
||||||
components: {"file-preview": httpVueLoader("/components/plugins/sysFilePreview/index.vue")},
|
|
||||||
data() {
|
data() {
|
||||||
return { courseId: "", preferredResourceId: "", apiBase: "/platform/learning/course/display", recordApi: "/platform/learning/study/record", course: {}, treeData: [], selectedResource: {}, studying: false, startingStudy: false, currentSegmentId: "", recordProgress: 0, recordStudyTime: "0秒", pendingSeconds: 0, heartbeatTimer: null, autoResumeKey: "", lastPositionSyncAt: 0, lastActiveAt: Date.now(), inactiveLimit: 60000, lastAllowedPositionSeconds: 0, restoringMediaPosition: false, pageLoading: true, loadingCount: 0 }
|
return { courseId: "", preferredResourceId: "", apiBase: "/platform/learning/course/display", recordApi: "/platform/learning/study/record", course: {}, treeData: [], selectedResource: {}, studying: false, startingStudy: false, currentSegmentId: "", recordProgress: 0, recordStudyTime: "0秒", pendingSeconds: 0, heartbeatTimer: null, autoResumeKey: "", lastPositionSyncAt: 0, lastActiveAt: Date.now(), inactiveLimit: 60000, lastAllowedPositionSeconds: 0, restoringMediaPosition: false, pageLoading: true, loadingCount: 0 }
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
fileUrl() { return this.getFileUrl(this.selectedResource.fileData) },
|
canPreview() { return !!this.selectedResource.id && this.selectedResource.allowPreview !== false },
|
||||||
|
canDownload() { return !!this.selectedResource.id && this.selectedResource.allowDownload === true },
|
||||||
|
fileUrl() {
|
||||||
|
if (!this.canPreview) return ""
|
||||||
|
const id = this.getFileId(this.selectedResource.fileData)
|
||||||
|
return id ? "/platform/sys/file/preview?id=" + encodeURIComponent(id) : this.getFileUrl(this.selectedResource.fileData)
|
||||||
|
},
|
||||||
|
downloadUrl() {
|
||||||
|
if (!this.canDownload) return ""
|
||||||
|
const id = this.getFileId(this.selectedResource.fileData)
|
||||||
|
return id ? "/platform/sys/file/download?id=" + encodeURIComponent(id) : this.getFileUrl(this.selectedResource.fileData)
|
||||||
|
},
|
||||||
mediaUrl() {
|
mediaUrl() {
|
||||||
|
if (!this.canPreview) return ""
|
||||||
if (this.selectedResource.resourceType !== "video") return this.fileUrl
|
if (this.selectedResource.resourceType !== "video") return this.fileUrl
|
||||||
const fileId = this.getFileId(this.selectedResource.fileData)
|
const fileId = this.getFileId(this.selectedResource.fileData)
|
||||||
// 视频续播和手动拖动依赖 Range 请求,普通文件地址会导致 currentTime 定位被浏览器重置。
|
// 视频续播和手动拖动依赖 Range 请求,普通文件地址会导致 currentTime 定位被浏览器重置。
|
||||||
return fileId ? "/platform/sys/file/videoPlay?id=" + encodeURIComponent(fileId) : this.fileUrl
|
return fileId ? "/platform/sys/file/videoPlay?id=" + encodeURIComponent(fileId) : this.fileUrl
|
||||||
},
|
},
|
||||||
inlinePreviewUrl() { const id = this.getFileId(this.selectedResource.fileData); return id ? this.apiBase + "/pdfPreview?id=" + encodeURIComponent(id) : this.fileUrl },
|
inlinePreviewUrl() { if (!this.canPreview) return ""; const id = this.getFileId(this.selectedResource.fileData); const url = id ? this.apiBase + "/pdfPreview?id=" + encodeURIComponent(id) : this.fileUrl; return this.canDownload ? url : url + "#toolbar=0" },
|
||||||
resourceTypeText() { const names = {video: "视频资料", audio: "音频资料", image: "图片资料", pdf: "文档资料", word: "文档资料", ppt: "演示资料"}; return names[this.selectedResource.resourceType] || "课程资料" },
|
resourceTypeText() { const names = {video: "视频资料", audio: "音频资料", image: "图片资料", pdf: "文档资料", word: "文档资料", ppt: "演示资料"}; return names[this.selectedResource.resourceType] || "课程资料" },
|
||||||
resourceList() { return this.collectResources(this.treeData) },
|
resourceList() { return this.collectResources(this.treeData) },
|
||||||
resourceCount() { return this.resourceList.length },
|
learnableResources() { return this.resourceList.filter(item => item.allowPreview !== false) },
|
||||||
completedCount() { return this.resourceList.filter((item) => item.completeStatus === "completed" || Number(item.progressPercent || 0) >= 100).length },
|
resourceCount() { return this.learnableResources.length },
|
||||||
courseProgress() { if (!this.resourceCount) return 0; return Math.round(this.resourceList.reduce((total, item) => total + Number(item.progressPercent || 0), 0) / this.resourceCount) },
|
completedCount() { return this.learnableResources.filter((item) => item.completeStatus === "completed" || Number(item.progressPercent || 0) >= 100).length },
|
||||||
|
courseProgress() { return Number(this.course.progressPercent || 0) },
|
||||||
currentResourceIndex() { return this.resourceList.findIndex((item) => item.id === this.selectedResource.id) },
|
currentResourceIndex() { return this.resourceList.findIndex((item) => item.id === this.selectedResource.id) },
|
||||||
hasNextResource() { return this.currentResourceIndex >= 0 && this.currentResourceIndex < this.resourceList.length - 1 },
|
hasNextResource() { return this.currentResourceIndex >= 0 && this.currentResourceIndex < this.resourceList.length - 1 },
|
||||||
isMediaResource() { return ["video", "audio"].includes(this.selectedResource.resourceType) }
|
isMediaResource() { return ["video", "audio"].includes(this.selectedResource.resourceType) }
|
||||||
@@ -230,15 +244,16 @@ layout("/layouts/platform.html"){
|
|||||||
pauseSelectedMedia() { if (!["video", "audio"].includes(this.selectedResource.resourceType)) return; const player = this.$refs.mediaPlayer; if (player && !player.paused) player.pause() },
|
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) },
|
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 } },
|
stopHeartbeatTimer() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null } },
|
||||||
isEffectiveLearning() { if (document.hidden || Date.now() - this.lastActiveAt > this.inactiveLimit) return false; if (["video", "audio"].includes(this.selectedResource.resourceType)) { const player = this.$refs.mediaPlayer; return !!player && !player.paused && !player.ended } return true },
|
isEffectiveLearning() { if (document.hidden) return false; if (["video", "audio"].includes(this.selectedResource.resourceType)) { const player = this.$refs.mediaPlayer; return !!player && !player.paused && !player.ended } return true },
|
||||||
markActive() { this.lastActiveAt = Date.now() },
|
markActive() { this.lastActiveAt = Date.now() },
|
||||||
applyRecordState(data) { this.recordProgress = Number(data.progressPercent || 0); this.recordStudyTime = data.studyTimeText || this.formatSeconds(Number(data.studySeconds || 0)); if (this.selectedResource.id && data.lastPositionSeconds !== undefined) this.$set(this.selectedResource, "lastPositionSeconds", Number(data.lastPositionSeconds || 0)); if (this.selectedResource.id && data.maxPositionSeconds !== undefined) { const maxPositionSeconds = Number(data.maxPositionSeconds || 0); this.$set(this.selectedResource, "maxPositionSeconds", maxPositionSeconds); this.lastAllowedPositionSeconds = Math.max(Number(this.lastAllowedPositionSeconds || 0), maxPositionSeconds) } const resource = this.resourceList.find(item => item.id === this.selectedResource.id); if (resource) { this.$set(resource, "progressPercent", this.recordProgress); this.$set(resource, "studySeconds", Number(data.studySeconds || 0)); this.$set(resource, "completeStatus", data.completeStatus || "studying"); if (data.maxPositionSeconds !== undefined) this.$set(resource, "maxPositionSeconds", Number(data.maxPositionSeconds || 0)) } },
|
applyRecordState(data) { if (data && data.courseProgress) Object.keys(data.courseProgress).forEach(key => this.$set(this.course, key, data.courseProgress[key])); if (!data || data.resourceId !== this.selectedResource.id) return; this.recordProgress = Number(data.progressPercent || 0); this.recordStudyTime = data.studyTimeText || this.formatSeconds(Number(data.studySeconds || 0)); if (this.selectedResource.id && data.lastPositionSeconds !== undefined) this.$set(this.selectedResource, "lastPositionSeconds", Number(data.lastPositionSeconds || 0)); if (this.selectedResource.id && data.maxPositionSeconds !== undefined) { const maxPositionSeconds = Number(data.maxPositionSeconds || 0); this.$set(this.selectedResource, "maxPositionSeconds", maxPositionSeconds); this.lastAllowedPositionSeconds = Math.max(Number(this.lastAllowedPositionSeconds || 0), maxPositionSeconds) } const resource = this.resourceList.find(item => item.id === this.selectedResource.id); if (resource) { this.$set(resource, "progressPercent", this.recordProgress); this.$set(resource, "studySeconds", Number(data.studySeconds || 0)); this.$set(resource, "completeStatus", data.completeStatus || "studying"); if (data.maxPositionSeconds !== undefined) this.$set(resource, "maxPositionSeconds", Number(data.maxPositionSeconds || 0)) } },
|
||||||
getMediaPosition() { if (!["video", "audio"].includes(this.selectedResource.resourceType)) return 0; const player = this.$refs.mediaPlayer; return player ? Math.floor(player.currentTime || 0) : Number(this.selectedResource.lastPositionSeconds || 0) },
|
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 + "秒" },
|
formatSeconds(seconds) { const hour = Math.floor(seconds / 3600); const minute = Math.floor(seconds % 3600 / 60); const second = Math.floor(seconds % 60); if (hour > 0) return hour + "小时" + minute + "分" + second + "秒"; if (minute > 0) return minute + "分" + second + "秒"; return second + "秒" },
|
||||||
formatShortDuration(seconds) { const total = Number(seconds) || 0; if (!total) return ""; const minute = Math.floor(total / 60); const second = total % 60; return String(minute).padStart(2, "0") + ":" + String(second).padStart(2, "0") },
|
formatShortDuration(seconds) { const total = Number(seconds) || 0; if (!total) return ""; const minute = Math.floor(total / 60); const second = total % 60; return String(minute).padStart(2, "0") + ":" + String(second).padStart(2, "0") },
|
||||||
formatCourseDuration(seconds) { const total = Number(seconds) || 0; if (total < 3600) return Math.max(1, Math.ceil(total / 60)) + "分钟"; const hour = Math.floor(total / 3600); const minute = Math.ceil(total % 3600 / 60); return minute ? hour + "小时" + minute + "分钟" : hour + "小时" },
|
formatCourseDuration(seconds) { const total = Number(seconds) || 0; if (total < 3600) return Math.max(1, Math.ceil(total / 60)) + "分钟"; const hour = Math.floor(total / 3600); const minute = Math.ceil(total % 3600 / 60); return minute ? hour + "小时" + minute + "分钟" : hour + "小时" },
|
||||||
nextResource() { if (!this.hasNextResource) return; const next = this.resourceList[this.currentResourceIndex + 1]; const changeResource = () => { this.selectResource(next); this.$nextTick(() => { if (this.$refs.studyTree) this.$refs.studyTree.setCurrentKey(next.id) }) }; if (this.studying) { this.finishStudy(false, {silent: true}).then(changeResource) } else { changeResource() } },
|
nextResource() { if (!this.hasNextResource) return; const next = this.resourceList[this.currentResourceIndex + 1]; const changeResource = () => { this.selectResource(next); this.$nextTick(() => { if (this.$refs.studyTree) this.$refs.studyTree.setCurrentKey(next.id) }) }; if (this.studying) { this.finishStudy(false, {silent: true}).then(changeResource) } else { changeResource() } },
|
||||||
openFile() { if (this.fileUrl) window.open(this.fileUrl) },
|
openFile() { if (!this.canPreview) return; const url = this.canInlinePreview(this.selectedResource) ? this.inlinePreviewUrl : this.mediaUrl; if (url) window.open(url) },
|
||||||
|
downloadFile() { if (this.canDownload && this.downloadUrl) window.open(this.downloadUrl) },
|
||||||
collapseAll() { const nodesMap = this.$refs.studyTree && this.$refs.studyTree.store.nodesMap; Object.keys(nodesMap || {}).forEach(key => { nodesMap[key].expanded = false }) },
|
collapseAll() { const nodesMap = this.$refs.studyTree && this.$refs.studyTree.store.nodesMap; Object.keys(nodesMap || {}).forEach(key => { nodesMap[key].expanded = false }) },
|
||||||
goBack() { this.saveServerPosition(this.getMediaPosition()); this.finishStudy(false, {silent: true}).then(() => { window.location.href = this.apiBase }) }
|
goBack() { this.saveServerPosition(this.getMediaPosition()); this.finishStudy(false, {silent: true}).then(() => { window.location.href = this.apiBase }) }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ layout("/layouts/platform.html"){
|
|||||||
</el-select>
|
</el-select>
|
||||||
</search-item>
|
</search-item>
|
||||||
<div class="search-query">
|
<div class="search-query">
|
||||||
<el-button size="medium" type="primary" icon="el-icon-search" @click="doSearch">搜索</el-button>
|
<el-button size="medium" type="primary" @click="doSearch">搜索</el-button>
|
||||||
<el-button size="medium" @click="resetSearch">重置</el-button>
|
<el-button size="medium" @click="resetSearch">重置</el-button>
|
||||||
</div>
|
</div>
|
||||||
</search>
|
</search>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ layout("/layouts/platform.html"){
|
|||||||
</el-input>
|
</el-input>
|
||||||
</search-item>
|
</search-item>
|
||||||
<div class="search-query">
|
<div class="search-query">
|
||||||
<el-button size="medium" type="primary" icon="el-icon-search" @click="doSearch">搜索</el-button>
|
<el-button size="medium" type="primary" @click="doSearch">搜索</el-button>
|
||||||
<el-button size="medium" @click="resetSearch">重置</el-button>
|
<el-button size="medium" @click="resetSearch">重置</el-button>
|
||||||
</div>
|
</div>
|
||||||
</search>
|
</search>
|
||||||
|
|||||||
@@ -5,20 +5,6 @@ layout("/layouts/platform.html"){
|
|||||||
<guava ref="guava">
|
<guava ref="guava">
|
||||||
<el-card shadow="never">
|
<el-card shadow="never">
|
||||||
<search :is-search-button="false">
|
<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="课程名称">
|
<search-item label="课程名称">
|
||||||
<el-select v-model="pageForm.courseId" clearable filterable placeholder="请选择课程" style="width: 100%">
|
<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-option v-for="item in courseOptions" :key="item.id" :label="item.courseName" :value="item.id"></el-option>
|
||||||
@@ -30,7 +16,7 @@ layout("/layouts/platform.html"){
|
|||||||
</el-select>
|
</el-select>
|
||||||
</search-item>
|
</search-item>
|
||||||
<div class="search-query">
|
<div class="search-query">
|
||||||
<el-button size="medium" type="primary" icon="el-icon-search" @click="doSearch">搜索</el-button>
|
<el-button size="medium" type="primary" @click="doSearch">搜索</el-button>
|
||||||
<el-button size="medium" @click="resetSearch">重置</el-button>
|
<el-button size="medium" @click="resetSearch">重置</el-button>
|
||||||
</div>
|
</div>
|
||||||
</search>
|
</search>
|
||||||
@@ -88,7 +74,7 @@ layout("/layouts/platform.html"){
|
|||||||
return {
|
return {
|
||||||
apiBase: "/platform/learning/study/record",
|
apiBase: "/platform/learning/study/record",
|
||||||
courseOptions: [],
|
courseOptions: [],
|
||||||
unionOptions: [],
|
|
||||||
statusOptions: [
|
statusOptions: [
|
||||||
{ name: "未开始", code: "not_started", type: "info" },
|
{ name: "未开始", code: "not_started", type: "info" },
|
||||||
{ name: "学习中", code: "studying", type: "warning" },
|
{ name: "学习中", code: "studying", type: "warning" },
|
||||||
@@ -121,8 +107,6 @@ layout("/layouts/platform.html"){
|
|||||||
this.pageData()
|
this.pageData()
|
||||||
},
|
},
|
||||||
resetSearch() {
|
resetSearch() {
|
||||||
this.pageForm.keyword = ""
|
|
||||||
this.pageForm.unionId = ""
|
|
||||||
this.pageForm.courseId = ""
|
this.pageForm.courseId = ""
|
||||||
this.pageForm.completeStatus = ""
|
this.pageForm.completeStatus = ""
|
||||||
this.pageForm.pageNumber = 1
|
this.pageForm.pageNumber = 1
|
||||||
@@ -133,10 +117,6 @@ layout("/layouts/platform.html"){
|
|||||||
if (courseResp.code === 0) {
|
if (courseResp.code === 0) {
|
||||||
this.courseOptions = courseResp.data || []
|
this.courseOptions = courseResp.data || []
|
||||||
}
|
}
|
||||||
const unionResp = await this.$axios.post(this.apiBase + "/unionOptions")
|
|
||||||
if (unionResp.code === 0) {
|
|
||||||
this.unionOptions = unionResp.data || []
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
statusName(code) {
|
statusName(code) {
|
||||||
const item = this.statusOptions.find(v => v.code === code)
|
const item = this.statusOptions.find(v => v.code === code)
|
||||||
@@ -166,8 +146,6 @@ layout("/layouts/platform.html"){
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async created() {
|
async created() {
|
||||||
this.$set(this.pageForm, "keyword", "")
|
|
||||||
this.$set(this.pageForm, "unionId", "")
|
|
||||||
this.$set(this.pageForm, "courseId", "")
|
this.$set(this.pageForm, "courseId", "")
|
||||||
this.$set(this.pageForm, "completeStatus", "")
|
this.$set(this.pageForm, "completeStatus", "")
|
||||||
await this.loadOptions()
|
await this.loadOptions()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ layout("/layouts/platform.html"){
|
|||||||
<div id="app" v-cloak>
|
<div id="app" v-cloak>
|
||||||
<guava ref="guava">
|
<guava ref="guava">
|
||||||
<el-card shadow="never">
|
<el-card shadow="never">
|
||||||
<search :is-search-button="false">
|
<div class="search">
|
||||||
<search-item label="课程名称">
|
<search-item label="课程名称">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="pageForm.courseId"
|
v-model="pageForm.courseId"
|
||||||
@@ -22,22 +22,36 @@ layout("/layouts/platform.html"){
|
|||||||
<el-option v-for="item in unionOptions" :key="item.id" :label="item.name" :value="item.id"></el-option>
|
<el-option v-for="item in unionOptions" :key="item.id" :label="item.name" :value="item.id"></el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
</search-item>
|
</search-item>
|
||||||
|
<search-item label="工号" v-if="activeTab === 'records'">
|
||||||
|
<el-input v-model="pageForm.loginName" clearable placeholder="请输入工号" @keyup.enter.native="doSearch"></el-input>
|
||||||
|
</search-item>
|
||||||
|
<search-item label="姓名" v-if="activeTab === 'records'">
|
||||||
|
<el-input v-model="pageForm.userName" clearable placeholder="请输入姓名" @keyup.enter.native="doSearch"></el-input>
|
||||||
|
</search-item>
|
||||||
|
<search-item label="是否完成" v-if="activeTab === 'records'">
|
||||||
|
<el-select v-model="pageForm.completeStatus" clearable placeholder="请选择完成状态" style="width: 100%">
|
||||||
|
<el-option label="已完成" value="completed"></el-option>
|
||||||
|
<el-option label="未完成" value="incomplete"></el-option>
|
||||||
|
</el-select>
|
||||||
|
</search-item>
|
||||||
<div class="search-query">
|
<div class="search-query">
|
||||||
<el-button size="medium" type="primary" icon="el-icon-search" @click="doSearch">搜索</el-button>
|
<el-button size="medium" type="primary" @click="doSearch">搜索</el-button>
|
||||||
<el-button size="medium" @click="resetSearch">重置</el-button>
|
<el-button size="medium" @click="resetSearch">重置</el-button>
|
||||||
</div>
|
</div>
|
||||||
</search>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card class="mt10" shadow="never">
|
<el-card class="mt10" shadow="never">
|
||||||
<el-tabs v-model="activeTab" @tab-click="tabChange">
|
<el-tabs v-model="activeTab" @tab-click="tabChange" style="margin-bottom: 12px">
|
||||||
<el-tab-pane label="课程统计" name="course"></el-tab-pane>
|
<el-tab-pane label="课程统计" name="course"></el-tab-pane>
|
||||||
<el-tab-pane label="分工会统计" name="union"></el-tab-pane>
|
<el-tab-pane label="分工会统计" name="union"></el-tab-pane>
|
||||||
|
<el-tab-pane label="学习列表" name="records"></el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
|
|
||||||
<table-tool :app="this" :label="activeTab === 'course' ? '课程统计' : '分工会统计'"></table-tool>
|
<table-tool :app="this" :label="activeTab === 'course' ? '课程统计' : activeTab === 'union' ? '分工会统计' : '学习列表'"></table-tool>
|
||||||
<el-table
|
<el-table
|
||||||
v-if="activeTab === 'course'"
|
v-if="activeTab === 'course'"
|
||||||
|
key="course-statistics"
|
||||||
v-loading="tableLoading"
|
v-loading="tableLoading"
|
||||||
:data="tableData"
|
:data="tableData"
|
||||||
:size="tableSize"
|
:size="tableSize"
|
||||||
@@ -47,13 +61,15 @@ layout("/layouts/platform.html"){
|
|||||||
<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="courseName" sortable="custom" show-overflow-tooltip min-width="220" ></el-table-column>
|
||||||
<el-table-column align="center" header-align="center" label="学习人数" prop="learnerCount" sortable="custom" width="130"></el-table-column>
|
<el-table-column align="center" header-align="center" label="学习人数" prop="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="completedCount" sortable="custom" width="130"></el-table-column>
|
||||||
|
<el-table-column align="center" header-align="center" label="完成率" prop="completeRate" sortable="custom" width="130"></el-table-column>
|
||||||
<el-table-column align="center" header-align="center" label="平均学习时长" prop="avgStudySeconds" sortable="custom" width="170">
|
<el-table-column align="center" header-align="center" label="平均学习时长" prop="avgStudySeconds" sortable="custom" width="170">
|
||||||
<template slot-scope="{row}">{{row.avgStudyTimeText}}</template>
|
<template slot-scope="{row}">{{row.avgStudyTimeText}}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<el-table
|
<el-table
|
||||||
v-else
|
v-else-if="activeTab === 'union'"
|
||||||
|
key="union-statistics"
|
||||||
v-loading="tableLoading"
|
v-loading="tableLoading"
|
||||||
:data="tableData"
|
:data="tableData"
|
||||||
:size="tableSize"
|
:size="tableSize"
|
||||||
@@ -63,11 +79,35 @@ layout("/layouts/platform.html"){
|
|||||||
<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="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="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="learnerCount" sortable="custom" width="130"></el-table-column>
|
||||||
|
<el-table-column align="center" header-align="center" label="完成人数" prop="completedCount" sortable="custom" width="130"></el-table-column>
|
||||||
<el-table-column align="center" header-align="center" label="学习时长" prop="studySeconds" sortable="custom" width="160">
|
<el-table-column align="center" header-align="center" label="学习时长" prop="studySeconds" sortable="custom" width="160">
|
||||||
<template slot-scope="{row}">{{row.studyTimeText}}</template>
|
<template slot-scope="{row}">{{row.studyTimeText}}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column align="center" header-align="center" label="完成率" prop="completeRate" sortable="custom" width="130"></el-table-column>
|
<el-table-column align="center" header-align="center" label="完成率" prop="completeRate" sortable="custom" width="130"></el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
<el-table v-else key="learning-records" v-loading="tableLoading" :data="tableData" :size="tableSize" border @sort-change="pageOrder">
|
||||||
|
<el-table-column :index="indexMethod" align="center" label="序号" type="index" width="70"></el-table-column>
|
||||||
|
<el-table-column align="center" label="工号" prop="loginName" sortable="custom" show-overflow-tooltip width="120"></el-table-column>
|
||||||
|
<el-table-column align="center" label="姓名" prop="userName" sortable="custom" show-overflow-tooltip width="110"></el-table-column>
|
||||||
|
<el-table-column align="center" label="所属分工会" prop="unionName" sortable="custom" show-overflow-tooltip min-width="150"></el-table-column>
|
||||||
|
<el-table-column align="center" label="课程名称" prop="courseName" sortable="custom" show-overflow-tooltip min-width="180"></el-table-column>
|
||||||
|
<el-table-column align="center" label="章节名称" prop="outlineName" sortable="custom" show-overflow-tooltip min-width="160"></el-table-column>
|
||||||
|
<el-table-column align="center" label="学习开始时间" prop="firstStudyTime" sortable="custom" width="170">
|
||||||
|
<template slot-scope="{row}">{{formatTime(row.firstStudyTime)}}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column align="center" label="最近学习时间" prop="latestStudyTime" sortable="custom" width="170">
|
||||||
|
<template slot-scope="{row}">{{formatTime(row.latestStudyTime)}}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column align="center" label="学习时长" prop="studySeconds" sortable="custom" width="130">
|
||||||
|
<template slot-scope="{row}">{{row.studyTimeText}}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column align="center" label="学习进度" prop="progressPercent" sortable="custom" width="180">
|
||||||
|
<template slot-scope="{row}"><el-progress :percentage="Number(row.progressPercent || 0)" :stroke-width="8"></el-progress></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column align="center" label="是否完成" prop="completeStatus" sortable="custom" width="110">
|
||||||
|
<template slot-scope="{row}"><el-tag :type="row.completeStatus === 'completed' ? 'success' : 'warning'" size="mini">{{row.completeStatus === 'completed' ? '已完成' : '未完成'}}</el-tag></template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
<!--#include("/layouts/pagination.html"){}#-->
|
<!--#include("/layouts/pagination.html"){}#-->
|
||||||
</el-card>
|
</el-card>
|
||||||
</guava>
|
</guava>
|
||||||
@@ -82,15 +122,17 @@ layout("/layouts/platform.html"){
|
|||||||
apiBase: "/platform/learning/statistics",
|
apiBase: "/platform/learning/statistics",
|
||||||
activeTab: "course",
|
activeTab: "course",
|
||||||
courseOptions: [],
|
courseOptions: [],
|
||||||
unionOptions: []
|
unionOptions: [],
|
||||||
|
requestSeq: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
pageData() {
|
pageData() {
|
||||||
this.tableLoading = true
|
this.tableLoading = true
|
||||||
const url = this.activeTab === "course" ? "/coursePageData" : "/unionPageData"
|
const requestSeq = ++this.requestSeq
|
||||||
this.$axios.post(this.apiBase + url, this.pageForm).then(resp => {
|
const urls = { course: "/coursePageData", union: "/unionPageData", records: "/recordPageData" }
|
||||||
this.tableLoading = false
|
this.$axios.post(this.apiBase + urls[this.activeTab], this.pageForm).then(resp => {
|
||||||
|
if (requestSeq !== this.requestSeq) return
|
||||||
if (resp.code === 0) {
|
if (resp.code === 0) {
|
||||||
this.tableData = resp.data.list || []
|
this.tableData = resp.data.list || []
|
||||||
this.pageForm.totalCount = resp.data.totalCount
|
this.pageForm.totalCount = resp.data.totalCount
|
||||||
@@ -98,9 +140,14 @@ layout("/layouts/platform.html"){
|
|||||||
this.$message.warning(resp.msg)
|
this.$message.warning(resp.msg)
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
this.tableLoading = false
|
if (requestSeq === this.requestSeq) this.$message.warning("查询失败")
|
||||||
|
}).finally(() => {
|
||||||
|
if (requestSeq === this.requestSeq) this.tableLoading = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
formatTime(value) {
|
||||||
|
return value ? this.$moment(value).format("YYYY-MM-DD HH:mm:ss") : ""
|
||||||
|
},
|
||||||
pageOrder(column) {
|
pageOrder(column) {
|
||||||
this.pageForm.pageOrderName = column.prop
|
this.pageForm.pageOrderName = column.prop
|
||||||
this.pageForm.pageOrderBy = column.order
|
this.pageForm.pageOrderBy = column.order
|
||||||
@@ -114,6 +161,9 @@ layout("/layouts/platform.html"){
|
|||||||
async resetSearch() {
|
async resetSearch() {
|
||||||
this.pageForm.courseId = ""
|
this.pageForm.courseId = ""
|
||||||
this.pageForm.unionId = ""
|
this.pageForm.unionId = ""
|
||||||
|
this.pageForm.loginName = ""
|
||||||
|
this.pageForm.userName = ""
|
||||||
|
this.pageForm.completeStatus = ""
|
||||||
this.pageForm.pageOrderName = ""
|
this.pageForm.pageOrderName = ""
|
||||||
this.pageForm.pageOrderBy = ""
|
this.pageForm.pageOrderBy = ""
|
||||||
this.pageForm.pageNumber = 1
|
this.pageForm.pageNumber = 1
|
||||||
@@ -122,7 +172,9 @@ layout("/layouts/platform.html"){
|
|||||||
this.pageData()
|
this.pageData()
|
||||||
},
|
},
|
||||||
async tabChange() {
|
async tabChange() {
|
||||||
|
this.requestSeq += 1
|
||||||
this.tableData = []
|
this.tableData = []
|
||||||
|
this.pageForm.totalCount = 0
|
||||||
this.pageForm.pageNumber = 1
|
this.pageForm.pageNumber = 1
|
||||||
this.pageForm.pageOrderName = ""
|
this.pageForm.pageOrderName = ""
|
||||||
this.pageForm.pageOrderBy = ""
|
this.pageForm.pageOrderBy = ""
|
||||||
@@ -156,6 +208,9 @@ layout("/layouts/platform.html"){
|
|||||||
async created() {
|
async created() {
|
||||||
this.$set(this.pageForm, "courseId", "")
|
this.$set(this.pageForm, "courseId", "")
|
||||||
this.$set(this.pageForm, "unionId", "")
|
this.$set(this.pageForm, "unionId", "")
|
||||||
|
this.$set(this.pageForm, "loginName", "")
|
||||||
|
this.$set(this.pageForm, "userName", "")
|
||||||
|
this.$set(this.pageForm, "completeStatus", "")
|
||||||
await this.queryCourseOptions("")
|
await this.queryCourseOptions("")
|
||||||
await this.loadUnionOptions()
|
await this.loadUnionOptions()
|
||||||
this.pageData()
|
this.pageData()
|
||||||
|
|||||||
@@ -15,58 +15,6 @@ layout("/layouts/platform_h5.html"){
|
|||||||
.learning-course-page .van-nav-bar {
|
.learning-course-page .van-nav-bar {
|
||||||
background: #f4f7fb;
|
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 {
|
.learning-filter-card {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin: 10px 10px 0;
|
margin: 10px 10px 0;
|
||||||
@@ -101,8 +49,7 @@ layout("/layouts/platform_h5.html"){
|
|||||||
color: #26354c;
|
color: #26354c;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.learning-filter-category-main,
|
.learning-filter-category-main {
|
||||||
.learning-filter-category-actions {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -116,48 +63,39 @@ layout("/layouts/platform_h5.html"){
|
|||||||
color: #1677ff;
|
color: #1677ff;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
}
|
}
|
||||||
.learning-filter-selected-count {
|
|
||||||
color: #97a4b7;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.learning-filter-reset,
|
|
||||||
.learning-filter-collapse {
|
|
||||||
margin-left: 12px;
|
|
||||||
color: #4d92ec;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.learning-filter-collapse .van-icon {
|
|
||||||
margin-left: 2px;
|
|
||||||
font-size: 11px;
|
|
||||||
vertical-align: -1px;
|
|
||||||
}
|
|
||||||
.learning-filter-options {
|
.learning-filter-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
gap: 7px 9px;
|
overflow-x: auto;
|
||||||
padding: 0 1px 10px;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 1px 10px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.learning-filter-options::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
.learning-filter-item {
|
.learning-filter-item {
|
||||||
flex: 1 0 calc((100% - 18px) / 3);
|
flex: 0 0 auto;
|
||||||
min-width: 0;
|
min-width: 62px;
|
||||||
height: 22px;
|
height: 26px;
|
||||||
padding: 0 7px;
|
padding: 0 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 1px solid #dce5f1;
|
border: 1px solid #e8f2ff;
|
||||||
border-radius: 12px;
|
border-radius: 999px;
|
||||||
|
background: #f0f7ff;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #718096;
|
color: #7c899c;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.learning-filter-item.active {
|
.learning-filter-item.active {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-weight: 700;
|
font-weight: 400;
|
||||||
background: #287ff0;
|
background: #1989fa;
|
||||||
border-color: #287ff0;
|
border-color: #1989fa;
|
||||||
box-shadow: 0 2px 5px rgba(40, 127, 240, .2);
|
|
||||||
}
|
}
|
||||||
.learning-filter-name {
|
.learning-filter-name {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -185,30 +123,35 @@ layout("/layouts/platform_h5.html"){
|
|||||||
}
|
}
|
||||||
.learning-course-card {
|
.learning-course-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
min-height: 104px;
|
min-height: 104px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
padding: 14px;
|
padding: 10px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.learning-course-cover {
|
.learning-course-cover {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 0 0 142px;
|
flex: none;
|
||||||
width: 142px;
|
width: 100%;
|
||||||
height: 82px;
|
aspect-ratio: 16 / 9;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #e6edf6;
|
background: #e6edf6;
|
||||||
}
|
}
|
||||||
.learning-course-cover img {
|
.learning-course-cover img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
.learning-course-cover-empty {
|
.learning-course-cover-empty {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -228,6 +171,7 @@ layout("/layouts/platform_h5.html"){
|
|||||||
.learning-course-title {
|
.learning-course-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -278,37 +222,12 @@ layout("/layouts/platform_h5.html"){
|
|||||||
}
|
}
|
||||||
@media (max-width: 360px) {
|
@media (max-width: 360px) {
|
||||||
.learning-filter-card { margin-left: 8px; margin-right: 8px; padding-left: 8px; padding-right: 8px; }
|
.learning-filter-card { margin-left: 8px; margin-right: 8px; padding-left: 8px; padding-right: 8px; }
|
||||||
.learning-filter-reset, .learning-filter-collapse { margin-left: 8px; }
|
|
||||||
.learning-filter-item { padding-left: 4px; padding-right: 4px; }
|
|
||||||
.learning-course-cover {
|
|
||||||
flex-basis: 120px;
|
|
||||||
width: 120px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div id="app" class="learning-course-page" v-cloak>
|
<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>
|
<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-card">
|
||||||
<div class="learning-filter-group" v-for="group in filterGroups" :key="group.key">
|
<div class="learning-filter-group" v-for="group in filterGroups" :key="group.key">
|
||||||
<div class="learning-filter-category">
|
<div class="learning-filter-category">
|
||||||
@@ -316,13 +235,8 @@ layout("/layouts/platform_h5.html"){
|
|||||||
<van-icon :name="group.icon"></van-icon>
|
<van-icon :name="group.icon"></van-icon>
|
||||||
<div class="learning-filter-category-title">{{ group.name }}</div>
|
<div class="learning-filter-category-title">{{ group.name }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="learning-filter-category-actions">
|
|
||||||
<span class="learning-filter-selected-count">已选{{ selectedFilterCount(group) }}项</span>
|
|
||||||
<span class="learning-filter-reset" @click.stop="resetGroupFilter(group)">重置</span>
|
|
||||||
<span class="learning-filter-collapse" @click.stop="toggleFilterGroup(group.key)">{{ filterCollapsed[group.key] ? '展开' : '收起' }}<van-icon :name="filterCollapsed[group.key] ? 'arrow-down' : 'arrow-up'"></van-icon></span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="learning-filter-options">
|
||||||
<div v-show="!filterCollapsed[group.key]" class="learning-filter-options">
|
|
||||||
<div
|
<div
|
||||||
v-for="item in group.items"
|
v-for="item in group.items"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
@@ -346,7 +260,7 @@ layout("/layouts/platform_h5.html"){
|
|||||||
v-model="loading"
|
v-model="loading"
|
||||||
:finished="finished"
|
:finished="finished"
|
||||||
:immediate-check="false"
|
:immediate-check="false"
|
||||||
finished-text="没有更多了"
|
:finished-text="courseList.length ? '没有更多了' : ''"
|
||||||
@load="onLoad">
|
@load="onLoad">
|
||||||
<div class="learning-course-list">
|
<div class="learning-course-list">
|
||||||
<div
|
<div
|
||||||
@@ -396,14 +310,12 @@ layout("/layouts/platform_h5.html"){
|
|||||||
totalCount: 0
|
totalCount: 0
|
||||||
},
|
},
|
||||||
courseList: [],
|
courseList: [],
|
||||||
bannerCourses: [],
|
|
||||||
courseTypeOptions: [],
|
courseTypeOptions: [],
|
||||||
recommendOptions: [],
|
recommendOptions: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
finished: false,
|
finished: false,
|
||||||
refreshing: false,
|
refreshing: false,
|
||||||
requestSeq: 0,
|
requestSeq: 0
|
||||||
filterCollapsed: { recommend: false, type: false }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -494,7 +406,6 @@ layout("/layouts/platform_h5.html"){
|
|||||||
this.pageForm.totalCount = data.totalCount || 0
|
this.pageForm.totalCount = data.totalCount || 0
|
||||||
this.pageForm.pageNumber += 1
|
this.pageForm.pageNumber += 1
|
||||||
this.finished = this.courseList.length >= this.pageForm.totalCount
|
this.finished = this.courseList.length >= this.pageForm.totalCount
|
||||||
this.syncBannerCourses()
|
|
||||||
} else {
|
} else {
|
||||||
this.finished = true
|
this.finished = true
|
||||||
this.$toast(res.msg || "查询失败")
|
this.$toast(res.msg || "查询失败")
|
||||||
@@ -523,26 +434,6 @@ layout("/layouts/platform_h5.html"){
|
|||||||
}
|
}
|
||||||
this.doSearch()
|
this.doSearch()
|
||||||
},
|
},
|
||||||
// 切换指定筛选组的展开状态,不改变该组已选择的查询条件。
|
|
||||||
toggleFilterGroup(groupKey) {
|
|
||||||
this.$set(this.filterCollapsed, groupKey, !this.filterCollapsed[groupKey])
|
|
||||||
},
|
|
||||||
// 重置指定筛选组并重新查询;推荐标识和课程类型可独立恢复为全部。
|
|
||||||
resetGroupFilter(group) {
|
|
||||||
if (!group) return
|
|
||||||
if (group.key === "recommend") {
|
|
||||||
this.query.recommendFlag = ""
|
|
||||||
} else if (group.key === "type") {
|
|
||||||
this.query.courseTypeId = ""
|
|
||||||
}
|
|
||||||
this.doSearch()
|
|
||||||
},
|
|
||||||
selectedFilterCount(group) {
|
|
||||||
if (!group) return 0
|
|
||||||
if (group.key === "recommend") return this.query.recommendFlag ? 1 : 0
|
|
||||||
if (group.key === "type") return this.query.courseTypeId ? 1 : 0
|
|
||||||
return 0
|
|
||||||
},
|
|
||||||
isFilterActive(item) {
|
isFilterActive(item) {
|
||||||
if (!item) return false
|
if (!item) return false
|
||||||
if (item.type === "recommend") {
|
if (item.type === "recommend") {
|
||||||
@@ -553,10 +444,6 @@ layout("/layouts/platform_h5.html"){
|
|||||||
}
|
}
|
||||||
return false
|
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) {
|
getCoverUrl(cover) {
|
||||||
if (!cover) return ""
|
if (!cover) return ""
|
||||||
if (Array.isArray(cover)) {
|
if (Array.isArray(cover)) {
|
||||||
|
|||||||
@@ -3,202 +3,89 @@ layout("/layouts/platform_h5.html"){
|
|||||||
#-->
|
#-->
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.learning-study-page {
|
.learning-study-page { height: 100vh; height: 100dvh; overflow-y: auto; background: #f5f8fd; color: #152238; -webkit-overflow-scrolling: touch; }
|
||||||
height: 100vh;
|
.study-hero { position: relative; min-height: 240px; background: linear-gradient(135deg, #172a49, #427ac2); overflow: hidden; }
|
||||||
height: 100dvh;
|
.study-hero > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||||
overflow: hidden;
|
.study-hero::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, rgba(10, 19, 37, .86), rgba(10, 19, 37, .15)), linear-gradient(0deg, rgba(10, 19, 37, .75), transparent); }
|
||||||
display: flex;
|
.study-back { position: absolute; top: 12px; left: 14px; z-index: 2; width: 34px; height: 34px; border: 0; border-radius: 50%; background: rgba(0, 0, 0, .5); color: #fff; font-size: 24px; display: flex; align-items: center; justify-content: center; }
|
||||||
flex-direction: column;
|
.study-title-block { position: relative; z-index: 1; padding: 65px 16px 30px; color: #fff; }
|
||||||
background: #f5f6f8;
|
.study-badge { display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px; margin-bottom: 9px; border-radius: 12px; background: rgba(154, 91, 0, .72); color: #fff4d8; font-size: 12px; }
|
||||||
}
|
.study-title { font-size: 23px; font-weight: 700; line-height: 1.45; overflow-wrap: anywhere; }
|
||||||
.study-hero {
|
.study-subtitle { margin-top: 7px; color: #d4deed; font-size: 13px; line-height: 1.6; }
|
||||||
position: relative;
|
.study-meta { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 12px; color: #d4deed; font-size: 12px; }
|
||||||
flex-shrink: 0;
|
.study-meta-item { display: inline-flex; align-items: center; gap: 5px; }
|
||||||
height: 195px;
|
.study-content { position: relative; z-index: 1; margin-top: -16px; padding: 8px 14px 24px; border-radius: 20px 20px 0 0; background: #f5f8fd; }
|
||||||
background: linear-gradient(135deg, #7554e8, #48b7f0);
|
.study-navigation { display: flex; padding: 4px; margin-bottom: 12px; border-radius: 12px; background: #fff; }
|
||||||
overflow: hidden;
|
.study-navigation button { position: relative; flex: 1; padding: 8px 4px; border: 0; border-radius: 9px; background: transparent; color: #667b9a; font-size: 12px; }
|
||||||
}
|
.study-navigation .van-icon { display: block; margin-bottom: 4px; font-size: 20px; }
|
||||||
.study-hero img {
|
.study-navigation .active { color: #1677ff; background: #f2f7ff; font-weight: 700; }
|
||||||
display: block;
|
.study-navigation .active::after { content: ""; position: absolute; bottom: 0; left: calc(50% - 12px); width: 24px; height: 3px; border-radius: 2px; background: #1677ff; }
|
||||||
width: 100%;
|
.study-progress-card, .study-card { padding: 16px; margin-bottom: 14px; border-radius: 16px; background: #fff; }
|
||||||
height: 100%;
|
.study-progress-card { display: flex; align-items: center; gap: 10px; padding: 12px 14px; margin-bottom: 12px; border-radius: 12px; }
|
||||||
object-fit: cover;
|
.study-progress-main { flex: 1; min-width: 0; }
|
||||||
}
|
.study-progress-title { margin-bottom: 4px; font-size: 13px; font-weight: 700; line-height: 1.4; }
|
||||||
.study-hero-empty {
|
.study-progress-line { display: flex; align-items: center; gap: 8px; color: #1677ff; font-size: 16px; font-weight: 700; line-height: 1.2; }
|
||||||
height: 100%;
|
.study-progress-line .van-progress { flex: 1; min-width: 0; overflow: hidden; }
|
||||||
display: flex;
|
.study-progress-line > span { flex: 0 0 3.5em; white-space: nowrap; text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
align-items: center;
|
.study-progress-note { margin-top: 4px; color: #7a8ca6; font-size: 10px; line-height: 1.4; }
|
||||||
justify-content: center;
|
.study-action { flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; gap: 4px; padding: 6px 10px; border: 1px solid #dbe9ff; border-radius: 20px; background: #f6faff; color: #1677ff; font-size: 12px; white-space: nowrap; }
|
||||||
padding: 28px;
|
.study-action:disabled { color: #a3afbf; background: #f5f6f8; }
|
||||||
color: #ffffff;
|
.study-continue { padding: 9px 12px; border: 0; background: #e8f1ff; font-size: 12px; font-weight: 700; line-height: 1.5; }
|
||||||
font-size: 26px;
|
.study-section-head { display: flex; align-items: center; gap: 8px; margin: 20px 0 14px; }
|
||||||
font-weight: 800;
|
.study-section-head strong { font-size: 20px; }
|
||||||
line-height: 1.35;
|
.study-section-head small { color: #7a8ca6; font-size: 12px; }
|
||||||
text-align: center;
|
.study-text-button { margin-left: auto; border: 0; padding: 4px 0; background: transparent; color: #7a8ca6; font-size: 12px; white-space: nowrap; }
|
||||||
box-sizing: border-box;
|
.study-chapter-head { display: flex; align-items: center; gap: 9px; width: 100%; padding: 0; border: 0; background: transparent; color: #152238; text-align: left; font-size: 16px; font-weight: 700; }
|
||||||
}
|
.study-chapter-head > span:nth-child(2) { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||||
.study-back {
|
.study-chapter-dot { flex-shrink: 0; width: 10px; height: 10px; box-sizing: border-box; border: 2px solid #1677ff; border-radius: 50%; box-shadow: 0 0 0 6px #edf5ff; margin: 0 4px; }
|
||||||
position: absolute;
|
.study-chapter-description { margin: 12px 0 4px 27px; color: #7a8ca6; font-size: 13px; line-height: 1.7; white-space: pre-line; }
|
||||||
top: 10px;
|
.study-resource { display: flex; align-items: center; gap: 10px; padding: 16px 0; border-bottom: 1px solid #edf1f7; }
|
||||||
left: 8px;
|
.study-resource:last-child { padding-bottom: 0; border-bottom: 0; }
|
||||||
z-index: 2;
|
.study-resource-icon { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 30px; height: 34px; border-radius: 7px; color: #fff; font-size: 23px; }
|
||||||
width: 38px;
|
.study-resource-icon.video { background: #ffb723; }
|
||||||
height: 38px;
|
.study-resource-icon.audio { background: #33b3a6; }
|
||||||
border-radius: 50%;
|
.study-resource-icon.image { background: #5a94f2; }
|
||||||
color: #ffffff;
|
.study-resource-icon.word { background: transparent; font-size: 34px; }
|
||||||
font-size: 24px;
|
.study-resource-icon.doc { background: #ff6868; }
|
||||||
background: rgba(15, 23, 42, .22);
|
.study-resource-main { flex: 1; min-width: 0; }
|
||||||
display: flex;
|
.study-resource-title { font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||||
align-items: center;
|
.study-resource-status { margin-top: 5px; color: #7a8ca6; font-size: 11px; line-height: 1.6; }
|
||||||
justify-content: center;
|
.study-resource-status .completed { color: #2fc78c; }
|
||||||
}
|
.study-download-list { padding: 0 12px; border-radius: 12px; background: #fff; }
|
||||||
.study-title-block {
|
.study-download-row { display: flex; align-items: center; gap: 12px; min-height: 72px; padding: 14px 0; box-sizing: border-box; border-bottom: 1px solid #edf1f7; }
|
||||||
flex-shrink: 0;
|
.study-download-row:last-child { border-bottom: 0; }
|
||||||
padding: 16px 18px 10px;
|
.study-download-icon { flex-shrink: 0; width: 34px; height: 38px; object-fit: contain; }
|
||||||
background: #ffffff;
|
.study-download-info { flex: 1; min-width: 0; }
|
||||||
}
|
.study-download-name { color: #26364c; font-size: 14px; font-weight: 600; line-height: 1.5; overflow-wrap: anywhere; }
|
||||||
.study-title {
|
.study-download-meta { display: flex; flex-wrap: wrap; gap: 4px 8px; margin-top: 4px; color: #8b9ab0; font-size: 12px; line-height: 1.5; }
|
||||||
color: #202733;
|
.study-download-meta:empty { display: none; }
|
||||||
font-size: 20px;
|
.study-download-button { flex-shrink: 0; min-width: 54px; padding: 5px 12px; border: 1px solid #cce2ff; border-radius: 18px; background: #f5faff; color: #1989fa; font-size: 12px; line-height: 1.5; }
|
||||||
font-weight: 800;
|
.study-catalog-card { padding: 14px 12px; }
|
||||||
line-height: 1.4;
|
.study-catalog-card .study-chapter-head { gap: 10px; font-size: 15px; line-height: 1.5; }
|
||||||
}
|
.study-catalog-card .study-chapter-head > .van-icon { color: #7a8ca6; font-size: 14px; }
|
||||||
.study-meta {
|
.study-chapter-body { position: relative; padding-left: 26px; }
|
||||||
display: flex;
|
.study-chapter-body::before { content: ""; position: absolute; top: 5px; bottom: 24px; left: 9px; width: 1px; transform: translateX(-50%); background: #e8eef7; }
|
||||||
align-items: center;
|
.study-catalog-card .study-chapter-description { margin: 8px 0 4px; color: #8293ad; font-size: 12px; line-height: 1.6; }
|
||||||
gap: 18px;
|
.study-catalog-card .study-resource { position: relative; gap: 9px; padding: 14px 0; }
|
||||||
margin-top: 14px;
|
.study-catalog-card .study-resource::before { content: ""; position: absolute; top: 50%; left: -17px; width: 8px; height: 8px; box-sizing: border-box; transform: translate(-50%, -50%); border: 2px solid #96aaca; border-radius: 50%; background: #fff; }
|
||||||
color: #8a93a3;
|
.study-catalog-card .study-resource:last-child { padding-bottom: 10px; }
|
||||||
font-size: 14px;
|
.study-catalog-card .study-resource-title { font-size: 13px; font-weight: 600; line-height: 1.5; }
|
||||||
}
|
.study-catalog-card .study-resource-status { margin-top: 3px; font-size: 10px; }
|
||||||
.study-meta-item {
|
.study-catalog-card .study-resource-icon { width: 28px; height: 32px; font-size: 21px; }
|
||||||
display: inline-flex;
|
.study-catalog-card .study-resource-icon.word, .study-catalog-card .study-resource-icon.pdf { background: transparent; font-size: 30px; }
|
||||||
align-items: center;
|
.study-catalog-card .study-action { padding: 5px 9px; font-size: 11px; }
|
||||||
gap: 4px;
|
.study-catalog-card .completed .van-icon { margin-right: 3px; vertical-align: -1px; }
|
||||||
}
|
.study-intro { padding: 14px; margin-bottom: 10px; border-radius: 12px; background: #fff; color: #7a8ca6; font-size: 12px; line-height: 1.7; overflow-wrap: anywhere; }
|
||||||
.study-tabs {
|
.study-intro h3 { margin: 0 0 7px; color: #26364c; font-size: 14px; line-height: 1.5; font-weight: 700; }
|
||||||
min-height: 0;
|
.study-intro-text { white-space: pre-line; }
|
||||||
flex: 1;
|
.study-goals { margin: 0; padding: 0; list-style: none; }
|
||||||
display: flex;
|
.study-goals li { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 7px; }
|
||||||
flex-direction: column;
|
.study-goals li:last-child { margin-bottom: 0; }
|
||||||
background: #ffffff;
|
.study-goals .van-icon { flex-shrink: 0; margin-top: 3px; color: #1989fa; font-size: 14px; }
|
||||||
}
|
.study-audience { display: flex; align-items: flex-start; gap: 10px; }
|
||||||
.study-tabs .van-tabs__content {
|
.study-audience > .van-icon { flex-shrink: 0; padding: 4px; border-radius: 6px; color: #1989fa; background: #edf6ff; font-size: 22px; }
|
||||||
min-height: 0;
|
.study-audience > span { padding-top: 4px; white-space: pre-line; }
|
||||||
flex: 1;
|
@media (max-width: 359px) { .study-action { padding: 6px 8px; } }
|
||||||
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 {
|
.study-player-popup {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
@@ -297,75 +184,106 @@ layout("/layouts/platform_h5.html"){
|
|||||||
|
|
||||||
<div id="app" class="learning-study-page" v-cloak>
|
<div id="app" class="learning-study-page" v-cloak>
|
||||||
<div class="study-hero">
|
<div class="study-hero">
|
||||||
<div class="study-back" @click="goBack"><van-icon name="arrow-left"></van-icon></div>
|
<button type="button" class="study-back" aria-label="返回" @click="goBack"><van-icon name="arrow-left"></van-icon></button>
|
||||||
<img v-if="coverUrl" :src="coverUrl" :alt="course.courseName">
|
<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-block">
|
||||||
|
<div v-if="courseBadge" class="study-badge"><van-icon name="fire"></van-icon>{{ courseBadge }}</div>
|
||||||
<div class="study-title">{{ course.courseName || '课程学习' }}</div>
|
<div class="study-title">{{ course.courseName || '课程学习' }}</div>
|
||||||
<div class="study-meta">
|
<div v-if="course.courseTags" class="study-subtitle">{{ course.courseTags }}</div>
|
||||||
<span class="study-meta-item"><van-icon name="eye-o"></van-icon>{{ studyCountText }}</span>
|
<div v-if="course.lecturerName" class="study-meta">
|
||||||
<span class="study-meta-item"><van-icon name="bookmark-o"></van-icon>收藏</span>
|
<span v-if="course.lecturerName" class="study-meta-item"><van-icon name="manager"></van-icon>{{ course.lecturerName }}</span>
|
||||||
<span class="study-meta-item"><van-icon name="share-o"></van-icon>分享</span>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</van-tab>
|
<div class="study-content">
|
||||||
<van-tab title="课程目录" name="catalog">
|
<nav class="study-navigation" aria-label="课程详情">
|
||||||
<div class="study-catalog-scroll">
|
<button v-for="tab in detailTabs" :key="tab.name" type="button" :class="{ active: activeTab === tab.name }" :aria-pressed="activeTab === tab.name" @click="activeTab = tab.name">
|
||||||
<div class="study-summary">
|
<van-icon :name="tab.icon"></van-icon>{{ tab.title }}
|
||||||
<div>共 <strong>{{ chapterCount }}</strong> 个章节,<strong>{{ totalMinutes }}</strong> 分钟</div>
|
</button>
|
||||||
<div>总学习进度 <strong>{{ totalProgress }}%</strong></div>
|
</nav>
|
||||||
|
<div v-if="activeTab === 'intro'" class="study-introduction">
|
||||||
|
<section class="study-intro">
|
||||||
|
<h3>课程简介</h3>
|
||||||
|
<div class="study-intro-text">{{ course.courseIntro || '暂无课程简介' }}</div>
|
||||||
|
</section>
|
||||||
|
<section class="study-intro">
|
||||||
|
<h3>学习目标</h3>
|
||||||
|
<ul v-if="learningGoals.length" class="study-goals">
|
||||||
|
<li v-for="(goal, index) in learningGoals" :key="index"><van-icon name="checked"></van-icon><span>{{ goal }}</span></li>
|
||||||
|
</ul>
|
||||||
|
<div v-else class="study-intro-text">暂无学习目标</div>
|
||||||
|
</section>
|
||||||
|
<section class="study-intro">
|
||||||
|
<h3>适用人群</h3>
|
||||||
|
<div class="study-audience"><van-icon name="manager"></van-icon><span>{{ course.suitablePeople || '暂无适用人群说明' }}</span></div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="activeTab === 'catalog'">
|
||||||
<div v-for="chapter in catalogList" :key="chapter.id" class="study-card">
|
<div v-if="Number(course.outlineCount || 0) > 0" class="study-progress-card">
|
||||||
<div class="study-chapter">
|
<div class="study-progress-main">
|
||||||
<div class="study-chapter-head" @click="selectChapter(chapter)">
|
<div class="study-progress-title">学习进度</div>
|
||||||
<span class="study-chapter-dot"></span>
|
<div class="study-progress-line"><van-progress :percentage="totalProgress" :show-pivot="false" stroke-width="8" color="#1989fa"></van-progress><span>{{ totalProgress }}%</span></div>
|
||||||
<span>{{ chapter.title }}</span>
|
<div class="study-progress-note">已完成 {{ completedChapterCount }} 个章节,共 {{ totalMinutes }} 分钟</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<button type="button" class="study-action study-continue" :disabled="!learnableResources.length" @click="continueStudy"><van-icon name="play"></van-icon>{{ totalProgress ? '继续学习' : '开始学习' }}</button>
|
||||||
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>
|
||||||
|
<div class="study-section-head">
|
||||||
|
<strong>课程目录</strong><small>共 {{ chapterCount }} 个章节</small>
|
||||||
|
<button v-if="chapterCount" type="button" class="study-text-button" @click="toggleAllChapters">{{ allChaptersCollapsed ? '展开全部' : '收起全部' }} <van-icon :name="allChaptersCollapsed ? 'arrow-down' : 'arrow-up'"></van-icon></button>
|
||||||
|
</div>
|
||||||
|
<div v-for="chapter in catalogList" :key="chapter.id" class="study-card study-catalog-card">
|
||||||
|
<button type="button" class="study-chapter-head" :aria-expanded="!collapsedChapters[chapter.id]" @click="toggleChapter(chapter.id)">
|
||||||
|
<span class="study-chapter-dot"></span><span>{{ chapter.title }}</span><van-icon :name="collapsedChapters[chapter.id] ? 'arrow-down' : 'arrow-up'"></van-icon>
|
||||||
|
</button>
|
||||||
|
<div v-show="!collapsedChapters[chapter.id]" class="study-chapter-body">
|
||||||
|
<div v-if="chapter.description" class="study-chapter-description">{{ chapter.description }}</div>
|
||||||
|
<div v-for="resource in chapter.resources" :key="resource.id" class="study-resource">
|
||||||
|
<div class="study-resource-icon" :class="resourceIconClass(resource)"><van-icon :name="resourceIcon(resource)"></van-icon></div>
|
||||||
<div class="study-resource-main">
|
<div class="study-resource-main">
|
||||||
<div class="study-resource-title">{{ resource.title }}</div>
|
<div class="study-resource-title">{{ resource.title }}</div>
|
||||||
<div class="study-resource-status">{{ statusText(resource) }}</div>
|
<div class="study-resource-status">{{ resourceMeta(resource) }} · <span :class="{ completed: resource.studied && resource.completeStatus === 'completed' }"><van-icon v-if="resource.completeStatus === 'completed'" name="checked"></van-icon>{{ statusText(resource) }}</span></div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="study-action" :disabled="resource.allowPreview === false" @click="openResource(resource)"><van-icon name="play"></van-icon>{{ resource.allowPreview === false ? '不可学习' : resource.studied ? '再次学习' : '学习' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<van-empty v-if="!catalogList.length" description="暂无课程目录"></van-empty>
|
<van-empty v-if="!catalogList.length" description="暂无课程目录"></van-empty>
|
||||||
</div>
|
</div>
|
||||||
</van-tab>
|
<div v-if="activeTab === 'downloads'" class="study-downloads">
|
||||||
</van-tabs>
|
<div v-if="downloadableResources.length" class="study-download-list">
|
||||||
|
<div v-for="resource in downloadableResources" :key="resource.id" class="study-download-row">
|
||||||
|
<img class="study-download-icon" :src="downloadIcon(resource)" alt="">
|
||||||
|
<div class="study-download-info">
|
||||||
|
<div class="study-download-name">{{ downloadFileInfo(resource).name || resource.title }}</div>
|
||||||
|
<div class="study-download-meta">
|
||||||
|
<span v-if="downloadSize(resource)">{{ downloadSize(resource) }}</span>
|
||||||
|
<span v-if="downloadDate(resource)">{{ downloadDate(resource) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="study-download-button" @click="downloadResource(resource)">下载</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<van-empty v-else description="暂无可下载资料"></van-empty>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<van-popup v-model="playerVisible" position="bottom" class="study-player-popup" :overlay="false">
|
<van-popup v-model="playerVisible" position="bottom" class="study-player-popup" :overlay="false">
|
||||||
<div class="study-player-head">
|
<div class="study-player-head">
|
||||||
<div class="study-player-title">{{ selectedResource.title || '学习资料' }}</div>
|
<div class="study-player-title">{{ selectedResource.title || '学习资料' }}</div>
|
||||||
<div class="study-player-actions">
|
<div class="study-player-actions">
|
||||||
<span v-if="selectedResource.resourceType === 'video'" @click="enterVideoFullscreen">横屏</span>
|
<span v-if="canPreview && selectedResource.resourceType === 'video'" @click="enterVideoFullscreen">横屏</span>
|
||||||
<span @click="closePlayer">退出</span>
|
<span @click="closePlayer">退出</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="study-player-body">
|
<div class="study-player-body">
|
||||||
|
<div v-if="!canPreview" class="study-file-fallback">该资料不允许预览</div>
|
||||||
<video
|
<video
|
||||||
v-if="selectedResource.resourceType === 'video'"
|
v-else-if="selectedResource.resourceType === 'video'"
|
||||||
ref="mediaPlayer"
|
ref="mediaPlayer"
|
||||||
class="study-media"
|
class="study-media"
|
||||||
:src="mediaUrl"
|
:src="mediaUrl"
|
||||||
controls
|
controls
|
||||||
|
:controlslist="canDownload ? null : 'nodownload'"
|
||||||
playsinline
|
playsinline
|
||||||
webkit-playsinline
|
webkit-playsinline
|
||||||
x5-video-player-type="h5"
|
x5-video-player-type="h5"
|
||||||
@@ -383,7 +301,9 @@ layout("/layouts/platform_h5.html"){
|
|||||||
<audio
|
<audio
|
||||||
ref="mediaPlayer"
|
ref="mediaPlayer"
|
||||||
:src="fileUrl"
|
:src="fileUrl"
|
||||||
|
preload="metadata"
|
||||||
controls
|
controls
|
||||||
|
:controlslist="canDownload ? null : 'nodownload'"
|
||||||
@loadedmetadata="restoreMediaPosition"
|
@loadedmetadata="restoreMediaPosition"
|
||||||
@seeked="handleMediaSeeked"
|
@seeked="handleMediaSeeked"
|
||||||
@seeking="handleMediaSeeking"
|
@seeking="handleMediaSeeking"
|
||||||
@@ -397,7 +317,6 @@ layout("/layouts/platform_h5.html"){
|
|||||||
<iframe v-else-if="canInlinePreview(selectedResource)" class="study-doc-frame" :src="inlinePreviewUrl"></iframe>
|
<iframe v-else-if="canInlinePreview(selectedResource)" class="study-doc-frame" :src="inlinePreviewUrl"></iframe>
|
||||||
<div v-else class="study-file-fallback">
|
<div v-else class="study-file-fallback">
|
||||||
<p>该资料暂不支持内嵌预览</p>
|
<p>该资料暂不支持内嵌预览</p>
|
||||||
<van-button type="info" size="small" @click="openFile">打开资料</van-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</van-popup>
|
</van-popup>
|
||||||
@@ -412,10 +331,19 @@ layout("/layouts/platform_h5.html"){
|
|||||||
apiBase: "/platform/learning/course/display",
|
apiBase: "/platform/learning/course/display",
|
||||||
recordApi: "/platform/learning/study/record",
|
recordApi: "/platform/learning/study/record",
|
||||||
activeTab: "catalog",
|
activeTab: "catalog",
|
||||||
|
detailTabs: [
|
||||||
|
{ name: "intro", title: "课程介绍", icon: "description" },
|
||||||
|
{ name: "catalog", title: "课程目录", icon: "orders-o" },
|
||||||
|
{ name: "downloads", title: "资料下载", icon: "down" }
|
||||||
|
],
|
||||||
|
collapsedChapters: {},
|
||||||
course: {},
|
course: {},
|
||||||
treeData: [],
|
treeData: [],
|
||||||
|
downloadFiles: {},
|
||||||
selectedResource: {},
|
selectedResource: {},
|
||||||
playerVisible: false,
|
playerVisible: false,
|
||||||
|
startingStudy: false,
|
||||||
|
switchingResource: false,
|
||||||
currentSegmentId: "",
|
currentSegmentId: "",
|
||||||
studying: false,
|
studying: false,
|
||||||
pendingSeconds: 0,
|
pendingSeconds: 0,
|
||||||
@@ -435,42 +363,67 @@ layout("/layouts/platform_h5.html"){
|
|||||||
coverUrl() {
|
coverUrl() {
|
||||||
return this.getFileUrl(this.course.cover)
|
return this.getFileUrl(this.course.cover)
|
||||||
},
|
},
|
||||||
|
canPreview() { return !!this.selectedResource.id && this.selectedResource.allowPreview !== false },
|
||||||
|
canDownload() { return !!this.selectedResource.id && this.selectedResource.allowDownload === true },
|
||||||
fileUrl() {
|
fileUrl() {
|
||||||
return this.getFileUrl(this.selectedResource.fileData)
|
if (!this.canPreview) return ""
|
||||||
|
const id = this.getFileId(this.selectedResource.fileData)
|
||||||
|
return id ? "/platform/sys/file/preview?id=" + encodeURIComponent(id) : this.getFileUrl(this.selectedResource.fileData)
|
||||||
|
},
|
||||||
|
downloadUrl() {
|
||||||
|
if (!this.canDownload) return ""
|
||||||
|
const id = this.getFileId(this.selectedResource.fileData)
|
||||||
|
return id ? "/platform/sys/file/download?id=" + encodeURIComponent(id) : this.getFileUrl(this.selectedResource.fileData)
|
||||||
},
|
},
|
||||||
mediaUrl() {
|
mediaUrl() {
|
||||||
|
if (!this.canPreview) return ""
|
||||||
if (this.selectedResource.resourceType !== "video") return this.fileUrl
|
if (this.selectedResource.resourceType !== "video") return this.fileUrl
|
||||||
const fileId = this.getFileId(this.selectedResource.fileData)
|
const fileId = this.getFileId(this.selectedResource.fileData)
|
||||||
// 视频必须使用支持 Range 的播放接口,否则手动拖动和历史位置恢复都会失效。
|
// 视频必须使用支持 Range 的播放接口,否则手动拖动和历史位置恢复都会失效。
|
||||||
return fileId ? "/platform/sys/file/videoPlay?id=" + encodeURIComponent(fileId) : this.fileUrl
|
return fileId ? "/platform/sys/file/videoPlay?id=" + encodeURIComponent(fileId) : this.fileUrl
|
||||||
},
|
},
|
||||||
inlinePreviewUrl() {
|
inlinePreviewUrl() {
|
||||||
|
if (!this.canPreview) return ""
|
||||||
const id = this.getFileId(this.selectedResource.fileData)
|
const id = this.getFileId(this.selectedResource.fileData)
|
||||||
return id ? this.apiBase + "/pdfPreview?id=" + encodeURIComponent(id) : this.fileUrl
|
const url = id ? this.apiBase + "/pdfPreview?id=" + encodeURIComponent(id) : this.fileUrl
|
||||||
|
return this.canDownload ? url : url + "#toolbar=0"
|
||||||
},
|
},
|
||||||
catalogList() {
|
catalogList() {
|
||||||
const result = []
|
const result = []
|
||||||
;(this.treeData || []).forEach(node => this.appendCatalogNode(node, result))
|
;(this.treeData || []).forEach(node => this.appendCatalogNode(node, result))
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
|
courseBadge() {
|
||||||
|
const flags = this.course.recommendFlags || ""
|
||||||
|
return Array.isArray(flags) ? flags[0] : String(flags).split(",")[0]
|
||||||
|
},
|
||||||
|
allResources() {
|
||||||
|
return this.collectResources(this.treeData)
|
||||||
|
},
|
||||||
|
learnableResources() {
|
||||||
|
return this.allResources.filter(resource => resource.allowPreview !== false)
|
||||||
|
},
|
||||||
|
downloadableResources() {
|
||||||
|
return this.allResources.filter(resource => resource.allowDownload === true && this.getFileUrl(resource.fileData))
|
||||||
|
},
|
||||||
|
completedChapterCount() {
|
||||||
|
return Number(this.course.completedOutlineCount || 0)
|
||||||
|
},
|
||||||
|
allChaptersCollapsed() {
|
||||||
|
return this.catalogList.length > 0 && this.catalogList.every(chapter => this.collapsedChapters[chapter.id])
|
||||||
|
},
|
||||||
chapterCount() {
|
chapterCount() {
|
||||||
return this.catalogList.length
|
return this.catalogList.filter(chapter => chapter.resources.some(resource => resource.allowPreview !== false)).length
|
||||||
},
|
},
|
||||||
totalMinutes() {
|
totalMinutes() {
|
||||||
const seconds = this.catalogList.reduce((sum, chapter) => {
|
const seconds = this.learnableResources.reduce((sum, item) => sum + Number(item.durationSeconds || 0), 0)
|
||||||
return sum + chapter.resources.reduce((value, item) => value + Number(item.durationSeconds || 0), 0)
|
|
||||||
}, 0)
|
|
||||||
return Math.max(0, Math.ceil(seconds / 60))
|
return Math.max(0, Math.ceil(seconds / 60))
|
||||||
},
|
},
|
||||||
totalProgress() {
|
totalProgress() {
|
||||||
const resources = []
|
return Number(this.course.progressPercent || 0)
|
||||||
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() {
|
learningGoals() {
|
||||||
return this.course.lecturerName ? this.course.lecturerName : "开始学习"
|
return String(this.course.learningGoal || "").split(/[\r\n;;]+/).map(goal => goal.trim()).filter(Boolean)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -489,6 +442,7 @@ layout("/layouts/platform_h5.html"){
|
|||||||
const resp = await this.$axios.post(this.apiBase + "/studyTree", { courseId: this.courseId })
|
const resp = await this.$axios.post(this.apiBase + "/studyTree", { courseId: this.courseId })
|
||||||
if (resp.code === 0) {
|
if (resp.code === 0) {
|
||||||
this.treeData = resp.data || []
|
this.treeData = resp.data || []
|
||||||
|
this.loadDownloadFiles()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
appendCatalogNode(node, result) {
|
appendCatalogNode(node, result) {
|
||||||
@@ -499,6 +453,7 @@ layout("/layouts/platform_h5.html"){
|
|||||||
result.push({
|
result.push({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
title: node.title,
|
title: node.title,
|
||||||
|
description: node.description,
|
||||||
resources: resources
|
resources: resources
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -519,10 +474,74 @@ layout("/layouts/platform_h5.html"){
|
|||||||
})
|
})
|
||||||
return resources
|
return resources
|
||||||
},
|
},
|
||||||
selectChapter(chapter) {
|
toggleChapter(id) {
|
||||||
if (chapter && chapter.resources && chapter.resources.length) {
|
this.$set(this.collapsedChapters, id, !this.collapsedChapters[id])
|
||||||
this.openResource(chapter.resources[0])
|
},
|
||||||
|
toggleAllChapters() {
|
||||||
|
const collapsed = !this.allChaptersCollapsed
|
||||||
|
this.catalogList.forEach(chapter => this.$set(this.collapsedChapters, chapter.id, collapsed))
|
||||||
|
},
|
||||||
|
continueStudy() {
|
||||||
|
const resources = this.learnableResources
|
||||||
|
const resource = resources.find(item => item.studied && item.completeStatus !== "completed")
|
||||||
|
|| resources.find(item => !item.studied)
|
||||||
|
|| resources.find(item => item.completeStatus !== "completed")
|
||||||
|
|| resources[0]
|
||||||
|
if (resource) this.openResource(resource)
|
||||||
|
},
|
||||||
|
resourceMeta(resource) {
|
||||||
|
const types = { video: "视频", audio: "音频", image: "图片", word: "Word文档", pdf: "PDF文档", ppt: "演示文稿" }
|
||||||
|
const type = types[resource.resourceType] || ((resource.fileExt || "").toUpperCase() + " 文档").trim()
|
||||||
|
const seconds = Number(resource.durationSeconds || 0)
|
||||||
|
if (seconds > 0) return type + " · " + Math.floor(seconds / 60) + ":" + String(seconds % 60).padStart(2, "0")
|
||||||
|
return type
|
||||||
|
},
|
||||||
|
async loadDownloadFiles() {
|
||||||
|
const ids = [...new Set(this.downloadableResources.map(resource => this.getFileId(resource.fileData)).filter(id => id && !this.downloadFiles[id]))]
|
||||||
|
if (!ids.length) return
|
||||||
|
try {
|
||||||
|
const resp = await this.$axios.post("/platform/sys/file/previewFileData", { ids: JSON.stringify(ids) })
|
||||||
|
if (resp.code === 0) {
|
||||||
|
;(resp.data || []).forEach(file => this.$set(this.downloadFiles, file.id, file))
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.$toast("文件信息加载失败")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
downloadFileInfo(resource) {
|
||||||
|
const file = this.downloadFiles[this.getFileId(resource.fileData)]
|
||||||
|
if (file) return file
|
||||||
|
const attachment = this.getFileEntry(resource.fileData)
|
||||||
|
return attachment && typeof attachment === "object" ? attachment : {}
|
||||||
|
},
|
||||||
|
downloadIcon(resource) {
|
||||||
|
const file = this.downloadFileInfo(resource)
|
||||||
|
const ext = (file.suffix || resource.fileExt || (file.name || "").split(".").pop() || resource.resourceType || "").toLowerCase()
|
||||||
|
const icons = { pdf: "pdf", doc: "docx", docx: "docx", word: "docx", xls: "xlsx", xlsx: "xlsx", csv: "xlsx", ppt: "pptx", pptx: "pptx", zip: "zip", rar: "zip", "7z": "zip", mp3: "mp3", audio: "mp3", mp4: "mp4", video: "mp4" }
|
||||||
|
return "/assets/platform/img/svg/" + (icons[ext] || "txt") + ".svg"
|
||||||
|
},
|
||||||
|
downloadSize(resource) {
|
||||||
|
const file = this.downloadFileInfo(resource)
|
||||||
|
if (file.sizeInfo) return file.sizeInfo
|
||||||
|
const bytes = file.size != null ? Number(file.size) : file.sizeKb != null ? Number(file.sizeKb) * 1024 : NaN
|
||||||
|
if (!Number.isFinite(bytes) || bytes < 0) return ""
|
||||||
|
if (bytes < 1024) return bytes + "B"
|
||||||
|
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + "KB"
|
||||||
|
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + "MB"
|
||||||
|
return (bytes / 1073741824).toFixed(1) + "GB"
|
||||||
|
},
|
||||||
|
downloadDate(resource) {
|
||||||
|
const value = this.downloadFileInfo(resource).createdAt
|
||||||
|
if (!value) return ""
|
||||||
|
const date = new Date(/^\d+$/.test(String(value)) ? Number(value) : value)
|
||||||
|
if (Number.isNaN(date.getTime())) return ""
|
||||||
|
return date.getFullYear() + "-" + String(date.getMonth() + 1).padStart(2, "0") + "-" + String(date.getDate()).padStart(2, "0")
|
||||||
|
},
|
||||||
|
downloadResource(resource) {
|
||||||
|
if (resource.allowDownload !== true) return
|
||||||
|
const id = this.getFileId(resource.fileData)
|
||||||
|
const url = id ? "/platform/sys/file/download?id=" + encodeURIComponent(id) : this.getFileUrl(resource.fileData)
|
||||||
|
if (url) window.open(url)
|
||||||
},
|
},
|
||||||
progressKey(resource) {
|
progressKey(resource) {
|
||||||
const target = resource || this.selectedResource
|
const target = resource || this.selectedResource
|
||||||
@@ -533,10 +552,13 @@ layout("/layouts/platform_h5.html"){
|
|||||||
const serverPosition = Number(resource.lastPositionSeconds || 0)
|
const serverPosition = Number(resource.lastPositionSeconds || 0)
|
||||||
return Math.max(0, serverPosition)
|
return Math.max(0, serverPosition)
|
||||||
},
|
},
|
||||||
openResource(resource) {
|
async openResource(resource) {
|
||||||
if (!resource || !resource.id) return
|
if (!resource || !resource.id || this.switchingResource || this.startingStudy) return
|
||||||
|
this.switchingResource = true
|
||||||
|
try {
|
||||||
this.pauseMedia()
|
this.pauseMedia()
|
||||||
this.finishStudy(false)
|
await this.finishStudy(false)
|
||||||
|
resource = this.allResources.find(item => item.id === resource.id) || resource
|
||||||
this.selectedResource = resource
|
this.selectedResource = resource
|
||||||
this.resumePositionSeconds = this.getResumePosition(resource)
|
this.resumePositionSeconds = this.getResumePosition(resource)
|
||||||
this.resumeSeekRetryCount = 0
|
this.resumeSeekRetryCount = 0
|
||||||
@@ -546,11 +568,15 @@ layout("/layouts/platform_h5.html"){
|
|||||||
this.lastAllowedPositionSeconds = Number(resource.maxPositionSeconds || 0)
|
this.lastAllowedPositionSeconds = Number(resource.maxPositionSeconds || 0)
|
||||||
this.restoringMediaPosition = false
|
this.restoringMediaPosition = false
|
||||||
this.playerVisible = true
|
this.playerVisible = true
|
||||||
|
if (!this.canPreview) return
|
||||||
if (!["video", "audio"].includes(resource.resourceType)) {
|
if (!["video", "audio"].includes(resource.resourceType)) {
|
||||||
this.$nextTick(() => this.startStudy())
|
this.$nextTick(() => this.startStudy())
|
||||||
} else {
|
} else {
|
||||||
this.$nextTick(() => this.restoreMediaPosition())
|
this.$nextTick(() => this.restoreMediaPosition())
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
this.switchingResource = false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
restoreMediaPosition() {
|
restoreMediaPosition() {
|
||||||
const player = this.$refs.mediaPlayer
|
const player = this.$refs.mediaPlayer
|
||||||
@@ -612,21 +638,31 @@ layout("/layouts/platform_h5.html"){
|
|||||||
this.verifyMediaPosition()
|
this.verifyMediaPosition()
|
||||||
},
|
},
|
||||||
async startStudy() {
|
async startStudy() {
|
||||||
if (!this.selectedResource.id || this.studying) return
|
if (!this.canPreview || this.studying || this.startingStudy) return
|
||||||
|
this.startingStudy = true
|
||||||
|
const resourceId = this.selectedResource.id
|
||||||
|
try {
|
||||||
const resp = await this.$axios.post(this.recordApi + "/start", {
|
const resp = await this.$axios.post(this.recordApi + "/start", {
|
||||||
courseId: this.courseId,
|
courseId: this.courseId,
|
||||||
resourceId: this.selectedResource.id,
|
resourceId: resourceId,
|
||||||
positionSeconds: this.resumePositionRestored ? this.getMediaPosition() : this.resumePositionSeconds
|
positionSeconds: this.resumePositionRestored ? this.getMediaPosition() : this.resumePositionSeconds
|
||||||
})
|
})
|
||||||
if (resp.code !== 0) {
|
if (resp.code !== 0) {
|
||||||
this.$toast(resp.msg || "开始学习失败")
|
this.$toast(resp.msg || "开始学习失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!this.playerVisible || this.selectedResource.id !== resourceId) {
|
||||||
|
await this.$axios.post(this.recordApi + "/finish", { segmentId: resp.data.segmentId, activeSeconds: 0 })
|
||||||
|
return
|
||||||
|
}
|
||||||
this.currentSegmentId = resp.data.segmentId
|
this.currentSegmentId = resp.data.segmentId
|
||||||
this.studying = true
|
this.studying = true
|
||||||
this.pendingSeconds = 0
|
this.pendingSeconds = 0
|
||||||
this.applyRecordState(resp.data)
|
this.applyRecordState(resp.data)
|
||||||
this.startHeartbeatTimer()
|
this.startHeartbeatTimer()
|
||||||
|
} finally {
|
||||||
|
this.startingStudy = false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async heartbeat() {
|
async heartbeat() {
|
||||||
if (!this.currentSegmentId || this.pendingSeconds <= 0) return
|
if (!this.currentSegmentId || this.pendingSeconds <= 0) return
|
||||||
@@ -651,14 +687,15 @@ layout("/layouts/platform_h5.html"){
|
|||||||
this.stopHeartbeatTimer()
|
this.stopHeartbeatTimer()
|
||||||
this.studying = false
|
this.studying = false
|
||||||
this.currentSegmentId = ""
|
this.currentSegmentId = ""
|
||||||
await this.$axios.post(this.recordApi + "/finish", {
|
const resp = await this.$axios.post(this.recordApi + "/finish", {
|
||||||
segmentId: segmentId,
|
segmentId: segmentId,
|
||||||
activeSeconds: seconds,
|
activeSeconds: seconds,
|
||||||
positionSeconds: this.getMediaPosition(),
|
positionSeconds: this.getMediaPosition(),
|
||||||
completed: !!completed
|
completed: !!completed
|
||||||
})
|
})
|
||||||
|
if (resp.code === 0) this.applyRecordState(resp.data)
|
||||||
if (force) return
|
if (force) return
|
||||||
this.loadTree()
|
await this.loadTree()
|
||||||
},
|
},
|
||||||
startHeartbeatTimer() {
|
startHeartbeatTimer() {
|
||||||
this.stopHeartbeatTimer()
|
this.stopHeartbeatTimer()
|
||||||
@@ -734,17 +771,20 @@ layout("/layouts/platform_h5.html"){
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
applyRecordState(data) {
|
applyRecordState(data) {
|
||||||
if (!this.selectedResource.id || !data) return
|
if (data && data.courseProgress) Object.keys(data.courseProgress).forEach(key => this.$set(this.course, key, data.courseProgress[key]))
|
||||||
this.$set(this.selectedResource, "progressPercent", Number(data.progressPercent || 0))
|
if (!data || !data.resourceId) return
|
||||||
this.$set(this.selectedResource, "studySeconds", Number(data.studySeconds || 0))
|
const resources = this.allResources.filter(item => item.id === data.resourceId)
|
||||||
this.$set(this.selectedResource, "completeStatus", data.completeStatus || "studying")
|
if (this.selectedResource.id === data.resourceId && !resources.includes(this.selectedResource)) resources.push(this.selectedResource)
|
||||||
if (data.lastPositionSeconds !== undefined) {
|
resources.forEach(resource => {
|
||||||
this.$set(this.selectedResource, "lastPositionSeconds", Number(data.lastPositionSeconds || 0))
|
this.$set(resource, "studied", data.studied !== false)
|
||||||
}
|
this.$set(resource, "progressPercent", Number(data.progressPercent || 0))
|
||||||
if (data.maxPositionSeconds !== undefined) {
|
this.$set(resource, "studySeconds", Number(data.studySeconds || 0))
|
||||||
const maxPositionSeconds = Number(data.maxPositionSeconds || 0)
|
this.$set(resource, "completeStatus", data.completeStatus || "studying")
|
||||||
this.$set(this.selectedResource, "maxPositionSeconds", maxPositionSeconds)
|
if (data.lastPositionSeconds !== undefined) this.$set(resource, "lastPositionSeconds", Number(data.lastPositionSeconds || 0))
|
||||||
this.lastAllowedPositionSeconds = Math.max(Number(this.lastAllowedPositionSeconds || 0), maxPositionSeconds)
|
if (data.maxPositionSeconds !== undefined) this.$set(resource, "maxPositionSeconds", Number(data.maxPositionSeconds || 0))
|
||||||
|
})
|
||||||
|
if (this.selectedResource.id === data.resourceId && data.maxPositionSeconds !== undefined) {
|
||||||
|
this.lastAllowedPositionSeconds = Math.max(Number(this.lastAllowedPositionSeconds || 0), Number(data.maxPositionSeconds || 0))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getMediaPosition() {
|
getMediaPosition() {
|
||||||
@@ -753,41 +793,52 @@ layout("/layouts/platform_h5.html"){
|
|||||||
return player ? Math.floor(player.currentTime || 0) : Number(this.selectedResource.lastPositionSeconds || 0)
|
return player ? Math.floor(player.currentTime || 0) : Number(this.selectedResource.lastPositionSeconds || 0)
|
||||||
},
|
},
|
||||||
statusText(resource) {
|
statusText(resource) {
|
||||||
if (!resource || resource.completeStatus === "not_started") return "未学习"
|
if (!resource || !resource.studied) return "未学习"
|
||||||
if (resource.completeStatus === "completed") return "已完成"
|
if (resource.completeStatus === "completed") return "已完成"
|
||||||
return "学习中 " + Number(resource.progressPercent || 0) + "%"
|
return "学习中 " + Number(resource.progressPercent || 0) + "%"
|
||||||
},
|
},
|
||||||
resourceIcon(resource) {
|
resourceIcon(resource) {
|
||||||
|
if (resource.resourceType === "pdf" || (resource.fileExt || "").toLowerCase() === "pdf") return "/assets/platform/img/svg/pdf.svg"
|
||||||
|
if (resource.resourceType === "word" || ["doc", "docx"].includes((resource.fileExt || "").toLowerCase())) return "/assets/platform/img/svg/docx.svg"
|
||||||
if (resource.resourceType === "video") return "video"
|
if (resource.resourceType === "video") return "video"
|
||||||
if (resource.resourceType === "audio") return "music-o"
|
if (resource.resourceType === "audio") return "music-o"
|
||||||
if (resource.resourceType === "image") return "photo-o"
|
if (resource.resourceType === "image") return "photo-o"
|
||||||
return "description"
|
return "description"
|
||||||
},
|
},
|
||||||
resourceIconClass(resource) {
|
resourceIconClass(resource) {
|
||||||
|
if (resource.resourceType === "pdf" || (resource.fileExt || "").toLowerCase() === "pdf") return "pdf"
|
||||||
|
if (resource.resourceType === "word" || ["doc", "docx"].includes((resource.fileExt || "").toLowerCase())) return "word"
|
||||||
if (resource.resourceType === "video") return "video"
|
if (resource.resourceType === "video") return "video"
|
||||||
if (resource.resourceType === "audio") return "audio"
|
if (resource.resourceType === "audio") return "audio"
|
||||||
if (resource.resourceType === "image") return "image"
|
if (resource.resourceType === "image") return "image"
|
||||||
return "doc"
|
return "doc"
|
||||||
},
|
},
|
||||||
getFileUrl(value) {
|
getFileEntry(value) {
|
||||||
if (!value) return ""
|
if (!value) return null
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value.length ? (value[0].url || (value[0].response && value[0].response.data) || value[0].data || "") : ""
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
const text = value.trim()
|
const text = value.trim()
|
||||||
if (!text) return ""
|
if (!text) return null
|
||||||
if (text.startsWith("[")) {
|
if (["[", "{", '"'].includes(text.charAt(0))) {
|
||||||
try {
|
try { return this.getFileEntry(JSON.parse(text)) } catch (e) { return null }
|
||||||
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 text
|
||||||
}
|
}
|
||||||
return ""
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(item => this.getFileEntry(item)).find(item => this.getFileUrl(item)) || null
|
||||||
|
}
|
||||||
|
return typeof value === "object" ? value : null
|
||||||
|
},
|
||||||
|
getFileUrl(value) {
|
||||||
|
const file = this.getFileEntry(value)
|
||||||
|
if (!file) return ""
|
||||||
|
if (typeof file === "string") {
|
||||||
|
return /^[a-zA-Z0-9_-]+$/.test(file) ? "/platform/sys/file/download?id=" + encodeURIComponent(file) : file
|
||||||
|
}
|
||||||
|
return this.getFileUrl(file.url)
|
||||||
|
|| this.getFileUrl(file.downloadPath)
|
||||||
|
|| this.getFileUrl(file.response && file.response.data)
|
||||||
|
|| this.getFileUrl(file.data)
|
||||||
|
|| this.getFileUrl(file.id || file.fileId)
|
||||||
},
|
},
|
||||||
getFileId(value) {
|
getFileId(value) {
|
||||||
const url = this.getFileUrl(value)
|
const url = this.getFileUrl(value)
|
||||||
@@ -803,9 +854,12 @@ layout("/layouts/platform_h5.html"){
|
|||||||
return ["pdf", "ppt", "word"].includes(type) || ["pdf", "ppt", "pptx", "doc", "docx"].includes(ext)
|
return ["pdf", "ppt", "word"].includes(type) || ["pdf", "ppt", "pptx", "doc", "docx"].includes(ext)
|
||||||
},
|
},
|
||||||
openFile() {
|
openFile() {
|
||||||
if (this.fileUrl) {
|
if (!this.canPreview) return
|
||||||
window.open(this.fileUrl)
|
const url = this.canInlinePreview(this.selectedResource) ? this.inlinePreviewUrl : this.mediaUrl
|
||||||
}
|
if (url) window.open(url)
|
||||||
|
},
|
||||||
|
downloadFile() {
|
||||||
|
if (this.canDownload && this.downloadUrl) window.open(this.downloadUrl)
|
||||||
},
|
},
|
||||||
pauseMedia() {
|
pauseMedia() {
|
||||||
const player = this.$refs.mediaPlayer
|
const player = this.$refs.mediaPlayer
|
||||||
|
|||||||
@@ -3,294 +3,105 @@ layout("/layouts/platform_h5.html"){
|
|||||||
#-->
|
#-->
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.my-study-page {
|
#app.my-study-page { min-height: 100%; height: auto; box-sizing: border-box; padding-bottom: 24px; padding-bottom: calc(24px + env(safe-area-inset-bottom, 0px)); background: #f7fbff linear-gradient(180deg, #cfe8ff 0, #e5f3ff 160px, #f1f8ff 320px, #f7fbff 480px) no-repeat; color: #26364c; }
|
||||||
min-height: 100vh;
|
.my-study-hero { padding: 24px 16px 18px; background: transparent; }
|
||||||
background: #f3f7fb;
|
.my-study-head { display: flex; align-items: center; gap: 8px; }
|
||||||
color: #202733;
|
.my-study-back { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 36px; width: 36px; height: 36px; padding: 0; border: 0; border-radius: 50%; background: #fff; color: #1989fa; font-size: 20px; }
|
||||||
}
|
.my-study-heading { margin: 0 0 0 auto; text-align: right; font-size: 22px; line-height: 1.5; font-weight: 700; }
|
||||||
.my-study-hero {
|
.my-study-subtitle { margin: 4px 0 18px; text-align: right; color: #8192a9; font-size: 12px; }
|
||||||
position: relative;
|
.my-study-overview { padding: 18px 16px 14px; border-radius: 16px; background: #fff; box-shadow: 0 6px 20px rgba(45, 111, 188, .04); }
|
||||||
padding: 86px 20px 72px;
|
.my-study-time-heading { position: relative; min-height: 80px; padding-right: 100px; }
|
||||||
color: #ffffff;
|
.my-study-book { position: absolute; top: 0; right: 0; width: 92px; height: 80px; background-color: #c9e5ff; -webkit-mask: url("${base!}/assets/platform/img/svg/ant-design_read-filled.svg") center / contain no-repeat; mask: url("${base!}/assets/platform/img/svg/ant-design_read-filled.svg") center / contain no-repeat; transform: rotate(-15deg); pointer-events: none; }
|
||||||
background: linear-gradient(135deg, #0ca7dd 0%, #0699d6 58%, #0f8fd7 100%);
|
.my-study-time-heading .my-study-time { flex-wrap: wrap; }
|
||||||
overflow: hidden;
|
.my-study-time-label { display: flex; align-items: center; gap: 6px; color: #7a8ca6; font-size: 12px; }
|
||||||
}
|
.my-study-time-label .van-icon { color: #1989fa; font-size: 16px; }
|
||||||
.my-study-hero::after {
|
.my-study-time { display: flex; align-items: baseline; gap: 6px; margin-top: 8px; color: #8192a9; font-size: 12px; }
|
||||||
content: "";
|
.my-study-time strong { color: #1989fa; font-size: 30px; font-weight: 700; line-height: 1.3; font-variant-numeric: tabular-nums; }
|
||||||
position: absolute;
|
.my-study-time strong:not(:first-child) { margin-left: 8px; }
|
||||||
right: -74px;
|
.my-study-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); padding-top: 16px; margin-top: 16px; border-top: 1px solid #edf2f8; text-align: center; }
|
||||||
bottom: -108px;
|
.my-study-stat + .my-study-stat { border-left: 1px solid #edf2f8; }
|
||||||
width: 260px;
|
.my-study-stat-value { font-size: 20px; line-height: 1.3; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
height: 260px;
|
.my-study-stat-label { margin-top: 4px; color: #8192a9; font-size: 11px; }
|
||||||
border-radius: 50%;
|
.my-study-main { padding: 0 16px; }
|
||||||
background: rgba(255, 255, 255, .08);
|
.my-study-list-header { position: sticky; top: 0; z-index: 10; margin: 0 -16px; padding: 12px 16px 14px; background: #f7fbff; }
|
||||||
}
|
.my-study-list-header .my-study-tabs { margin-bottom: 0; }
|
||||||
.my-study-status {
|
.my-study-section-head { display: flex; align-items: baseline; justify-content: space-between; margin: 2px 0 12px; }
|
||||||
display: block;
|
.my-study-section-title { margin: 0; font-size: 17px; font-weight: 700; }
|
||||||
position: relative;
|
.my-study-section-count { color: #8b9ab0; font-size: 12px; }
|
||||||
z-index: 1;
|
.my-study-tabs { display: flex; gap: 8px; margin-bottom: 14px; }
|
||||||
}
|
.my-study-tab { flex: 1; padding: 8px 4px; border: 0; border-radius: 9px; background: #eaf1fa; color: #7a8ca6; font-size: 12px; line-height: 1.5; }
|
||||||
.my-study-time {
|
.my-study-tab.active { background: #1989fa; color: #fff; font-weight: 600; }
|
||||||
font-size: 15px;
|
.my-study-list-card { margin-bottom: 12px; padding: 12px; border-radius: 14px; background: #fff; }
|
||||||
color: rgba(255, 255, 255, .82);
|
.my-study-list-card:last-child { margin-bottom: 0; }
|
||||||
}
|
.my-study-course-top { display: flex; align-items: flex-start; gap: 12px; width: 100%; padding: 0; border: 0; background: transparent; text-align: left; color: inherit; }
|
||||||
.my-study-time strong {
|
.my-study-list-cover { position: relative; flex: 0 0 112px; width: 112px; height: 84px; aspect-ratio: 4 / 3; border-radius: 8px; overflow: hidden; background: #edf5ff; }
|
||||||
margin: 0 3px;
|
.my-study-list-cover img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||||
color: #ffffff;
|
.my-study-cover-empty { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; color: #98bde9; font-size: 30px; }
|
||||||
font-size: 26px;
|
.my-study-list-info { min-width: 0; flex: 1; }
|
||||||
font-weight: 800;
|
.my-study-list-title { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; color: #26364c; font-size: 14px; font-weight: 600; line-height: 1.5; }
|
||||||
}
|
.my-study-status { display: inline-flex; align-items: center; gap: 3px; margin-top: 6px; padding: 2px 6px; border-radius: 5px; color: #1989fa; background: #edf6ff; font-size: 10px; }
|
||||||
.my-study-stats {
|
.my-study-status.completed { color: #28b583; background: #eafaf3; }
|
||||||
position: relative;
|
.my-study-progress { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
|
||||||
z-index: 1;
|
.my-study-progress .van-progress { flex: 1; }
|
||||||
display: grid;
|
.my-study-progress-value { flex-shrink: 0; color: #1989fa; font-size: 12px; font-weight: 600; }
|
||||||
grid-template-columns: repeat(2, 1fr);
|
.my-study-course-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 10px; }
|
||||||
gap: 0;
|
.my-study-list-meta { min-width: 0; color: #8b9ab0; font-size: 11px; line-height: 1.7; }
|
||||||
margin-top: 26px;
|
.my-study-list-meta span { display: block; }
|
||||||
text-align: center;
|
.my-study-continue { display: inline-flex; align-items: center; gap: 4px; flex-shrink: 0; padding: 7px 12px; border: 0; border-radius: 16px; background: #edf6ff; color: #1989fa; font-size: 12px; line-height: 1.5; }
|
||||||
}
|
.my-study-loading { padding: 40px 0; text-align: center; }
|
||||||
.my-study-stat-value {
|
.my-study-page button:focus-visible { outline: 2px solid #1989fa; outline-offset: 2px; }
|
||||||
font-size: 23px;
|
@media (max-width: 359px) { .my-study-main { padding: 0 12px; } .my-study-list-header { margin-right: -12px; margin-left: -12px; padding-right: 12px; padding-left: 12px; } .my-study-list-cover { flex-basis: 96px; width: 96px; height: 72px; } .my-study-course-top { gap: 9px; } }
|
||||||
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>
|
</style>
|
||||||
|
|
||||||
<div id="app" class="my-study-page" v-cloak>
|
<div id="app" class="my-study-page" v-cloak>
|
||||||
<div class="my-study-hero">
|
<div class="my-study-hero">
|
||||||
<div class="my-study-status">
|
<div class="my-study-head">
|
||||||
<div class="my-study-time">
|
<button type="button" class="my-study-back" aria-label="返回" @click="goBack"><van-icon name="arrow-left"></van-icon></button>
|
||||||
累计学习时长 <strong>{{ summary.studyHour || 0 }}</strong> 小时 <strong>{{ summary.studyMinute || 0 }}</strong> 分钟
|
<h1 class="my-study-heading">我的学习</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="my-study-subtitle">记录学习点滴,见证每一次进步</p>
|
||||||
|
<div class="my-study-overview">
|
||||||
|
<div class="my-study-time-heading">
|
||||||
|
<div class="my-study-time-label"><van-icon name="clock-o"></van-icon>累计学习时长</div>
|
||||||
|
<div class="my-study-time"><strong>{{ summary.studyHour || 0 }}</strong>小时<strong>{{ summary.studyMinute || 0 }}</strong>分钟</div>
|
||||||
|
<span class="my-study-book" aria-hidden="true"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-study-stats">
|
<div class="my-study-stats">
|
||||||
<div v-for="item in statItems" :key="item.label">
|
<div v-for="item in statItems" :key="item.label" class="my-study-stat">
|
||||||
<div class="my-study-stat-value">{{ item.value }}</div>
|
<div class="my-study-stat-value">{{ item.value }}</div>
|
||||||
<div class="my-study-stat-label">{{ item.label }}</div>
|
<div class="my-study-stat-label">{{ item.label }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="my-study-main">
|
<div class="my-study-main">
|
||||||
<div class="my-study-assistant">
|
<div class="my-study-list-header">
|
||||||
<div class="my-study-ai"><span>AI</span>学助手</div>
|
<div class="my-study-section-head"><h2 class="my-study-section-title">我学习的课程</h2><span class="my-study-section-count">共 {{ courses.length }} 门</span></div>
|
||||||
<div class="my-study-assistant-text">来问问AI学习助手,获取专属学习计划</div>
|
<nav class="my-study-tabs" aria-label="学习状态">
|
||||||
<div class="my-study-assistant-btn">查看</div>
|
<button v-for="tab in statusTabs" :key="tab.value" type="button" class="my-study-tab" :class="{ active: activeStatus === tab.value }" :aria-pressed="activeStatus === tab.value" @click="activeStatus = tab.value">{{ tab.label }}</button>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="loading" class="my-study-loading"><van-loading size="22px" color="#1989fa">加载中…</van-loading></div>
|
||||||
<div class="my-study-section">
|
<van-empty v-else-if="loadError" description="学习记录加载失败"><van-button round size="small" type="info" @click="loadData">重新加载</van-button></van-empty>
|
||||||
<div class="my-study-section-head">
|
<div v-else class="my-study-list">
|
||||||
<div class="my-study-section-title">我学习的课程</div>
|
<article v-for="course in visibleCourses" :key="course.courseId" class="my-study-list-card">
|
||||||
<div class="my-study-all" @click="scrollToList">全部 <van-icon name="arrow"></van-icon></div>
|
<button type="button" class="my-study-course-top" @click="openCourse(course)">
|
||||||
</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">
|
<div class="my-study-list-cover">
|
||||||
<img v-if="getCoverUrl(course.cover)" :src="getCoverUrl(course.cover)" :alt="course.courseName">
|
<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 v-else class="my-study-cover-empty"><van-icon name="description"></van-icon></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-study-list-info">
|
<div class="my-study-list-info">
|
||||||
<div class="my-study-list-title">{{ course.courseName || '未命名课程' }}</div>
|
<div class="my-study-list-title">{{ course.courseName || '未命名课程' }}</div>
|
||||||
<div class="my-study-list-meta">学习进度 {{ course.progressPercent || 0 }}% · {{ course.studyTimeText || '0秒' }}</div>
|
<span class="my-study-status" :class="{ completed: isCompleted(course) }"><van-icon :name="isCompleted(course) ? 'checked' : 'clock-o'"></van-icon>{{ isCompleted(course) ? '已完成' : '学习中' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
|
<div v-if="Number(course.outlineCount || 0) > 0" class="my-study-progress"><van-progress :percentage="courseProgress(course)" :show-pivot="false" stroke-width="5" :color="isCompleted(course) ? '#2fc78c' : '#1989fa'"></van-progress><span class="my-study-progress-value">{{ courseProgress(course) }}%</span></div>
|
||||||
|
<div class="my-study-course-foot">
|
||||||
|
<div class="my-study-list-meta"><span>已完成 {{ course.completedOutlineCount || 0 }} 个章节 · 已学 {{ course.studyTimeText || '0秒' }}</span><span v-if="studyDate(course.latestStudyTime)">最近学习 {{ studyDate(course.latestStudyTime) }}</span></div>
|
||||||
|
<button type="button" class="my-study-continue" @click="openCourse(course)"><van-icon name="play"></van-icon>{{ isCompleted(course) ? '再次学习' : '继续学习' }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
</article>
|
||||||
|
<van-empty v-if="!visibleCourses.length" :description="activeStatus === 'completed' ? '暂无已完成课程' : activeStatus === 'studying' ? '暂无学习中的课程' : '暂无学习课程'"></van-empty>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -303,35 +114,75 @@ layout("/layouts/platform_h5.html"){
|
|||||||
apiBase: "/platform/learning/study/record",
|
apiBase: "/platform/learning/study/record",
|
||||||
summary: {},
|
summary: {},
|
||||||
courses: [],
|
courses: [],
|
||||||
loading: false
|
loading: false,
|
||||||
}
|
loadError: false,
|
||||||
},
|
activeStatus: "all",
|
||||||
computed: {
|
statusTabs: [
|
||||||
statItems() {
|
{ value: "all", label: "全部" },
|
||||||
return [
|
{ value: "studying", label: "学习中" },
|
||||||
{ label: "已学完课程", value: this.summary.completedCourseCount || 0 },
|
{ value: "completed", label: "已完成" }
|
||||||
{ label: "学习专题", value: 0 }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
completedCount() {
|
||||||
|
return this.courses.filter(course => this.isCompleted(course)).length
|
||||||
|
},
|
||||||
|
statItems() {
|
||||||
|
return [
|
||||||
|
{ label: "已学课程", value: this.courses.length },
|
||||||
|
{ label: "学习中", value: this.courses.length - this.completedCount },
|
||||||
|
{ label: "已完成", value: this.completedCount }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
visibleCourses() {
|
||||||
|
if (this.activeStatus === "completed") return this.courses.filter(course => this.isCompleted(course))
|
||||||
|
if (this.activeStatus === "studying") return this.courses.filter(course => !this.isCompleted(course))
|
||||||
|
return this.courses
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
goBack() {
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
window.history.back()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pjaxReplace("/platform/learning/course/h5")
|
||||||
|
},
|
||||||
async loadData() {
|
async loadData() {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
|
this.loadError = false
|
||||||
try {
|
try {
|
||||||
const [summaryResp, courseResp] = await Promise.all([
|
const [summaryResp, courseResp] = await Promise.all([
|
||||||
this.$axios.post(this.apiBase + "/h5Summary"),
|
this.$axios.post(this.apiBase + "/h5Summary"),
|
||||||
this.$axios.post(this.apiBase + "/h5Courses")
|
this.$axios.post(this.apiBase + "/h5Courses")
|
||||||
])
|
])
|
||||||
if (summaryResp.code === 0) {
|
if (summaryResp.code !== 0 || courseResp.code !== 0) {
|
||||||
|
this.loadError = true
|
||||||
|
return
|
||||||
|
}
|
||||||
this.summary = summaryResp.data || {}
|
this.summary = summaryResp.data || {}
|
||||||
}
|
|
||||||
if (courseResp.code === 0) {
|
|
||||||
this.courses = courseResp.data || []
|
this.courses = courseResp.data || []
|
||||||
}
|
} catch (e) {
|
||||||
|
this.loadError = true
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false
|
this.loading = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
courseProgress(course) {
|
||||||
|
const value = Number(course.progressPercent || 0)
|
||||||
|
return Number.isFinite(value) ? Math.max(0, Math.min(100, Math.round(value))) : 0
|
||||||
|
},
|
||||||
|
isCompleted(course) {
|
||||||
|
return this.courseProgress(course) === 100 && Number(course.outlineCount || 0) > 0
|
||||||
|
&& Number(course.completedOutlineCount || 0) >= Number(course.outlineCount)
|
||||||
|
},
|
||||||
|
studyDate(value) {
|
||||||
|
if (!value) return ""
|
||||||
|
const date = new Date(/^\d+$/.test(String(value)) ? Number(value) : String(value).replace(/-/g, "/"))
|
||||||
|
if (Number.isNaN(date.getTime())) return ""
|
||||||
|
return date.getFullYear() + "-" + String(date.getMonth() + 1).padStart(2, "0") + "-" + String(date.getDate()).padStart(2, "0")
|
||||||
|
},
|
||||||
getCoverUrl(cover) {
|
getCoverUrl(cover) {
|
||||||
if (!cover) return ""
|
if (!cover) return ""
|
||||||
if (Array.isArray(cover)) {
|
if (Array.isArray(cover)) {
|
||||||
@@ -354,10 +205,7 @@ layout("/layouts/platform_h5.html"){
|
|||||||
},
|
},
|
||||||
openCourse(course) {
|
openCourse(course) {
|
||||||
if (!course || !course.courseId) return
|
if (!course || !course.courseId) return
|
||||||
pjaxReplace("/platform/learning/course/h5/study?id=" + course.courseId)
|
pjaxReplace("/platform/learning/course/h5/study?id=" + encodeURIComponent(course.courseId))
|
||||||
},
|
|
||||||
scrollToList() {
|
|
||||||
this.$refs.courseList && this.$refs.courseList.scrollIntoView({ behavior: "smooth", block: "start" })
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
Reference in New Issue
Block a user