This commit is contained in:
2026-06-12 15:30:50 +08:00
parent 742a0ff388
commit b23d3bfa79
58 changed files with 10475 additions and 474 deletions
@@ -6,7 +6,7 @@ import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.result.Result;
import com.budwk.app.sys.models.Sys_home_activity;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.activity.basic.models.ActivityUserScope;
import com.budwk.app.zhgh.activity.basic.service.ActivityBasicScopeService;
import io.swagger.annotations.Api;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
@@ -32,6 +32,8 @@ public class SysH5IndexController {
@Inject
private Dao dao;
@Inject
private ActivityBasicScopeService activityBasicScopeService;
@At
@Ok("beetl:/platform/zhghh5/sys/home/index.html")
@@ -40,6 +42,26 @@ public class SysH5IndexController {
}
/**
* Render the mobile featured activity aggregation page.
*/
@At
@Ok("beetl:/platform/zhghh5/sys/home/featuredActivity.html")
@SaCheckLogin
public void featuredActivity() {
}
/**
* Render the mobile festival benefit aggregation page.
*/
@At
@Ok("beetl:/platform/zhghh5/sys/home/festivalBenefit.html")
@SaCheckLogin
public void festivalBenefit() {
}
@At
@Ok("beetl:/platform/zhghh5/index/mine/index.html")
@SaCheckLogin
@@ -88,8 +110,8 @@ public class SysH5IndexController {
}
if (allowUserGroupId != null) {
int count = dao.count(ActivityUserScope.class, Cnd.where("groupId", "=", allowUserGroupId).and("userId", "=", userId));
if (count > 0) {
// Delegate group membership checks to the service so SQL based groups and stored groups stay consistent.
if (activityBasicScopeService.isUserInGroup(allowUserGroupId, userId)) {
allowActivityList.add(activity);
continue;
}
@@ -97,7 +119,10 @@ public class SysH5IndexController {
if (StrUtil.isNotBlank(allowUserSql)){
Sql sql = Sqls.create(allowUserSql).setParam("userId", userId);
NutMap nutMap = (NutMap) Daos.query(dao, sql.toString(), Sqls.callback.map());
// Execute the configured visibility SQL with Nutz parameters to avoid rebuilding raw SQL text in controller logic.
sql.setCallback(Sqls.callback.map());
dao.execute(sql);
NutMap nutMap = (NutMap) sql.getResult();
if (Lang.isNotEmpty(nutMap) && StrUtil.isNotBlank(nutMap.getString("userId"))) {
allowActivityList.add(activity);
}
@@ -6,11 +6,14 @@ import cn.hutool.core.util.StrUtil;
import cn.hutool.http.useragent.UserAgent;
import cn.hutool.http.useragent.UserAgentUtil;
import cn.hutool.json.JSONObject;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.result.Result;
import com.budwk.app.sys.models.Sys_home_activity;
import com.budwk.app.sys.models.Sys_home_template;
import com.budwk.app.sys.models.Sys_menu;
import com.budwk.app.sys.services.SysMenuService;
import com.budwk.app.sys.services.SysUserService;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.activity.basic.models.ActivityUserScope;
import io.swagger.annotations.ApiOperation;
@@ -39,6 +42,7 @@ import org.nutz.mvc.annotation.Param;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
@@ -255,6 +259,26 @@ public class SysHomeController {
// return Result.success(hasPermisiionList);
}
/**
* 查询 PC 首页工作模板。
* 仅系统管理员和校工会管理员可见,返回前隐藏模板 SQL 和后端类路径等敏感字段。
*
* @return 首页工作模板列表
*/
@At
@SaCheckLogin
@ApiOperation("首页工作模板")
@Ok("json")
public Result listHomeTemplate() {
if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) {
return Result.success(Collections.emptyList());
}
FieldFilter fieldFilter = FieldFilter.locked(Sys_home_template.class, "allowUserSql|classPath");
List<Sys_home_template> list = Daos.ext(dao, fieldFilter).query(Sys_home_template.class,
Cnd.where("enable", "=", 1).asc("sortNo"));
return Result.success(list);
}
@At
@SaCheckLogin
@@ -338,8 +338,12 @@ public class SysUserController {
@At
@Ok("json")
@SaCheckLogin
public Result subAppMenus(@Param("appId") String appId) {
public Result subAppMenus(@Param("appId") String appId, @Param("platform") String platform) {
List<Sys_menu> menus = sysUserService.getMenus(SecurityUtil.getUserId());
if (StrUtil.isNotBlank(platform)) {
// Keep sub-application menus aligned with the requested client platform.
menus = menus.stream().filter(menu -> platform.equals(menu.getPlatform())).toList();
}
List<Sys_menu> list = SysMenuUtil.createTreeMenus(menus, appId);
if (ObjectUtil.isEmpty(list)) {
List<Sys_menu> self = menus.stream().filter(menu -> menu.getId().equals(appId)).toList();
@@ -75,6 +75,7 @@ public class SysV4AppsController {
m.href,
m.icon,
m.picIcon,
m.location,
sm.`name` AS moduleName,
CASE
WHEN f.userId IS NOT NULL THEN
@@ -97,6 +98,7 @@ public class SysV4AppsController {
cnd.and("m.id","in", menus.stream().map(Sys_menu::getId).toArray());
cnd.andEX("m.moduleId", "=", categoryId);
cnd.asc("m.location");
cnd.asc("m.id");
if (StrUtil.isNotBlank(keyword)) {
cnd.where().andLike("m.name", keyword);
}
@@ -143,7 +145,7 @@ public class SysV4AppsController {
@Ok("json:full")
@SaCheckLogin
@ApiOperation("获取收藏的应用")
public Result favorite(HttpServletRequest req) {
public Result favorite(@Param(value = "platform", df = "PC") String platform, HttpServletRequest req) {
Sql sql = Sqls.create("""
SELECT
m.id,
@@ -151,6 +153,7 @@ public class SysV4AppsController {
m.href,
m.icon,
m.picIcon,
m.location,
sm.`name` AS moduleName,
1 AS isFavorite
FROM
@@ -162,12 +165,15 @@ public class SysV4AppsController {
LEFT JOIN sys_module sm ON sm.id = m.moduleId
WHERE
f.userId = @userId
AND m.platform = 'PC'
AND m.platform = @platform
AND m.disabled = 0
ORDER BY
m.location ASC;
m.location ASC,
m.id ASC;
""");
sql.setParam("userId", SecurityUtil.getUserId());
// The H5 home sends platform=H5; defaulting to PC keeps the existing desktop caller compatible.
sql.setParam("platform", platform);
List<NutMap> list = sysMenuService.listMap(sql);
return Result.success(list);
}
@@ -55,6 +55,49 @@ public class HonorManageController {
}
/**
* Render the mobile honor display page used by the H5 home feature card.
*/
@At("/h5")
@SaCheckLogin
@Ok("beetl:/platform/zhghh5/dayofficework/honor/manage/index.html")
public void h5() {
}
/**
* Query honor records for the mobile honor display page.
*/
@At
@SaCheckLogin
public Result h5Data(HonorPageForm pageForm) {
Sql sql = Sqls.create("""
SELECT
h.id,
h.userName,
h.applyUnionName,
h.unionName,
h.grantDate,
h.files AS photoFiles,
prize.`name` AS prizeName,
type.`name` AS typeName,
type.queryTypeCode AS typeQueryTypeCode
FROM
`honor` h
LEFT JOIN honor_basic_settings prize ON prize.id = h.prize
LEFT JOIN honor_basic_settings type ON type.id = h.honorType
$condition
""");
Cnd cnd = Cnd.NEW();
Integer year = pageForm.getYear() == null ? Calendar.getInstance().get(Calendar.YEAR) : pageForm.getYear();
cnd.andEX("YEAR(h.grantDate)", "=", year);
cnd.andEX("h.honorType", "=", pageForm.getHonorType());
cnd.desc("h.grantDate");
sql.setCondition(cnd);
Pagination pagination = honorViService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql);
return Result.success(pagination);
}
@At
@SaCheckPermission("honor.manage")
public Result pageData(HonorPageForm pageForm) {
@@ -642,8 +642,10 @@ public class TourUserAssignmentServiceImpl extends BaseServiceImpl<TourUserAssig
throw new IllegalArgumentException("该人员已取消退出");
}
checkAssignmentCancelDeadline(assignment);
// 取消退出时删除对应事项台账,再标记人员分配表状态,防止后续移动端再次报名
// 取消退出时删除对应事项台账,并仅清空当前人员分配记录的线路快照,避免误处理同配置下其它分配记录
deleteAssignmentLedgers(assignment);
clearAssignmentMatterSnapshot(assignment.getId());
// 标记人员分配表取消状态,防止后续移动端再次报名。
update(Chain.make("cancelled", true),
Cnd.where(TourUserAssignment::getId, "=", assignment.getId()));
}
@@ -829,6 +831,27 @@ public class TourUserAssignmentServiceImpl extends BaseServiceImpl<TourUserAssig
dao().clear(TourLedger.class, Cnd.where(TourLedger::getId, "in", ledgerIds));
}
/**
* 清空当前人员分配记录中的线路相关快照,不删除人员分配表数据。
* 出行时间由 matterId 关联事项表展示,清空 matterId 后列表不再展示原出行时间。
*
* @param assignmentId 人员分配记录ID
*/
private void clearAssignmentMatterSnapshot(String assignmentId) {
if (StrUtil.isBlank(assignmentId)) {
return;
}
update(Chain.make("matterId", null)
.add("matterName", null)
.add("lineId", null)
.add("lineName", null)
.add("travelAgencyId", null)
.add("travelAgencyName", null)
.add("boardingPlace", null),
Cnd.where(TourUserAssignment::getId, "=", assignmentId)
.and(TourUserAssignment::getDelFlag, "=", false));
}
private void restoreCancelledAssignment(TourUserAssignment assignment) {
if (!Boolean.TRUE.equals(assignment.getCancelled())) {
throw new IllegalArgumentException("该人员未取消退出,无需恢复");
@@ -0,0 +1,383 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.learning.models.LearningCourse;
import com.budwk.app.zhgh.learning.models.LearningCourseOutline;
import com.budwk.app.zhgh.learning.models.LearningCourseType;
import com.budwk.app.zhgh.learning.models.LearningOutlineResource;
import com.budwk.app.zhgh.learning.models.LearningStudyRule;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.pager.Pager;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@IocBean
@Ok("json:full")
@Api("学习教育平台-章节内容管理")
@At("/platform/learning/chapter/content")
public class LearningChapterContentController {
@Inject
private Dao dao;
@At("")
@Ok("beetl:/platform/zhgh/Learning/chapterContent/index.html")
@SaCheckPermission("learning.chapter.content")
public void index() {
}
@At
@ApiOperation("课程列表")
@SaCheckPermission("learning.chapter.content")
public Result courses(String courseId, String courseName, String courseTypeId, String lecturerName, String status) {
Cnd cnd = Cnd.NEW();
cnd.andEX("id", "=", courseId);
cnd.and(Cnd.likeEX("courseName", courseName));
cnd.andEX("courseTypeId", "=", courseTypeId);
cnd.and(Cnd.likeEX("lecturerName", lecturerName));
cnd.andEX("status", "=", status);
cnd.desc("createdAt");
return Result.success(dao.query(LearningCourse.class, cnd, dao.createPager(1, 50)));
}
@At
@ApiOperation("课程类型")
@SaCheckPermission("learning.chapter.content")
public Result courseTypes() {
return Result.success(dao.query(LearningCourseType.class, Cnd.where("enabled", "=", true).asc("sortNum").asc("createdAt")));
}
@At
@ApiOperation("课程大纲树")
@SaCheckPermission("learning.chapter.content")
public Result tree(String courseId) {
if (StrUtil.isBlank(courseId)) {
return Result.success(List.of());
}
List<LearningCourseOutline> outlines = dao.query(LearningCourseOutline.class, Cnd.where("courseId", "=", courseId).asc("sortOrder").asc("createdAt"));
Map<String, List<NutMap>> childrenMap = new HashMap<>();
List<NutMap> roots = new ArrayList<>();
for (LearningCourseOutline outline : outlines) {
NutMap map = NutMap.NEW()
.addv("id", outline.getId())
.addv("courseId", outline.getCourseId())
.addv("courseName", outline.getCourseName())
.addv("parentId", outline.getParentId())
.addv("nodeType", outline.getNodeType())
.addv("title", outline.getTitle())
.addv("subtitle", outline.getSubtitle())
.addv("description", outline.getDescription())
.addv("sortOrder", outline.getSortOrder())
.addv("required", outline.getRequired())
.addv("status", outline.getStatus())
.addv("children", new ArrayList<>());
String parentId = StrUtil.blankToDefault(outline.getParentId(), "");
childrenMap.computeIfAbsent(parentId, k -> new ArrayList<>()).add(map);
}
roots.addAll(childrenMap.getOrDefault("", new ArrayList<>()));
appendChildren(roots, childrenMap);
return Result.success(roots);
}
private void appendChildren(List<NutMap> nodes, Map<String, List<NutMap>> childrenMap) {
for (NutMap node : nodes) {
List<NutMap> children = childrenMap.getOrDefault(node.getString("id"), new ArrayList<>());
node.put("children", children);
appendChildren(children, childrenMap);
}
}
@At
@ApiOperation("新增/编辑大纲节点")
@SaCheckPermission("learning.chapter.content")
@SLog(tag = "保存大纲节点", msg = "节点标题:${args[0].title}")
public Result saveNode(LearningCourseOutline outline) {
Result checkResult = checkNode(outline);
if (checkResult != null) {
return checkResult;
}
LearningCourse course = dao.fetch(LearningCourse.class, outline.getCourseId());
outline.setCourseName(course == null ? outline.getCourseName() : course.getCourseName());
if ("chapter".equals(outline.getNodeType())) {
outline.setParentId("");
}
if (outline.getRequired() == null) {
outline.setRequired(false);
}
if (StrUtil.isBlank(outline.getStatus())) {
outline.setStatus("enabled");
}
if (outline.getSortOrder() == null) {
outline.setSortOrder(nextNodeSort(outline.getCourseId(), outline.getParentId()));
}
if (StrUtil.isBlank(outline.getId())) {
dao.insert(outline);
} else {
dao.updateIgnoreNull(outline);
}
ensureRule(outline.getCourseId(), "outline", outline.getId(), outline.getRequired(), "enabled");
return Result.success();
}
@At
@ApiOperation("删除大纲节点")
@SaCheckPermission("learning.chapter.content")
@SLog(tag = "删除大纲节点", msg = "节点ID:${args[0]}")
public Result deleteNode(String id) {
if (StrUtil.isBlank(id)) {
return Result.error("请选择要删除的数据");
}
int childCount = dao.count(LearningCourseOutline.class, Cnd.where("parentId", "=", id));
int resourceCount = dao.count(LearningOutlineResource.class, Cnd.where("outlineId", "=", id));
if (childCount > 0 || resourceCount > 0) {
return Result.error("该节点下存在子节点或学习资料,请先删除后再操作");
}
dao.clear(LearningStudyRule.class, Cnd.where("targetId", "=", id).and("targetType", "=", "outline"));
dao.clear(LearningCourseOutline.class, Cnd.where("id", "=", id));
return Result.success();
}
@At
@ApiOperation("启用/禁用大纲节点")
@SaCheckPermission("learning.chapter.content")
public Result toggleNode(String id, String status) {
if (StrUtil.isBlank(id)) {
return Result.error("请选择要操作的数据");
}
dao.update(LearningCourseOutline.class, Chain.make("status", status), Cnd.where("id", "=", id));
dao.update(LearningStudyRule.class, Chain.make("status", status), Cnd.where("targetId", "=", id).and("targetType", "=", "outline"));
return Result.success();
}
@At
@ApiOperation("移动大纲节点")
@SaCheckPermission("learning.chapter.content")
public Result moveNode(String id, String direction) {
LearningCourseOutline current = dao.fetch(LearningCourseOutline.class, id);
if (current == null) {
return Result.error("节点不存在");
}
Cnd cnd = Cnd.where("courseId", "=", current.getCourseId()).and("parentId", "=", StrUtil.blankToDefault(current.getParentId(), ""));
if ("up".equals(direction)) {
cnd.and("sortOrder", "<", current.getSortOrder()).desc("sortOrder");
} else {
cnd.and("sortOrder", ">", current.getSortOrder()).asc("sortOrder");
}
LearningCourseOutline target = dao.fetch(LearningCourseOutline.class, cnd);
if (target == null) {
return Result.success();
}
Integer currentSort = current.getSortOrder();
dao.update(LearningCourseOutline.class, Chain.make("sortOrder", target.getSortOrder()), Cnd.where("id", "=", current.getId()));
dao.update(LearningCourseOutline.class, Chain.make("sortOrder", currentSort), Cnd.where("id", "=", target.getId()));
return Result.success();
}
@At
@ApiOperation("学习资料分页")
@SaCheckPermission("learning.chapter.content")
public Result resourcePage(PageForm pageForm, String outlineId) {
if (StrUtil.isBlank(outlineId)) {
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), 0, List.of()));
}
Cnd cnd = Cnd.where("outlineId", "=", outlineId);
String orderColumn = getResourceOrderColumn(pageForm.getPageOrderName());
if (StrUtil.isBlank(orderColumn)) {
cnd.asc("sortOrder").asc("createdAt");
} else if ("descending".equals(pageForm.getPageOrderBy())) {
cnd.desc(orderColumn);
} else {
cnd.asc(orderColumn);
}
int count = dao.count(LearningOutlineResource.class, Cnd.where("outlineId", "=", outlineId));
Pager pager = dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize());
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), count, dao.query(LearningOutlineResource.class, cnd, pager)));
}
@At
@ApiOperation("新增/编辑学习资料")
@SaCheckPermission("learning.chapter.content")
@SLog(tag = "保存学习资料", msg = "资料标题:${args[0].resourceTitle}")
public Result saveResource(LearningOutlineResource resource) {
Result checkResult = checkResource(resource);
if (checkResult != null) {
return checkResult;
}
if (resource.getRequired() == null) {
resource.setRequired(false);
}
if (resource.getAllowPreview() == null) {
resource.setAllowPreview(true);
}
if (resource.getAllowDownload() == null) {
resource.setAllowDownload(false);
}
if (StrUtil.isBlank(resource.getStatus())) {
resource.setStatus("enabled");
}
if (resource.getSortOrder() == null) {
resource.setSortOrder(nextResourceSort(resource.getOutlineId()));
}
if (StrUtil.isBlank(resource.getId())) {
dao.insert(resource);
} else {
dao.updateIgnoreNull(resource);
}
ensureRule(resource.getCourseId(), "resource", resource.getId(), resource.getRequired(), resource.getStatus());
return Result.success();
}
@At
@ApiOperation("删除学习资料")
@SaCheckPermission("learning.chapter.content")
@SLog(tag = "删除学习资料", msg = "资料ID:${args[0]}")
public Result deleteResource(String id) {
if (StrUtil.isBlank(id)) {
return Result.error("请选择要删除的数据");
}
dao.clear(LearningStudyRule.class, Cnd.where("targetId", "=", id).and("targetType", "=", "resource"));
dao.clear(LearningOutlineResource.class, Cnd.where("id", "=", id));
return Result.success();
}
@At
@ApiOperation("查询学习规则")
@SaCheckPermission("learning.chapter.content")
public Result getRule(String courseId, String targetType, String targetId) {
LearningStudyRule rule = dao.fetch(LearningStudyRule.class, Cnd.where("courseId", "=", courseId).and("targetType", "=", targetType).and("targetId", "=", targetId));
if (rule == null) {
rule = defaultRule(courseId, targetType, targetId, false, "enabled");
}
return Result.success(rule);
}
@At
@ApiOperation("保存学习规则")
@SaCheckPermission("learning.chapter.content")
public Result saveRule(LearningStudyRule rule) {
if (StrUtil.isBlank(rule.getCourseId()) || StrUtil.isBlank(rule.getTargetType()) || StrUtil.isBlank(rule.getTargetId())) {
return Result.error("规则对象不能为空");
}
if (StrUtil.isBlank(rule.getStatus())) {
rule.setStatus("enabled");
}
LearningStudyRule old = dao.fetch(LearningStudyRule.class, Cnd.where("courseId", "=", rule.getCourseId()).and("targetType", "=", rule.getTargetType()).and("targetId", "=", rule.getTargetId()));
if (old == null) {
dao.insert(rule);
} else {
rule.setId(old.getId());
dao.updateIgnoreNull(rule);
}
if ("outline".equals(rule.getTargetType())) {
dao.update(LearningCourseOutline.class, Chain.make("required", rule.getRequired()).add("status", rule.getStatus()), Cnd.where("id", "=", rule.getTargetId()));
}
if ("resource".equals(rule.getTargetType())) {
dao.update(LearningOutlineResource.class, Chain.make("required", rule.getRequired()).add("status", rule.getStatus()), Cnd.where("id", "=", rule.getTargetId()));
}
return Result.success();
}
private Result checkNode(LearningCourseOutline outline) {
if (outline == null || StrUtil.isBlank(outline.getCourseId())) {
return Result.error("请选择课程");
}
if (StrUtil.isBlank(outline.getNodeType())) {
return Result.error("请选择节点类型");
}
if (StrUtil.isBlank(outline.getTitle())) {
return Result.error("标题不能为空");
}
if ("section".equals(outline.getNodeType()) && StrUtil.isBlank(outline.getParentId())) {
return Result.error("新增节需要选择所属章");
}
return null;
}
private Result checkResource(LearningOutlineResource resource) {
if (resource == null || StrUtil.isBlank(resource.getCourseId()) || StrUtil.isBlank(resource.getOutlineId())) {
return Result.error("请选择章/节节点");
}
if (StrUtil.isBlank(resource.getResourceTitle())) {
return Result.error("资料标题不能为空");
}
if (StrUtil.isBlank(resource.getResourceType())) {
return Result.error("请选择资料类型");
}
if (StrUtil.isBlank(resource.getFileData())) {
return Result.error("请上传附件");
}
return null;
}
private Integer nextNodeSort(String courseId, String parentId) {
LearningCourseOutline outline = dao.fetch(LearningCourseOutline.class, Cnd.where("courseId", "=", courseId).and("parentId", "=", StrUtil.blankToDefault(parentId, "")).desc("sortOrder"));
return outline == null || outline.getSortOrder() == null ? 1 : outline.getSortOrder() + 1;
}
private Integer nextResourceSort(String outlineId) {
LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, Cnd.where("outlineId", "=", outlineId).desc("sortOrder"));
return resource == null || resource.getSortOrder() == null ? 1 : resource.getSortOrder() + 1;
}
private void ensureRule(String courseId, String targetType, String targetId, Boolean required, String status) {
LearningStudyRule rule = dao.fetch(LearningStudyRule.class, Cnd.where("courseId", "=", courseId).and("targetType", "=", targetType).and("targetId", "=", targetId));
if (rule == null) {
dao.insert(defaultRule(courseId, targetType, targetId, required, status));
} else {
dao.update(LearningStudyRule.class,
Chain.make("required", required != null && required).add("status", StrUtil.blankToDefault(status, "enabled")),
Cnd.where("id", "=", rule.getId()));
}
}
private LearningStudyRule defaultRule(String courseId, String targetType, String targetId, Boolean required, String status) {
LearningStudyRule rule = new LearningStudyRule();
rule.setCourseId(courseId);
rule.setTargetType(targetType);
rule.setTargetId(targetId);
rule.setRequired(required != null && required);
rule.setStudyMode("mixed");
rule.setCompletionRule("all_required_resource");
rule.setCompletePercent(90);
rule.setMinStudySeconds(0);
rule.setUnlockRule("free");
rule.setAllowSkip(false);
rule.setAllowDrag(true);
rule.setPauseCountTime(false);
rule.setHiddenCountTime(false);
rule.setInactiveCountTime(false);
rule.setStatus(StrUtil.blankToDefault(status, "enabled"));
return rule;
}
private String getResourceOrderColumn(String prop) {
Map<String, String> columns = new HashMap<>();
columns.put("resourceTitle", "resourceTitle");
columns.put("resourceType", "resourceType");
columns.put("fileExt", "fileExt");
columns.put("durationSeconds", "durationSeconds");
columns.put("sortOrder", "sortOrder");
columns.put("required", "required");
columns.put("allowPreview", "allowPreview");
columns.put("allowDownload", "allowDownload");
columns.put("status", "status");
return columns.get(prop);
}
}
@@ -0,0 +1,298 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.CharsetUtil;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.OfficePlusUtil;
import com.budwk.app.sys.enums.SysFileEngineTypeEnum;
import com.budwk.app.sys.models.Sys_file;
import com.budwk.app.sys.models.Sys_dict;
import com.budwk.app.sys.services.SysDictService;
import com.budwk.app.sys.utils.SysFileMinIoUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.learning.models.LearningCourseOutline;
import com.budwk.app.zhgh.learning.models.LearningCourseType;
import com.budwk.app.zhgh.learning.models.LearningOutlineResource;
import com.budwk.app.zhgh.learning.models.LearningStudyRecord;
import com.google.common.net.HttpHeaders;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Strings;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.util.List;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
@IocBean
@Ok("json:full")
@Api("学习教育平台-课程展示")
@At("/platform/learning/course/display")
public class LearningCourseDisplayController {
@Inject
private Dao dao;
@Inject
private SysDictService sysDictService;
@At("")
@Ok("beetl:/platform/zhgh/Learning/courseDisplay/index.html")
@SaCheckPermission(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
public void index() {
}
@At("/detail")
@Ok("beetl:/platform/zhgh/Learning/courseDisplay/detail.html")
@SaCheckPermission(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
public void detail() {
}
@At("/study")
@Ok("beetl:/platform/zhgh/Learning/courseDisplay/study.html")
@SaCheckPermission(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
public void study() {
}
@At
@ApiOperation("课程展示列表")
@SaCheckPermission(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
public Result pageData(PageForm pageForm, String keyword, String courseTypeId, String recommendFlag) {
Cnd cnd = Cnd.where("c.status", "=", "published");
if (StrUtil.isNotBlank(keyword)) {
cnd.and(Cnd.exps("c.courseName", "like", "%" + keyword + "%")
.or("c.courseIntro", "like", "%" + keyword + "%"));
}
cnd.andEX("c.courseTypeId", "=", courseTypeId);
if (StrUtil.isNotBlank(recommendFlag)) {
cnd.and("c.recommendFlags", "like", "%" + recommendFlag + "%");
}
Sql countSql = Sqls.create("""
SELECT COUNT(1)
FROM learning_course c
LEFT JOIN learning_course_type t ON t.id = c.courseTypeId
$condition
""");
countSql.setCondition(cnd);
countSql.setCallback(Sqls.callback.integer());
dao.execute(countSql);
int count = countSql.getInt();
Sql listSql = Sqls.create("""
SELECT c.id, c.courseName, c.courseIntro, c.startTime, c.endTime, c.openType, c.cover,
c.recommendFlags, c.courseTypeId, c.lecturerName, c.sortNum, t.typeName AS courseTypeName
FROM learning_course c
LEFT JOIN learning_course_type t ON t.id = c.courseTypeId
$condition
ORDER BY c.sortNum ASC, c.createdAt DESC
""");
listSql.setCondition(cnd);
listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
dao.execute(listSql);
List<NutMap> rows = listSql.getList(NutMap.class);
rows.forEach(row -> {
Object startTime = row.get("startTime");
Object endTime = row.get("endTime");
row.put("startTimeText", startTime == null ? "" : DateUtil.formatDateTime(DateUtil.date((java.util.Date) startTime)));
row.put("endTimeText", endTime == null ? "" : DateUtil.formatDateTime(DateUtil.date((java.util.Date) endTime)));
});
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), count, rows));
}
@At
@ApiOperation("课程学习基础信息")
@SaCheckPermission(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
public Result courseInfo(String id) {
if (StrUtil.isBlank(id)) {
return Result.error("请选择课程");
}
Sql sql = Sqls.create("""
SELECT c.id, c.courseName, c.courseIntro, c.startTime, c.endTime, c.openType, c.cover,
c.recommendFlags, c.lecturerName, t.typeName AS courseTypeName
FROM learning_course c
LEFT JOIN learning_course_type t ON t.id = c.courseTypeId
WHERE c.id = @id
""");
sql.params().set("id", id);
sql.setCallback(Sqls.callback.map());
dao.execute(sql);
NutMap course = sql.getObject(NutMap.class);
if (course == null) {
return Result.error("课程不存在");
}
Object startTime = course.get("startTime");
Object endTime = course.get("endTime");
course.put("startTimeText", startTime == null ? "" : DateUtil.formatDateTime(DateUtil.date((java.util.Date) startTime)));
course.put("endTimeText", endTime == null ? "" : DateUtil.formatDateTime(DateUtil.date((java.util.Date) endTime)));
return Result.success(course);
}
@At
@ApiOperation("课程学习安排")
@SaCheckPermission(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
public Result studyTree(String courseId) {
if (StrUtil.isBlank(courseId)) {
return Result.success(List.of());
}
List<LearningCourseOutline> outlines = dao.query(LearningCourseOutline.class, Cnd.where("courseId", "=", courseId).and("status", "=", "enabled").asc("sortOrder").asc("createdAt"));
List<LearningOutlineResource> resources = dao.query(LearningOutlineResource.class, Cnd.where("courseId", "=", courseId).and("status", "=", "enabled").asc("sortOrder").asc("createdAt"));
List<LearningStudyRecord> records = dao.query(LearningStudyRecord.class, Cnd.where("courseId", "=", courseId).and("userId", "=", SecurityUtil.getUserId()));
Map<String, List<NutMap>> childrenMap = new HashMap<>();
List<NutMap> roots = new ArrayList<>();
Map<String, LearningStudyRecord> recordMap = new HashMap<>();
for (LearningStudyRecord record : records) {
recordMap.put(record.getOutlineId(), record);
}
Map<String, NutMap> outlineNodeMap = new HashMap<>();
for (LearningCourseOutline outline : outlines) {
List<NutMap> children = new ArrayList<>();
NutMap node = NutMap.NEW()
.addv("id", outline.getId())
.addv("type", "outline")
.addv("nodeType", outline.getNodeType())
.addv("title", outline.getTitle())
.addv("required", outline.getRequired())
.addv("progressPercent", recordMap.containsKey(outline.getId()) ? recordMap.get(outline.getId()).getProgressPercent() : 0)
.addv("studySeconds", recordMap.containsKey(outline.getId()) ? recordMap.get(outline.getId()).getStudySeconds() : 0)
.addv("lastPositionSeconds", recordMap.containsKey(outline.getId()) ? value(recordMap.get(outline.getId()).getLastPositionSeconds()) : 0)
.addv("completeStatus", recordMap.containsKey(outline.getId()) ? recordMap.get(outline.getId()).getCompleteStatus() : "not_started")
.addv("children", children);
outlineNodeMap.put(outline.getId(), node);
childrenMap.put(outline.getId(), children);
}
for (LearningCourseOutline outline : outlines) {
NutMap node = outlineNodeMap.get(outline.getId());
if (StrUtil.isBlank(outline.getParentId())) {
roots.add(node);
} else {
childrenMap.computeIfAbsent(outline.getParentId(), key -> new ArrayList<>()).add(node);
}
}
for (LearningOutlineResource resource : resources) {
NutMap node = NutMap.NEW()
.addv("id", resource.getId())
.addv("type", "resource")
.addv("outlineId", resource.getOutlineId())
.addv("title", resource.getResourceTitle())
.addv("resourceType", resource.getResourceType())
.addv("fileExt", resource.getFileExt())
.addv("fileData", resource.getFileData())
.addv("durationSeconds", resource.getDurationSeconds())
.addv("required", resource.getRequired())
.addv("progressPercent", recordMap.containsKey(resource.getOutlineId()) ? recordMap.get(resource.getOutlineId()).getProgressPercent() : 0)
.addv("studySeconds", recordMap.containsKey(resource.getOutlineId()) ? recordMap.get(resource.getOutlineId()).getStudySeconds() : 0)
.addv("lastPositionSeconds", recordMap.containsKey(resource.getOutlineId()) ? value(recordMap.get(resource.getOutlineId()).getLastPositionSeconds()) : 0)
.addv("completeStatus", recordMap.containsKey(resource.getOutlineId()) ? recordMap.get(resource.getOutlineId()).getCompleteStatus() : "not_started");
childrenMap.computeIfAbsent(resource.getOutlineId(), key -> new ArrayList<>()).add(node);
}
return Result.success(roots);
}
@At
@Ok("void")
@ApiOperation("PDF内联预览")
@SaCheckPermission(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
public void pdfPreview(String id, HttpServletResponse response) throws IOException {
if (StrUtil.isBlank(id)) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "文件ID不能为空");
return;
}
Sys_file file = dao.fetch(Sys_file.class, id);
if (file == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "文件不存在");
return;
}
byte[] bytes;
if (SysFileEngineTypeEnum.MINIO.getValue().equals(file.getEngine())) {
bytes = SysFileMinIoUtil.getFileBytes(file.getBucket(), file.getStoragePath());
} else {
File localFile = FileUtil.file(file.getStoragePath());
if (!FileUtil.exist(localFile)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "文件不存在");
return;
}
bytes = IoUtil.readBytes(FileUtil.getInputStream(localFile));
}
if (!"pdf".equalsIgnoreCase(file.getSuffix())) {
File sourceFile = File.createTempFile("learning_preview_origin", "." + file.getSuffix());
File pdfFile = File.createTempFile("learning_preview", ".pdf");
try {
Files.write(sourceFile.toPath(), bytes, StandardOpenOption.WRITE);
OfficePlusUtil.convert(sourceFile.getPath(), pdfFile.getPath());
if (!FileUtil.exist(pdfFile) || pdfFile.length() == 0) {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "文件转换PDF失败");
return;
}
bytes = IoUtil.readBytes(FileUtil.getInputStream(pdfFile));
} finally {
FileUtil.del(sourceFile);
FileUtil.del(pdfFile);
}
}
String fileName = StrUtil.blankToDefault(FileUtil.mainName(file.getName()), "preview") + ".pdf";
String encodedName = URLEncoder.encode(fileName, CharsetUtil.UTF_8).replace("+", "%20");
response.setContentType("application/pdf");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"preview.pdf\"; filename*=UTF-8''" + encodedName);
response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(bytes.length));
try (OutputStream out = response.getOutputStream()) {
out.write(bytes);
}
}
@At
@ApiOperation("启用课程类型")
@SaCheckPermission(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
public Result courseTypes() {
return Result.success(dao.query(LearningCourseType.class, Cnd.where("enabled", "=", true).asc("sortNum").asc("createdAt")));
}
@At
@ApiOperation("推荐标识")
@SaCheckPermission(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
public Result recommendOptions() {
Sys_dict root = sysDictService.fetch(Cnd.where("code", "=", "学习教育").or("name", "=", "学习教育"));
if (root == null) {
return Result.success(List.of());
}
List<Sys_dict> firstLevel = sysDictService.query(Cnd.where("parentId", "=", Strings.sNull(root.getId())).and("disabled", "=", false).asc("location"));
Sys_dict group = firstLevel.stream()
.filter(dict -> "推荐标识".equals(dict.getName()) || "推荐标识".equals(dict.getCode()))
.findFirst()
.orElse(null);
if (group == null) {
return Result.success(firstLevel);
}
List<Sys_dict> children = sysDictService.query(Cnd.where("parentId", "=", Strings.sNull(group.getId())).and("disabled", "=", false).asc("location"));
return Result.success(children.isEmpty() ? firstLevel : children);
}
private int value(Integer value) {
return value == null ? 0 : value;
}
}
@@ -0,0 +1,232 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.sys.models.Sys_dict;
import com.budwk.app.sys.services.SysDictService;
import com.budwk.app.zhgh.learning.models.LearningCourse;
import com.budwk.app.zhgh.learning.models.LearningCourseOutline;
import com.budwk.app.zhgh.learning.models.LearningCourseType;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Strings;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@IocBean
@Ok("json:full")
@Api("学习教育平台-课程管理")
@At("/platform/learning/course/manage")
public class LearningCourseManageController {
@Inject
private Dao dao;
@Inject
private SysDictService sysDictService;
@At("")
@Ok("beetl:/platform/zhgh/Learning/courseManage/index.html")
@SaCheckPermission("learning.course.manage")
public void index() {
}
@At
@ApiOperation("课程列表")
@SaCheckPermission("learning.course.manage")
public Result pageData(PageForm pageForm,
String courseName,
String courseTypeId,
String startTime,
String endTime,
String lecturerName,
String status,
String recommendFlag) {
Cnd cnd = Cnd.NEW();
cnd.and(Cnd.likeEX("c.courseName", courseName));
cnd.andEX("c.courseTypeId", "=", courseTypeId);
cnd.and(Cnd.likeEX("c.lecturerName", lecturerName));
cnd.andEX("c.status", "=", status);
if (StrUtil.isNotBlank(recommendFlag)) {
cnd.and("c.recommendFlags", "like", "%" + recommendFlag + "%");
}
if (StrUtil.isNotBlank(startTime)) {
cnd.and("c.startTime", ">=", DateUtil.parse(startTime));
}
if (StrUtil.isNotBlank(endTime)) {
cnd.and("c.endTime", "<=", DateUtil.parse(endTime));
}
String orderColumn = getOrderColumn(pageForm.getPageOrderName());
String orderBy = "descending".equals(pageForm.getPageOrderBy()) ? "desc" : "asc";
if (StrUtil.isBlank(orderColumn)) {
orderColumn = "c.sortNum";
orderBy = "asc";
}
Sql countSql = Sqls.create("""
SELECT COUNT(1)
FROM learning_course c
LEFT JOIN learning_course_type t ON t.id = c.courseTypeId
$condition
""");
countSql.setCondition(cnd);
countSql.setCallback(Sqls.callback.integer());
dao.execute(countSql);
int count = countSql.getInt();
Sql listSql = Sqls.create("""
SELECT c.*, t.typeName AS courseTypeName
FROM learning_course c
LEFT JOIN learning_course_type t ON t.id = c.courseTypeId
$condition
ORDER BY $orderColumn $orderBy
""");
listSql.setCondition(cnd);
listSql.setVar("orderColumn", orderColumn);
listSql.setVar("orderBy", orderBy);
listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
dao.execute(listSql);
Pagination<NutMap> pagination = new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), count, listSql.getList(NutMap.class));
return Result.success(pagination);
}
@At
@ApiOperation("新增课程")
@SaCheckPermission("learning.course.manage")
@SLog(tag = "新增课程", msg = "课程名称:${args[0].courseName}")
public Result doAdd(LearningCourse course) {
Result checkResult = checkCourse(course, null);
if (checkResult != null) {
return checkResult;
}
if (StrUtil.isBlank(course.getStatus())) {
course.setStatus("draft");
}
dao.insert(course);
return Result.success();
}
@At
@ApiOperation("编辑课程")
@SaCheckPermission("learning.course.manage")
@SLog(tag = "编辑课程", msg = "课程ID:${args[0].id}")
public Result doEdit(LearningCourse course) {
if (StrUtil.isBlank(course.getId())) {
return Result.error("请选择要编辑的数据");
}
Result checkResult = checkCourse(course, course.getId());
if (checkResult != null) {
return checkResult;
}
dao.updateIgnoreNull(course);
return Result.success();
}
@At
@ApiOperation("删除课程")
@SaCheckPermission("learning.course.manage")
@SLog(tag = "删除课程", msg = "课程ID:${args[0]}")
public Result doDelete(String id) {
if (StrUtil.isBlank(id)) {
return Result.error("请选择要删除的数据");
}
int outlineCount = dao.count(LearningCourseOutline.class, Cnd.where("courseId", "=", id));
if (outlineCount > 0) {
return Result.error("该课程存在章节内容,请先删除章节内容后再删除课程");
}
dao.clear(LearningCourse.class, Cnd.where("id", "=", id));
return Result.success();
}
@At
@ApiOperation("启用课程类型")
@SaCheckPermission("learning.course.manage")
public Result courseTypes() {
return Result.success(dao.query(LearningCourseType.class, Cnd.where("enabled", "=", true).asc("sortNum").asc("createdAt")));
}
@At
@ApiOperation("学习教育数据字典")
@SaCheckPermission("learning.course.manage")
public Result learningDictOptions(String name) {
Sys_dict root = sysDictService.fetch(Cnd.where("code", "=", "学习教育").or("name", "=", "学习教育"));
if (root == null) {
return Result.success(List.of());
}
List<Sys_dict> firstLevel = sysDictService.query(Cnd.where("parentId", "=", Strings.sNull(root.getId())).and("disabled", "=", false).asc("location"));
if (StrUtil.isBlank(name)) {
return Result.success(firstLevel);
}
Sys_dict group = firstLevel.stream()
.filter(dict -> name.equals(dict.getName()) || name.equals(dict.getCode()))
.findFirst()
.orElse(null);
if (group == null) {
return Result.success(firstLevel);
}
List<Sys_dict> children = sysDictService.query(Cnd.where("parentId", "=", Strings.sNull(group.getId())).and("disabled", "=", false).asc("location"));
return Result.success(children.isEmpty() ? firstLevel : children);
}
private Result checkCourse(LearningCourse course, String excludeId) {
if (course == null || StrUtil.isBlank(course.getCourseName())) {
return Result.error("课程名称不能为空");
}
if (StrUtil.isBlank(course.getCourseTypeId())) {
return Result.error("请选择课程类型");
}
if (StrUtil.isBlank(course.getLecturerName())) {
return Result.error("授课讲师不能为空");
}
if (course.getSortNum() == null) {
return Result.error("排序编码不能为空");
}
if (!"long_term".equals(course.getOpenType()) && (course.getStartTime() == null || course.getEndTime() == null)) {
return Result.error("请选择开课时间");
}
Cnd cnd = Cnd.where("courseName", "=", course.getCourseName().trim());
if (StrUtil.isNotBlank(excludeId)) {
cnd.and("id", "!=", excludeId);
}
if (dao.count(LearningCourse.class, cnd) > 0) {
return Result.error("课程名称已存在");
}
course.setCourseName(course.getCourseName().trim());
if ("long_term".equals(course.getOpenType())) {
course.setStartTime(null);
course.setEndTime(null);
}
return null;
}
private String getOrderColumn(String prop) {
Map<String, String> columns = new HashMap<>();
columns.put("courseName", "c.courseName");
columns.put("courseTypeName", "t.typeName");
columns.put("lecturerName", "c.lecturerName");
columns.put("startTime", "c.startTime");
columns.put("status", "c.status");
columns.put("recommendFlags", "c.recommendFlags");
columns.put("sortNum", "c.sortNum");
return columns.get(prop);
}
}
@@ -0,0 +1,110 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.learning.models.LearningCourseType;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.pager.Pager;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
@IocBean
@Ok("json:full")
@Api("学习教育平台-课程类型设置")
@At("/platform/learning/course/type")
public class LearningCourseTypeController {
@Inject
private Dao dao;
@At("")
@Ok("beetl:/platform/zhgh/Learning/courseType/index.html")
@SaCheckPermission("learning.course.type")
public void index() {
}
@At
@ApiOperation("课程类型列表")
@SaCheckPermission("learning.course.type")
public Result pageData(PageForm pageForm, @Param("typeName") String typeName) {
Cnd cnd = Cnd.NEW();
cnd.and(Cnd.likeEX("typeName", typeName));
cnd.asc("sortNum").asc("createdAt");
int count = dao.count(LearningCourseType.class, cnd);
Pager pager = dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize());
Pagination<LearningCourseType> pagination = new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), count, dao.query(LearningCourseType.class, cnd, pager));
return Result.success(pagination);
}
@At
@ApiOperation("新增课程类型")
@SaCheckPermission("learning.course.type")
@SLog(tag = "新增课程类型", msg = "课程类型名称:${args[0].typeName}")
public Result doAdd(LearningCourseType courseType) {
Result checkResult = checkCourseType(courseType, null);
if (checkResult != null) {
return checkResult;
}
if (courseType.getEnabled() == null) {
courseType.setEnabled(true);
}
dao.insert(courseType);
return Result.success();
}
@At
@ApiOperation("编辑课程类型")
@SaCheckPermission("learning.course.type")
@SLog(tag = "编辑课程类型", msg = "课程类型ID:${args[0].id}")
public Result doEdit(LearningCourseType courseType) {
if (StrUtil.isBlank(courseType.getId())) {
return Result.error("请选择要编辑的数据");
}
Result checkResult = checkCourseType(courseType, courseType.getId());
if (checkResult != null) {
return checkResult;
}
dao.updateIgnoreNull(courseType);
return Result.success();
}
@At
@ApiOperation("删除课程类型")
@SaCheckPermission("learning.course.type")
@SLog(tag = "删除课程类型", msg = "课程类型ID:${args[0]}")
public Result doDelete(String id) {
if (StrUtil.isBlank(id)) {
return Result.error("请选择要删除的数据");
}
dao.clear(LearningCourseType.class, Cnd.where("id", "=", id));
return Result.success();
}
private Result checkCourseType(LearningCourseType courseType, String excludeId) {
if (courseType == null || StrUtil.isBlank(courseType.getTypeName())) {
return Result.error("课程类型名称不能为空");
}
if (courseType.getSortNum() == null) {
return Result.error("排序编号不能为空");
}
Cnd cnd = Cnd.where("typeName", "=", courseType.getTypeName().trim());
if (StrUtil.isNotBlank(excludeId)) {
cnd.and("id", "!=", excludeId);
}
if (dao.count(LearningCourseType.class, cnd) > 0) {
return Result.error("课程类型名称已存在");
}
courseType.setTypeName(courseType.getTypeName().trim());
return null;
}
}
@@ -0,0 +1,47 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
@IocBean
@At("/platform/learning")
public class LearningPlatformController {
@At("/activity/manage")
@Ok("beetl:/platform/zhgh/Learning/activityManage/index.html")
@SaCheckPermission("learning.activity.manage")
public void activityManage() {
}
@At("/my/record")
@Ok("beetl:/platform/zhgh/Learning/myRecord/index.html")
@SaCheckPermission("learning.my.record")
public void myRecord() {
}
@At("/my/record/h5")
@Ok("beetl:/platform/zhghh5/learning/myRecord/index.html")
@SaCheckPermission("h5.learning.my.record")
public void myRecordH5() {
}
@At("/statistics")
@Ok("beetl:/platform/zhgh/Learning/statistics/index.html")
@SaCheckPermission("learning.statistics")
public void statistics() {
}
@At("/course/h5")
@Ok("beetl:/platform/zhghh5/learning/course/index.html")
@SaCheckPermission("h5.learning.course.display")
public void courseH5() {
}
@At("/course/h5/study")
@Ok("beetl:/platform/zhghh5/learning/course/study.html")
@SaCheckPermission("h5.learning.course.display")
public void courseH5Study() {
}
}
@@ -0,0 +1,330 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.PageUtil;
import com.budwk.app.sys.models.Sys_union;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.learning.models.LearningCourse;
import com.budwk.app.zhgh.learning.param.LearningStatisticsPageForm;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.dao.util.cri.Static;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@IocBean
@Ok("json:full")
@Api("学习教育平台-学习统计")
@At("/platform/learning/statistics")
public class LearningStatisticsController {
@Inject
private Dao dao;
@At("")
@Ok("beetl:/platform/zhgh/Learning/statistics/index.html")
@SaCheckPermission("learning.statistics")
public void index() {
}
@At
@ApiOperation("课程统计")
@SaCheckPermission("learning.statistics")
public Result coursePageData(LearningStatisticsPageForm pageForm) {
Cnd cnd = courseCondition(pageForm);
Sql countSql = Sqls.create("""
SELECT COUNT(1)
FROM learning_course c
$condition
""");
countSql.setCondition(cnd);
countSql.setCallback(Sqls.callback.integer());
dao.execute(countSql);
Sql listSql = Sqls.create("""
SELECT
c.id AS courseId,
c.courseName,
IFNULL(COUNT(uc.userId), 0) AS learnerCount,
IFNULL(SUM(CASE WHEN target.targetCount > 0 AND uc.completedOutlineCount >= target.targetCount THEN 1 ELSE 0 END), 0) AS completedCount,
IFNULL(ROUND(AVG(uc.studySeconds)), 0) AS avgStudySeconds
FROM learning_course c
LEFT JOIN (
SELECT
courseId,
CASE
WHEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) > 0
THEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END)
ELSE COUNT(1)
END AS targetCount,
SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount
FROM learning_course_outline
WHERE status = 'enabled'
GROUP BY courseId
) target ON target.courseId = c.id
LEFT JOIN (
SELECT
r.courseId,
r.userId,
SUM(IFNULL(r.studySeconds, 0)) AS studySeconds,
COUNT(DISTINCT CASE
WHEN r.completeStatus = 'completed'
AND ((target.requiredCount > 0 AND o.required = 1) OR target.requiredCount = 0)
THEN r.outlineId
ELSE NULL
END) AS completedOutlineCount
FROM learning_study_record r
JOIN learning_course_outline o ON o.id = r.outlineId AND o.courseId = r.courseId AND o.status = 'enabled'
JOIN (
SELECT
courseId,
CASE
WHEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) > 0
THEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END)
ELSE COUNT(1)
END AS targetCount,
SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount
FROM learning_course_outline
WHERE status = 'enabled'
GROUP BY courseId
) target ON target.courseId = r.courseId
$recordCondition
GROUP BY r.courseId, r.userId
) uc ON uc.courseId = c.id
$condition
GROUP BY c.id, c.courseName
ORDER BY $orderColumn $orderBy
""");
listSql.setCondition(cnd);
listSql.setVar("recordCondition", new Static(recordWhere(pageForm)));
setOrder(listSql, pageForm, "course");
listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
dao.execute(listSql);
List<NutMap> rows = listSql.getList(NutMap.class);
rows.forEach(row -> row.put("avgStudyTimeText", formatStudySeconds(row.getInt("avgStudySeconds", 0))));
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), countSql.getInt(), rows));
}
@At
@ApiOperation("分工会统计")
@SaCheckPermission("learning.statistics")
public Result unionPageData(LearningStatisticsPageForm pageForm) {
Cnd cnd = unionCondition(pageForm);
Sql countSql = Sqls.create("""
SELECT COUNT(1)
FROM learning_course c
JOIN (
SELECT r.courseId, r.unionId
FROM learning_study_record r
$recordCondition
GROUP BY r.courseId, r.unionId
) uc ON uc.courseId = c.id
LEFT JOIN sys_union un ON un.id = uc.unionId
$condition
""");
countSql.setCondition(cnd);
countSql.setVar("recordCondition", new Static(recordWhere(pageForm)));
countSql.setCallback(Sqls.callback.integer());
dao.execute(countSql);
Sql listSql = Sqls.create("""
SELECT
c.id AS courseId,
c.courseName,
un.id AS unionId,
IFNULL(un.name, uc.unionName) AS unionName,
COUNT(uc.userId) AS learnerCount,
IFNULL(SUM(uc.studySeconds), 0) AS studySeconds,
IFNULL(SUM(CASE WHEN target.targetCount > 0 AND uc.completedOutlineCount >= target.targetCount THEN 1 ELSE 0 END), 0) AS completedCount
FROM learning_course c
JOIN (
SELECT
r.courseId,
r.userId,
r.unionId,
MAX(r.unionName) AS unionName,
SUM(IFNULL(r.studySeconds, 0)) AS studySeconds,
COUNT(DISTINCT CASE
WHEN r.completeStatus = 'completed'
AND ((target.requiredCount > 0 AND o.required = 1) OR target.requiredCount = 0)
THEN r.outlineId
ELSE NULL
END) AS completedOutlineCount
FROM learning_study_record r
JOIN learning_course_outline o ON o.id = r.outlineId AND o.courseId = r.courseId AND o.status = 'enabled'
JOIN (
SELECT
courseId,
CASE
WHEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) > 0
THEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END)
ELSE COUNT(1)
END AS targetCount,
SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount
FROM learning_course_outline
WHERE status = 'enabled'
GROUP BY courseId
) target ON target.courseId = r.courseId
$recordCondition
GROUP BY r.courseId, r.userId, r.unionId
) uc ON uc.courseId = c.id
LEFT JOIN sys_union un ON un.id = uc.unionId
LEFT JOIN (
SELECT
courseId,
CASE
WHEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) > 0
THEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END)
ELSE COUNT(1)
END AS targetCount,
SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount
FROM learning_course_outline
WHERE status = 'enabled'
GROUP BY courseId
) target ON target.courseId = c.id
$condition
GROUP BY c.id, c.courseName, uc.unionId, un.id, un.name
ORDER BY $orderColumn $orderBy
""");
listSql.setCondition(cnd);
listSql.setVar("recordCondition", new Static(recordWhere(pageForm)));
setOrder(listSql, pageForm, "union");
listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
dao.execute(listSql);
List<NutMap> rows = listSql.getList(NutMap.class);
rows.forEach(row -> {
int learnerCount = row.getInt("learnerCount", 0);
int completedCount = row.getInt("completedCount", 0);
row.put("studyTimeText", formatStudySeconds(row.getInt("studySeconds", 0)));
row.put("completeRate", learnerCount == 0 ? "0%" : BigDecimal.valueOf(completedCount * 100.0 / learnerCount).setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() + "%");
});
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), countSql.getInt(), rows));
}
@At
@ApiOperation("可选课程")
@SaCheckPermission("learning.statistics")
public Result courseOptions(String courseName) {
Cnd cnd = Cnd.NEW();
cnd.and(Cnd.likeEX("courseName", courseName));
cnd.desc("createdAt").asc("sortNum");
return Result.success(dao.query(LearningCourse.class, cnd, dao.createPager(1, 50)));
}
@At
@ApiOperation("可选分工会")
@SaCheckPermission("learning.statistics")
public Result unionOptions() {
if (hasSchoolScope()) {
return Result.success(dao.query(Sys_union.class, Cnd.NEW().asc("unionCode").asc("name")));
}
if (hasBranchScope()) {
return Result.success(dao.query(Sys_union.class, Cnd.where("id", "=", SecurityUtil.getUnionId())));
}
return Result.success(List.of());
}
private Cnd courseCondition(LearningStatisticsPageForm pageForm) {
Cnd cnd = Cnd.NEW();
cnd.andEX("c.id", "=", pageForm.getCourseId());
cnd.and(Cnd.likeEX("c.courseName", pageForm.getCourseName()));
return cnd;
}
private Cnd unionCondition(LearningStatisticsPageForm pageForm) {
Cnd cnd = courseCondition(pageForm);
cnd.andEX("uc.unionId", "=", pageForm.getUnionId());
return cnd;
}
private String recordWhere(LearningStatisticsPageForm pageForm) {
StringBuilder where = new StringBuilder("WHERE 1 = 1");
if (hasBranchScope()) {
where.append(" AND r.unionId = '").append(escapeSql(SecurityUtil.getUnionId())).append("'");
} else if (!hasSchoolScope()) {
where.append(" AND r.userId = '").append(escapeSql(SecurityUtil.getUserId())).append("'");
}
if (StrUtil.isNotBlank(pageForm.getUnionId())) {
where.append(" AND r.unionId = '").append(escapeSql(pageForm.getUnionId())).append("'");
}
return where.toString();
}
private void setOrder(Sql sql, LearningStatisticsPageForm pageForm, String type) {
String orderColumn = "union".equals(type) ? getUnionOrderColumn(pageForm.getPageOrderName()) : getCourseOrderColumn(pageForm.getPageOrderName());
String orderBy = PageUtil.getOrder(pageForm.getPageOrderBy());
if (StrUtil.isBlank(orderColumn)) {
orderColumn = "union".equals(type) ? "c.courseName ASC, un.unionCode" : "c.sortNum ASC, c.createdAt";
orderBy = "DESC";
} else if (StrUtil.isBlank(orderBy)) {
orderBy = "ASC";
}
sql.setVar("orderColumn", new Static(orderColumn));
sql.setVar("orderBy", new Static(orderBy));
}
private String getCourseOrderColumn(String prop) {
Map<String, String> columns = new HashMap<>();
columns.put("courseName", "c.courseName");
columns.put("learnerCount", "learnerCount");
columns.put("completedCount", "completedCount");
columns.put("avgStudySeconds", "avgStudySeconds");
return columns.get(prop);
}
private String getUnionOrderColumn(String prop) {
Map<String, String> columns = new HashMap<>();
columns.put("courseName", "c.courseName");
columns.put("unionName", "unionName");
columns.put("learnerCount", "learnerCount");
columns.put("studySeconds", "studySeconds");
columns.put("completeRate", "completedCount");
return columns.get(prop);
}
private boolean hasSchoolScope() {
return AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name());
}
private boolean hasBranchScope() {
return AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_ADMIN.name());
}
private String escapeSql(String value) {
return value == null ? "" : value.replace("'", "''");
}
private String formatStudySeconds(Integer seconds) {
int value = seconds == null ? 0 : seconds;
int hour = value / 3600;
int minute = value % 3600 / 60;
int second = value % 60;
if (hour > 0) {
return hour + "小时" + minute + "" + second + "";
}
if (minute > 0) {
return minute + "" + second + "";
}
return second + "";
}
}
@@ -0,0 +1,494 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.PageUtil;
import com.budwk.app.sys.models.Sys_union;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.learning.models.LearningCourse;
import com.budwk.app.zhgh.learning.models.LearningCourseOutline;
import com.budwk.app.zhgh.learning.models.LearningOutlineResource;
import com.budwk.app.zhgh.learning.models.LearningStudyRecord;
import com.budwk.app.zhgh.learning.models.LearningStudyRule;
import com.budwk.app.zhgh.learning.models.LearningStudySegment;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.dao.util.cri.SqlExpressionGroup;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@IocBean
@Ok("json:full")
@Api("学习教育平台-学习记录")
@At("/platform/learning/study/record")
public class LearningStudyRecordController {
private static final int MAX_HEARTBEAT_SECONDS = 30;
@Inject
private Dao dao;
@At
@ApiOperation("学习记录分页")
@SaCheckPermission(value = {"learning.my.record", "h5.learning.my.record"}, mode = SaMode.OR)
public Result pageData(PageForm pageForm,
@Param("keyword") String keyword,
@Param("unionId") String unionId,
@Param("courseId") String courseId,
@Param("completeStatus") String completeStatus) {
Cnd cnd = Cnd.NEW();
if (StrUtil.isNotBlank(keyword)) {
SqlExpressionGroup seg = new SqlExpressionGroup();
seg.or("r.loginName", "like", "%" + keyword + "%");
seg.or("r.userName", "like", "%" + keyword + "%");
cnd.and(seg);
}
cnd.andEX("r.unionId", "=", unionId);
cnd.andEX("r.courseId", "=", courseId);
cnd.andEX("r.completeStatus", "=", completeStatus);
appendScope(cnd);
String orderColumn = getOrderColumn(pageForm.getPageOrderName());
if (StrUtil.isNotBlank(orderColumn) && StrUtil.isNotBlank(pageForm.getPageOrderBy())) {
cnd.orderBy(orderColumn, PageUtil.getOrder(pageForm.getPageOrderBy()));
} else {
cnd.desc("r.latestStudyTime");
}
Sql countSql = Sqls.create("SELECT COUNT(1) FROM learning_study_record r $condition");
countSql.setCondition(cnd);
countSql.setCallback(Sqls.callback.integer());
dao.execute(countSql);
Sql listSql = Sqls.create("""
SELECT r.*
FROM learning_study_record r
$condition
""");
listSql.setCondition(cnd);
listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
dao.execute(listSql);
List<NutMap> rows = listSql.getList(NutMap.class);
rows.forEach(row -> row.put("studyTimeText", formatStudySeconds(row.getInt("studySeconds", 0))));
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), countSql.getInt(), rows));
}
@At
@ApiOperation("移动端我的学习汇总")
@SaCheckPermission(value = {"learning.my.record", "h5.learning.my.record"}, mode = SaMode.OR)
public Result h5Summary() {
String userId = SecurityUtil.getUserId();
Sql sql = Sqls.create("""
SELECT IFNULL(SUM(IFNULL(studySeconds, 0)), 0) AS studySeconds,
COUNT(DISTINCT courseId) AS courseCount,
COUNT(DISTINCT CASE WHEN completeStatus = 'completed' THEN courseId END) AS completedCourseCount,
COUNT(DISTINCT outlineId) AS outlineCount,
COUNT(DISTINCT CASE WHEN completeStatus = 'completed' THEN outlineId END) AS completedOutlineCount
FROM learning_study_record
WHERE userId = @userId
""");
sql.params().set("userId", userId);
sql.setCallback(Sqls.callback.map());
dao.execute(sql);
NutMap data = sql.getObject(NutMap.class);
if (data == null) {
data = NutMap.NEW();
}
int studySeconds = data.getInt("studySeconds", 0);
data.put("studyHour", studySeconds / 3600);
data.put("studyMinute", studySeconds % 3600 / 60);
return Result.success(data);
}
@At
@ApiOperation("移动端我的学习课程")
@SaCheckPermission(value = {"learning.my.record", "h5.learning.my.record"}, mode = SaMode.OR)
public Result h5Courses() {
String userId = SecurityUtil.getUserId();
Sql sql = Sqls.create("""
SELECT r.courseId,
MAX(r.courseName) AS courseName,
MAX(c.cover) AS cover,
COUNT(1) AS outlineCount,
SUM(CASE WHEN r.completeStatus = 'completed' THEN 1 ELSE 0 END) AS completedOutlineCount,
IFNULL(SUM(IFNULL(r.studySeconds, 0)), 0) AS studySeconds,
IFNULL(ROUND(AVG(IFNULL(r.progressPercent, 0))), 0) AS progressPercent,
MAX(r.latestStudyTime) AS latestStudyTime
FROM learning_study_record r
LEFT JOIN learning_course c ON c.id = r.courseId
WHERE r.userId = @userId
GROUP BY r.courseId
ORDER BY latestStudyTime DESC
""");
sql.params().set("userId", userId);
sql.setCallback(Sqls.callback.maps());
dao.execute(sql);
List<NutMap> rows = sql.getList(NutMap.class);
rows.forEach(row -> row.put("studyTimeText", formatStudySeconds(row.getInt("studySeconds", 0))));
return Result.success(rows);
}
@At
@ApiOperation("开始学习")
@SaCheckPermission(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
public Result start(@Param("courseId") String courseId,
@Param("resourceId") String resourceId,
@Param("positionSeconds") Integer positionSeconds) {
if (StrUtil.hasBlank(courseId, resourceId)) {
return Result.error("请选择课程资料后开始学习");
}
LearningCourse course = dao.fetch(LearningCourse.class, courseId);
LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, resourceId);
if (course == null || resource == null || !courseId.equals(resource.getCourseId())) {
return Result.error("课程资料不存在");
}
LearningCourseOutline outline = dao.fetch(LearningCourseOutline.class, resource.getOutlineId());
if (outline == null) {
return Result.error("章节不存在");
}
String userId = SecurityUtil.getUserId();
Date now = new Date();
closeUnfinishedSegments(userId, "interrupted");
LearningStudyRecord record = dao.fetch(LearningStudyRecord.class, Cnd.where("userId", "=", userId)
.and("courseId", "=", courseId)
.and("outlineId", "=", outline.getId()));
if (record == null) {
record = buildRecord(userId, course, outline, now);
record.setLastPositionSeconds(sanitizePosition(positionSeconds, 0));
dao.insert(record);
} else {
int requiredSeconds = requiredSeconds(outline.getId());
dao.update(LearningStudyRecord.class, Chain.make("latestStudyTime", now)
.add("courseName", course.getCourseName())
.add("outlineName", outline.getTitle())
.add("lastPositionSeconds", sanitizePosition(positionSeconds, record.getLastPositionSeconds()))
.add("requiredSeconds", requiredSeconds)
.add("progressPercent", progress(record.getStudySeconds(), requiredSeconds))
.add("completeStatus", "completed".equals(record.getCompleteStatus()) ? "completed" : "studying"),
Cnd.where("id", "=", record.getId()));
record = dao.fetch(LearningStudyRecord.class, record.getId());
}
LearningStudySegment segment = new LearningStudySegment();
segment.setRecordId(record.getId());
segment.setUserId(userId);
segment.setCourseId(courseId);
segment.setOutlineId(outline.getId());
segment.setResourceId(resourceId);
segment.setResourceName(resource.getResourceTitle());
segment.setStartTime(now);
segment.setLastHeartbeatTime(now);
segment.setActiveSeconds(0);
segment.setState("studying");
dao.insert(segment);
return Result.success(NutMap.NEW()
.addv("recordId", record.getId())
.addv("segmentId", segment.getId())
.addv("studySeconds", record.getStudySeconds())
.addv("lastPositionSeconds", record.getLastPositionSeconds())
.addv("requiredSeconds", record.getRequiredSeconds())
.addv("progressPercent", record.getProgressPercent())
.addv("completeStatus", record.getCompleteStatus()));
}
@At
@ApiOperation("学习心跳")
@SaCheckPermission(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
public Result heartbeat(@Param("segmentId") String segmentId,
@Param("activeSeconds") Integer activeSeconds,
@Param("positionSeconds") Integer positionSeconds) {
LearningStudySegment segment = fetchOwnStudyingSegment(segmentId);
if (segment == null) {
return Result.error("学习时段已结束,请重新开始学习");
}
int seconds = Math.max(0, Math.min(activeSeconds == null ? 0 : activeSeconds, MAX_HEARTBEAT_SECONDS));
return Result.success(addActiveSeconds(segment, seconds, false, positionSeconds));
}
@At
@ApiOperation("结束学习")
@SaCheckPermission(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
public Result finish(@Param("segmentId") String segmentId,
@Param("activeSeconds") Integer activeSeconds,
@Param("positionSeconds") Integer positionSeconds) {
LearningStudySegment segment = fetchOwnStudyingSegment(segmentId);
if (segment == null) {
return Result.success();
}
int seconds = Math.max(0, Math.min(activeSeconds == null ? 0 : activeSeconds, MAX_HEARTBEAT_SECONDS));
NutMap result = addActiveSeconds(segment, seconds, true, positionSeconds);
dao.update(LearningStudySegment.class, Chain.make("endTime", new Date()).add("state", "finished"), Cnd.where("id", "=", segmentId));
return Result.success(result);
}
@At
@ApiOperation("保存播放位置")
@SaCheckPermission(value = {"learning.course.display", "h5.learning.course.display"}, mode = SaMode.OR)
public Result position(@Param("courseId") String courseId,
@Param("resourceId") String resourceId,
@Param("positionSeconds") Integer positionSeconds) {
if (StrUtil.hasBlank(courseId, resourceId) || positionSeconds == null || positionSeconds < 0) {
return Result.success();
}
LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, resourceId);
if (resource == null || !courseId.equals(resource.getCourseId())) {
return Result.success();
}
dao.update(LearningStudyRecord.class,
Chain.make("lastPositionSeconds", positionSeconds).add("latestStudyTime", new Date()),
Cnd.where("userId", "=", SecurityUtil.getUserId())
.and("courseId", "=", courseId)
.and("outlineId", "=", resource.getOutlineId()));
return Result.success();
}
@At
@ApiOperation("可选课程")
@SaCheckPermission(value = {"learning.my.record", "h5.learning.my.record"}, mode = SaMode.OR)
public Result courseOptions() {
return Result.success(dao.query(LearningCourse.class, Cnd.NEW().asc("sortNum").desc("createdAt")));
}
@At
@ApiOperation("可选分工会")
@SaCheckPermission(value = {"learning.my.record", "h5.learning.my.record"}, mode = SaMode.OR)
public Result unionOptions() {
if (hasSchoolScope()) {
return Result.success(dao.query(Sys_union.class, Cnd.NEW().asc("unionCode").asc("name")));
}
if (hasBranchScope()) {
return Result.success(dao.query(Sys_union.class, Cnd.where("id", "=", SecurityUtil.getUnionId())));
}
return Result.success(List.of());
}
@At
@ApiOperation("删除学习记录")
@SaCheckPermission(value = {"learning.my.record", "h5.learning.my.record"}, mode = SaMode.OR)
public Result delete(@Param("id") String id) {
if (StrUtil.isBlank(id)) {
return Result.error("请选择要删除的学习记录");
}
LearningStudyRecord record = dao.fetch(LearningStudyRecord.class, id);
if (record == null) {
return Result.error("学习记录不存在");
}
if (!canOperate(record)) {
return Result.error("无权删除该学习记录");
}
dao.clear(LearningStudySegment.class, Cnd.where("recordId", "=", id));
dao.clear(LearningStudyRecord.class, Cnd.where("id", "=", id));
return Result.success();
}
private LearningStudyRecord buildRecord(String userId, LearningCourse course, LearningCourseOutline outline, Date now) {
NutMap user = currentUserInfo(userId);
int requiredSeconds = requiredSeconds(outline.getId());
LearningStudyRecord record = new LearningStudyRecord();
record.setUserId(userId);
record.setLoginName(user.getString("loginname", SecurityUtil.getUserLoginname()));
record.setUserName(user.getString("username", SecurityUtil.getUserUsername()));
record.setUnionId(user.getString("unionId", SecurityUtil.getUnionId()));
record.setUnionName(user.getString("unionName", ""));
record.setCourseId(course.getId());
record.setCourseName(course.getCourseName());
record.setOutlineId(outline.getId());
record.setOutlineName(outline.getTitle());
record.setFirstStudyTime(now);
record.setLatestStudyTime(now);
record.setStudySeconds(0);
record.setLastPositionSeconds(0);
record.setRequiredSeconds(requiredSeconds);
record.setProgressPercent(0);
record.setCompleteStatus("studying");
return record;
}
private NutMap addActiveSeconds(LearningStudySegment segment, int activeSeconds, boolean finish, Integer positionSeconds) {
Date now = new Date();
int segmentSeconds = value(segment.getActiveSeconds()) + activeSeconds;
dao.update(LearningStudySegment.class, Chain.make("activeSeconds", segmentSeconds).add("lastHeartbeatTime", now), Cnd.where("id", "=", segment.getId()));
LearningStudyRecord record = dao.fetch(LearningStudyRecord.class, segment.getRecordId());
int studySeconds = value(record.getStudySeconds()) + activeSeconds;
int requiredSeconds = requiredSeconds(record.getOutlineId());
int progressPercent = progress(studySeconds, requiredSeconds);
String completeStatus = completeStatus(studySeconds, requiredSeconds, finish);
if ("completed".equals(completeStatus)) {
progressPercent = 100;
}
Chain chain = Chain.make("latestStudyTime", now)
.add("studySeconds", studySeconds)
.add("requiredSeconds", requiredSeconds)
.add("progressPercent", progressPercent)
.add("completeStatus", completeStatus);
int lastPositionSeconds = value(record.getLastPositionSeconds());
if (positionSeconds != null && positionSeconds >= 0) {
lastPositionSeconds = positionSeconds;
chain.add("lastPositionSeconds", positionSeconds);
}
if ("completed".equals(completeStatus) && record.getCompletedAt() == null) {
chain.add("completedAt", now);
}
dao.update(LearningStudyRecord.class, chain, Cnd.where("id", "=", record.getId()));
return NutMap.NEW()
.addv("studySeconds", studySeconds)
.addv("studyTimeText", formatStudySeconds(studySeconds))
.addv("lastPositionSeconds", lastPositionSeconds)
.addv("requiredSeconds", requiredSeconds)
.addv("progressPercent", progressPercent)
.addv("completeStatus", completeStatus);
}
private LearningStudySegment fetchOwnStudyingSegment(String segmentId) {
if (StrUtil.isBlank(segmentId)) {
return null;
}
return dao.fetch(LearningStudySegment.class, Cnd.where("id", "=", segmentId)
.and("userId", "=", SecurityUtil.getUserId())
.and("state", "=", "studying"));
}
private void closeUnfinishedSegments(String userId, String state) {
dao.update(LearningStudySegment.class,
Chain.make("endTime", new Date()).add("state", state),
Cnd.where("userId", "=", userId).and("state", "=", "studying"));
}
private int requiredSeconds(String outlineId) {
LearningStudyRule rule = dao.fetch(LearningStudyRule.class, Cnd.where("targetType", "=", "outline")
.and("targetId", "=", outlineId)
.and("status", "=", "enabled"));
if (rule != null && value(rule.getMinStudySeconds()) > 0) {
return rule.getMinStudySeconds();
}
Sql sql = Sqls.create("""
SELECT IFNULL(SUM(IFNULL(durationSeconds, 0)), 0)
FROM learning_outline_resource
WHERE outlineId = @outlineId AND status = 'enabled'
""");
sql.params().set("outlineId", outlineId);
sql.setCallback(Sqls.callback.integer());
dao.execute(sql);
return Math.max(0, sql.getInt());
}
private int progress(Integer studySeconds, int requiredSeconds) {
if (requiredSeconds <= 0) {
return 0;
}
return Math.min(100, (int) Math.floor(value(studySeconds) * 100.0 / requiredSeconds));
}
private String completeStatus(Integer studySeconds, int requiredSeconds, boolean finish) {
if (requiredSeconds <= 0) {
return finish ? "completed" : "studying";
}
if (value(studySeconds) >= requiredSeconds) {
return "completed";
}
return value(studySeconds) > 0 || finish ? "studying" : "not_started";
}
private NutMap currentUserInfo(String userId) {
Sql sql = Sqls.create("SELECT id, loginname, username, unionId AS unionId, unionName AS unionName FROM vw_user WHERE id = @id");
sql.params().set("id", userId);
sql.setCallback(Sqls.callback.map());
dao.execute(sql);
NutMap user = sql.getObject(NutMap.class);
return user == null ? NutMap.NEW() : user;
}
private void appendScope(Cnd cnd) {
if (hasSchoolScope()) {
return;
}
if (hasBranchScope()) {
cnd.and("r.unionId", "=", SecurityUtil.getUnionId());
return;
}
cnd.and("r.userId", "=", SecurityUtil.getUserId());
}
private boolean hasSchoolScope() {
return AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name());
}
private boolean hasBranchScope() {
return AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_ADMIN.name());
}
private boolean canOperate(LearningStudyRecord record) {
if (hasSchoolScope()) {
return true;
}
if (hasBranchScope()) {
return StrUtil.equals(record.getUnionId(), SecurityUtil.getUnionId());
}
return StrUtil.equals(record.getUserId(), SecurityUtil.getUserId());
}
private String getOrderColumn(String prop) {
Map<String, String> columns = new HashMap<>();
columns.put("loginName", "r.loginName");
columns.put("userName", "r.userName");
columns.put("unionName", "r.unionName");
columns.put("courseName", "r.courseName");
columns.put("outlineName", "r.outlineName");
columns.put("firstStudyTime", "r.firstStudyTime");
columns.put("latestStudyTime", "r.latestStudyTime");
columns.put("studySeconds", "r.studySeconds");
columns.put("progressPercent", "r.progressPercent");
columns.put("completeStatus", "r.completeStatus");
return columns.get(prop);
}
private String formatStudySeconds(Integer seconds) {
int value = value(seconds);
int hour = value / 3600;
int minute = value % 3600 / 60;
int second = value % 60;
if (hour > 0) {
return hour + "小时" + minute + "" + second + "";
}
if (minute > 0) {
return minute + "" + second + "";
}
return second + "";
}
private int value(Integer value) {
return value == null ? 0 : value;
}
private int sanitizePosition(Integer positionSeconds, Integer defaultValue) {
if (positionSeconds == null || positionSeconds < 0) {
return value(defaultValue);
}
return positionSeconds;
}
}
@@ -0,0 +1,117 @@
package com.budwk.app.zhgh.learning.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.interceptor.annotation.PrevInsert;
import java.util.Date;
@Data
@Table
@EqualsAndHashCode(callSuper = true)
public class LearningCourse extends BaseModel {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("课程名称")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String courseName;
@Column
@Comment("课程类型")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String courseTypeId;
@Column
@Comment("授课讲师")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String lecturerName;
@Column
@Comment("讲师基本信息")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String lecturerInfo;
@Column
@Comment("课程简介")
@ColDefine(customType = "longtext")
private String courseIntro;
@Column
@Comment("适合人群")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String suitablePeople;
@Column
@Comment("学习目标")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String learningGoal;
@Column
@Comment("课程周期")
@ColDefine(type = ColType.VARCHAR, width = 50)
private String coursePeriod;
@Column
@Comment("开课类型")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String openType;
@Column
@Comment("开始时间")
@ColDefine(type = ColType.DATETIME)
private Date startTime;
@Column
@Comment("结束时间")
@ColDefine(type = ColType.DATETIME)
private Date endTime;
@Column
@Comment("课程状态")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String status;
@Column
@Comment("推荐标识")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String recommendFlags;
@Column
@Comment("课程标签")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String courseTags;
@Column
@Comment("学习对象")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String targetType;
@Column
@Comment("指定组织")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String targetOrgText;
@Column
@Comment("排序编码")
@ColDefine(type = ColType.INT)
private Integer sortNum;
@Column
@Comment("课程封面")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String cover;
}
@@ -0,0 +1,75 @@
package com.budwk.app.zhgh.learning.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.interceptor.annotation.PrevInsert;
@Data
@Table
@EqualsAndHashCode(callSuper = true)
public class LearningCourseOutline extends BaseModel {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("所属课程ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String courseId;
@Column
@Comment("所属课程名称")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String courseName;
@Column
@Comment("父节点ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String parentId;
@Column
@Comment("节点类型")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String nodeType;
@Column
@Comment("章/节标题")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String title;
@Column
@Comment("副标题")
@ColDefine(type = ColType.VARCHAR, width = 200)
private String subtitle;
@Column
@Comment("简介")
@ColDefine(customType = "longtext")
private String description;
@Column
@Comment("排序值")
@ColDefine(type = ColType.INT)
private Integer sortOrder;
@Column
@Comment("是否必学")
@ColDefine(type = ColType.BOOLEAN)
private Boolean required;
@Column
@Comment("状态")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String status;
}
@@ -0,0 +1,45 @@
package com.budwk.app.zhgh.learning.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.interceptor.annotation.PrevInsert;
@Data
@Table
@EqualsAndHashCode(callSuper = true)
public class LearningCourseType extends BaseModel {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("课程类型名称")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String typeName;
@Column
@Comment("排序编号")
@ColDefine(type = ColType.INT)
private Integer sortNum;
@Column
@Comment("是否启用")
@ColDefine(type = ColType.BOOLEAN)
private Boolean enabled;
@Column
@Comment("备注")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String remark;
}
@@ -0,0 +1,85 @@
package com.budwk.app.zhgh.learning.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.interceptor.annotation.PrevInsert;
@Data
@Table
@EqualsAndHashCode(callSuper = true)
public class LearningOutlineResource extends BaseModel {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("所属课程ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String courseId;
@Column
@Comment("所属章/节ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String outlineId;
@Column
@Comment("资料标题")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String resourceTitle;
@Column
@Comment("资料类型")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String resourceType;
@Column
@Comment("文件扩展名")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String fileExt;
@Column
@Comment("附件")
@ColDefine(customType = "longtext")
private String fileData;
@Column
@Comment("视频/音频时长")
@ColDefine(type = ColType.INT)
private Integer durationSeconds;
@Column
@Comment("排序值")
@ColDefine(type = ColType.INT)
private Integer sortOrder;
@Column
@Comment("是否必学")
@ColDefine(type = ColType.BOOLEAN)
private Boolean required;
@Column
@Comment("是否允许预览")
@ColDefine(type = ColType.BOOLEAN)
private Boolean allowPreview;
@Column
@Comment("是否允许下载")
@ColDefine(type = ColType.BOOLEAN)
private Boolean allowDownload;
@Column
@Comment("状态")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String status;
}
@@ -0,0 +1,119 @@
package com.budwk.app.zhgh.learning.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Index;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.entity.annotation.TableIndexes;
import org.nutz.dao.interceptor.annotation.PrevInsert;
import java.util.Date;
@Data
@Table("learning_study_record")
@TableIndexes({
@Index(name = "idx_learning_record_user_course_outline", fields = {"userId", "courseId", "outlineId"}, unique = true),
@Index(name = "idx_learning_record_union", fields = {"unionId"}, unique = false),
@Index(name = "idx_learning_record_course", fields = {"courseId"}, unique = false)
})
@EqualsAndHashCode(callSuper = true)
public class LearningStudyRecord extends BaseModel {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("学习人ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String userId;
@Column
@Comment("工号")
@ColDefine(type = ColType.VARCHAR, width = 120)
private String loginName;
@Column
@Comment("姓名")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String userName;
@Column
@Comment("所属分工会ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String unionId;
@Column
@Comment("所属分工会")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String unionName;
@Column
@Comment("课程ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String courseId;
@Column
@Comment("课程名称")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String courseName;
@Column
@Comment("章节ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String outlineId;
@Column
@Comment("章节名称")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String outlineName;
@Column
@Comment("第一次进入时间")
@ColDefine(type = ColType.DATETIME)
private Date firstStudyTime;
@Column
@Comment("最近学习时间")
@ColDefine(type = ColType.DATETIME)
private Date latestStudyTime;
@Column
@Comment("累计有效学习时长(秒)")
@ColDefine(type = ColType.INT)
private Integer studySeconds;
@Column
@Comment("最近播放位置(秒)")
@ColDefine(type = ColType.INT)
private Integer lastPositionSeconds;
@Column
@Comment("要求学习时长(秒)")
@ColDefine(type = ColType.INT)
private Integer requiredSeconds;
@Column
@Comment("学习进度")
@ColDefine(type = ColType.INT)
private Integer progressPercent;
@Column
@Comment("完成状态")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String completeStatus;
@Column
@Comment("完成时间")
@ColDefine(type = ColType.DATETIME)
private Date completedAt;
}
@@ -0,0 +1,100 @@
package com.budwk.app.zhgh.learning.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.interceptor.annotation.PrevInsert;
@Data
@Table
@EqualsAndHashCode(callSuper = true)
public class LearningStudyRule extends BaseModel {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("所属课程ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String courseId;
@Column
@Comment("规则对象类型")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String targetType;
@Column
@Comment("规则对象ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String targetId;
@Column
@Comment("是否必学")
@ColDefine(type = ColType.BOOLEAN)
private Boolean required;
@Column
@Comment("学习方式")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String studyMode;
@Column
@Comment("完成规则")
@ColDefine(type = ColType.VARCHAR, width = 50)
private String completionRule;
@Column
@Comment("完成比例")
@ColDefine(type = ColType.INT)
private Integer completePercent;
@Column
@Comment("最少学习时长")
@ColDefine(type = ColType.INT)
private Integer minStudySeconds;
@Column
@Comment("解锁规则")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String unlockRule;
@Column
@Comment("是否允许跳过")
@ColDefine(type = ColType.BOOLEAN)
private Boolean allowSkip;
@Column
@Comment("是否允许拖动")
@ColDefine(type = ColType.BOOLEAN)
private Boolean allowDrag;
@Column
@Comment("暂停是否计时")
@ColDefine(type = ColType.BOOLEAN)
private Boolean pauseCountTime;
@Column
@Comment("页面隐藏是否计时")
@ColDefine(type = ColType.BOOLEAN)
private Boolean hiddenCountTime;
@Column
@Comment("长时间无操作是否计时")
@ColDefine(type = ColType.BOOLEAN)
private Boolean inactiveCountTime;
@Column
@Comment("状态")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String status;
}
@@ -0,0 +1,88 @@
package com.budwk.app.zhgh.learning.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Index;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.entity.annotation.TableIndexes;
import org.nutz.dao.interceptor.annotation.PrevInsert;
import java.util.Date;
@Data
@Table("learning_study_segment")
@TableIndexes({
@Index(name = "idx_learning_segment_record", fields = {"recordId"}, unique = false),
@Index(name = "idx_learning_segment_user_state", fields = {"userId", "state"}, unique = false)
})
@EqualsAndHashCode(callSuper = true)
public class LearningStudySegment extends BaseModel {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("学习记录ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String recordId;
@Column
@Comment("学习人ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String userId;
@Column
@Comment("课程ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String courseId;
@Column
@Comment("章节ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String outlineId;
@Column
@Comment("资源ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String resourceId;
@Column
@Comment("资源名称")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String resourceName;
@Column
@Comment("开始时间")
@ColDefine(type = ColType.DATETIME)
private Date startTime;
@Column
@Comment("结束时间")
@ColDefine(type = ColType.DATETIME)
private Date endTime;
@Column
@Comment("最近心跳时间")
@ColDefine(type = ColType.DATETIME)
private Date lastHeartbeatTime;
@Column
@Comment("本时段有效学习时长(秒)")
@ColDefine(type = ColType.INT)
private Integer activeSeconds;
@Column
@Comment("状态")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String state;
}
@@ -0,0 +1,16 @@
package com.budwk.app.zhgh.learning.param;
import com.budwk.app.base.param.PageForm;
import lombok.Data;
import lombok.EqualsAndHashCode;
@EqualsAndHashCode(callSuper = true)
@Data
public class LearningStatisticsPageForm extends PageForm {
private String courseId;
private String courseName;
private String unionId;
}
@@ -0,0 +1,72 @@
CREATE TABLE IF NOT EXISTS learning_course_outline (
id VARCHAR(32) NOT NULL COMMENT 'ID',
courseId VARCHAR(32) NULL COMMENT '所属课程ID',
courseName VARCHAR(100) NULL COMMENT '所属课程名称',
parentId VARCHAR(32) NULL COMMENT '父节点ID',
nodeType VARCHAR(20) NULL COMMENT '节点类型',
title VARCHAR(100) NULL COMMENT '章/节标题',
subtitle VARCHAR(200) NULL COMMENT '副标题',
description LONGTEXT NULL COMMENT '简介',
sortOrder INT NULL COMMENT '排序值',
required TINYINT(1) NULL COMMENT '是否必学',
status VARCHAR(20) NULL COMMENT '状态',
createdBy VARCHAR(32) NULL COMMENT '创建人',
createdAt BIGINT NULL COMMENT '创建时间',
updatedBy VARCHAR(32) NULL COMMENT '修改人',
updatedAt BIGINT NULL COMMENT '修改时间',
delFlag TINYINT(1) NULL COMMENT '删除标记',
PRIMARY KEY (id),
INDEX idx_learning_outline_course (courseId),
INDEX idx_learning_outline_parent (parentId)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学习教育课程大纲';
CREATE TABLE IF NOT EXISTS learning_outline_resource (
id VARCHAR(32) NOT NULL COMMENT 'ID',
courseId VARCHAR(32) NULL COMMENT '所属课程ID',
outlineId VARCHAR(32) NULL COMMENT '所属章/节ID',
resourceTitle VARCHAR(100) NULL COMMENT '资料标题',
resourceType VARCHAR(20) NULL COMMENT '资料类型',
fileExt VARCHAR(20) NULL COMMENT '文件扩展名',
fileData LONGTEXT NULL COMMENT '附件',
durationSeconds INT NULL COMMENT '视频/音频时长',
sortOrder INT NULL COMMENT '排序值',
required TINYINT(1) NULL COMMENT '是否必学',
allowPreview TINYINT(1) NULL COMMENT '是否允许预览',
allowDownload TINYINT(1) NULL COMMENT '是否允许下载',
status VARCHAR(20) NULL COMMENT '状态',
createdBy VARCHAR(32) NULL COMMENT '创建人',
createdAt BIGINT NULL COMMENT '创建时间',
updatedBy VARCHAR(32) NULL COMMENT '修改人',
updatedAt BIGINT NULL COMMENT '修改时间',
delFlag TINYINT(1) NULL COMMENT '删除标记',
PRIMARY KEY (id),
INDEX idx_learning_resource_course (courseId),
INDEX idx_learning_resource_outline (outlineId)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学习教育大纲资料';
CREATE TABLE IF NOT EXISTS learning_study_rule (
id VARCHAR(32) NOT NULL COMMENT 'ID',
courseId VARCHAR(32) NULL COMMENT '所属课程ID',
targetType VARCHAR(20) NULL COMMENT '规则对象类型',
targetId VARCHAR(32) NULL COMMENT '规则对象ID',
required TINYINT(1) NULL COMMENT '是否必学',
studyMode VARCHAR(20) NULL COMMENT '学习方式',
completionRule VARCHAR(50) NULL COMMENT '完成规则',
completePercent INT NULL COMMENT '完成比例',
minStudySeconds INT NULL COMMENT '最少学习时长',
unlockRule VARCHAR(20) NULL COMMENT '解锁规则',
allowSkip TINYINT(1) NULL COMMENT '是否允许跳过',
allowDrag TINYINT(1) NULL COMMENT '是否允许拖动',
pauseCountTime TINYINT(1) NULL COMMENT '暂停是否计时',
hiddenCountTime TINYINT(1) NULL COMMENT '页面隐藏是否计时',
inactiveCountTime TINYINT(1) NULL COMMENT '长时间无操作是否计时',
status VARCHAR(20) NULL COMMENT '状态',
createdBy VARCHAR(32) NULL COMMENT '创建人',
createdAt BIGINT NULL COMMENT '创建时间',
updatedBy VARCHAR(32) NULL COMMENT '修改人',
updatedAt BIGINT NULL COMMENT '修改时间',
delFlag TINYINT(1) NULL COMMENT '删除标记',
PRIMARY KEY (id),
INDEX idx_learning_rule_target (targetType, targetId),
INDEX idx_learning_rule_course (courseId)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学习教育学习规则';
@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS learning_course (
id VARCHAR(32) NOT NULL COMMENT 'ID',
courseName VARCHAR(100) NULL COMMENT '课程名称',
courseTypeId VARCHAR(32) NULL COMMENT '课程类型',
lecturerName VARCHAR(100) NULL COMMENT '授课讲师',
lecturerInfo VARCHAR(500) NULL COMMENT '讲师基本信息',
courseIntro LONGTEXT NULL COMMENT '课程简介',
suitablePeople VARCHAR(500) NULL COMMENT '适合人群',
learningGoal VARCHAR(500) NULL COMMENT '学习目标',
coursePeriod VARCHAR(50) NULL COMMENT '课程周期',
openType VARCHAR(20) NULL COMMENT '开课类型',
startTime DATETIME NULL COMMENT '开始时间',
endTime DATETIME NULL COMMENT '结束时间',
status VARCHAR(20) NULL COMMENT '课程状态',
recommendFlags VARCHAR(500) NULL COMMENT '推荐标识',
courseTags VARCHAR(500) NULL COMMENT '课程标签',
targetType VARCHAR(20) NULL COMMENT '学习对象',
targetOrgText VARCHAR(500) NULL COMMENT '指定组织',
sortNum INT NULL COMMENT '排序编码',
cover VARCHAR(500) NULL COMMENT '课程封面',
createdBy VARCHAR(32) NULL COMMENT '创建人',
createdAt BIGINT NULL COMMENT '创建时间',
updatedBy VARCHAR(32) NULL COMMENT '修改人',
updatedAt BIGINT NULL COMMENT '修改时间',
delFlag TINYINT(1) NULL COMMENT '删除标记',
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学习教育课程';
@@ -0,0 +1,2 @@
ALTER TABLE learning_course_type
ADD COLUMN remark VARCHAR(500) NULL COMMENT '备注';
@@ -0,0 +1,132 @@
-- 学习教育移动端字典初始化。
-- 移动端课程列表通过 /platform/learning/course/display/recommendOptions 读取“学习教育 -> 推荐标识”字典。
INSERT INTO `sys_dict` (
`id`, `parentId`, `path`, `name`, `remark`, `code`, `disabled`, `location`, `hasChildren`,
`createdBy`, `createdAt`, `updatedBy`, `updatedAt`, `delFlag`
)
SELECT
'a0f47f6a6d734c8b9c6ce5c0dfd81001',
'',
LPAD(IFNULL(MAX(CAST(`path` AS UNSIGNED)), 0) + 1, 4, '0'),
'学习教育',
'学习教育平台字典',
'学习教育',
0,
990,
1,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
0
FROM `sys_dict`
WHERE (`parentId` = '' OR `parentId` IS NULL)
AND CHAR_LENGTH(`path`) = 4
HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM `sys_dict` WHERE `code` = '学习教育' OR `name` = '学习教育') t);
INSERT INTO `sys_dict` (
`id`, `parentId`, `path`, `name`, `remark`, `code`, `disabled`, `location`, `hasChildren`,
`createdBy`, `createdAt`, `updatedBy`, `updatedAt`, `delFlag`
)
SELECT
'a0f47f6a6d734c8b9c6ce5c0dfd81002',
p.id,
CONCAT(p.`path`, '0001'),
'推荐标识',
'学习教育课程推荐标识',
'推荐标识',
0,
1,
1,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
0
FROM `sys_dict` p
WHERE (p.`code` = '学习教育' OR p.`name` = '学习教育')
AND NOT EXISTS (
SELECT 1
FROM (SELECT id FROM `sys_dict` WHERE `parentId` = p.id AND (`code` = '推荐标识' OR `name` = '推荐标识')) t
);
INSERT INTO `sys_dict` (
`id`, `parentId`, `path`, `name`, `remark`, `code`, `disabled`, `location`, `hasChildren`,
`createdBy`, `createdAt`, `updatedBy`, `updatedAt`, `delFlag`
)
SELECT
'a0f47f6a6d734c8b9c6ce5c0dfd81003',
p.id,
CONCAT(p.`path`, '0001'),
'推荐课程',
'移动端课程列表推荐筛选项',
'recommend',
0,
1,
0,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
0
FROM `sys_dict` p
JOIN `sys_dict` r ON r.id = p.`parentId`
WHERE (r.`code` = '学习教育' OR r.`name` = '学习教育')
AND (p.`code` = '推荐标识' OR p.`name` = '推荐标识')
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM `sys_dict` WHERE `parentId` = p.id AND `code` = 'recommend') t);
INSERT INTO `sys_dict` (
`id`, `parentId`, `path`, `name`, `remark`, `code`, `disabled`, `location`, `hasChildren`,
`createdBy`, `createdAt`, `updatedBy`, `updatedAt`, `delFlag`
)
SELECT
'a0f47f6a6d734c8b9c6ce5c0dfd81004',
p.id,
CONCAT(p.`path`, '0002'),
'热门课程',
'移动端课程列表热门筛选项',
'hot',
0,
2,
0,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
0
FROM `sys_dict` p
JOIN `sys_dict` r ON r.id = p.`parentId`
WHERE (r.`code` = '学习教育' OR r.`name` = '学习教育')
AND (p.`code` = '推荐标识' OR p.`name` = '推荐标识')
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM `sys_dict` WHERE `parentId` = p.id AND `code` = 'hot') t);
INSERT INTO `sys_dict` (
`id`, `parentId`, `path`, `name`, `remark`, `code`, `disabled`, `location`, `hasChildren`,
`createdBy`, `createdAt`, `updatedBy`, `updatedAt`, `delFlag`
)
SELECT
'a0f47f6a6d734c8b9c6ce5c0dfd81005',
p.id,
CONCAT(p.`path`, '0003'),
'最新课程',
'移动端课程列表最新筛选项',
'new',
0,
3,
0,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
0
FROM `sys_dict` p
JOIN `sys_dict` r ON r.id = p.`parentId`
WHERE (r.`code` = '学习教育' OR r.`name` = '学习教育')
AND (p.`code` = '推荐标识' OR p.`name` = '推荐标识')
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM `sys_dict` WHERE `parentId` = p.id AND `code` = 'new') t);
UPDATE `sys_dict`
SET `hasChildren` = 1
WHERE `code` IN ('学习教育', '推荐标识')
OR `name` IN ('学习教育', '推荐标识');
@@ -0,0 +1,2 @@
ALTER TABLE learning_study_record
ADD COLUMN lastPositionSeconds INT NULL COMMENT '最近播放位置(秒)' AFTER studySeconds;
@@ -0,0 +1,52 @@
CREATE TABLE IF NOT EXISTS learning_study_record (
id VARCHAR(32) NOT NULL COMMENT 'ID',
userId VARCHAR(32) NULL COMMENT '学习人ID',
loginName VARCHAR(120) NULL COMMENT '工号',
userName VARCHAR(100) NULL COMMENT '姓名',
unionId VARCHAR(32) NULL COMMENT '所属分工会ID',
unionName VARCHAR(100) NULL COMMENT '所属分工会',
courseId VARCHAR(32) NULL COMMENT '课程ID',
courseName VARCHAR(100) NULL COMMENT '课程名称',
outlineId VARCHAR(32) NULL COMMENT '章节ID',
outlineName VARCHAR(100) NULL COMMENT '章节名称',
firstStudyTime DATETIME NULL COMMENT '第一次进入时间',
latestStudyTime DATETIME NULL COMMENT '最近学习时间',
studySeconds INT NULL COMMENT '累计有效学习时长(秒)',
lastPositionSeconds INT NULL COMMENT '最近播放位置(秒)',
requiredSeconds INT NULL COMMENT '要求学习时长(秒)',
progressPercent INT NULL COMMENT '学习进度',
completeStatus VARCHAR(20) NULL COMMENT '完成状态',
completedAt DATETIME NULL COMMENT '完成时间',
createdBy VARCHAR(32) NULL COMMENT '创建人',
createdAt BIGINT NULL COMMENT '创建时间',
updatedBy VARCHAR(32) NULL COMMENT '修改人',
updatedAt BIGINT NULL COMMENT '修改时间',
delFlag TINYINT(1) NULL COMMENT '删除标记',
PRIMARY KEY (id),
UNIQUE KEY idx_learning_record_user_course_outline (userId, courseId, outlineId),
INDEX idx_learning_record_union (unionId),
INDEX idx_learning_record_course (courseId)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学习教育学习记录';
CREATE TABLE IF NOT EXISTS learning_study_segment (
id VARCHAR(32) NOT NULL COMMENT 'ID',
recordId VARCHAR(32) NULL COMMENT '学习记录ID',
userId VARCHAR(32) NULL COMMENT '学习人ID',
courseId VARCHAR(32) NULL COMMENT '课程ID',
outlineId VARCHAR(32) NULL COMMENT '章节ID',
resourceId VARCHAR(32) NULL COMMENT '资源ID',
resourceName VARCHAR(100) NULL COMMENT '资源名称',
startTime DATETIME NULL COMMENT '开始时间',
endTime DATETIME NULL COMMENT '结束时间',
lastHeartbeatTime DATETIME NULL COMMENT '最近心跳时间',
activeSeconds INT NULL COMMENT '本时段有效学习时长(秒)',
state VARCHAR(20) NULL COMMENT '状态',
createdBy VARCHAR(32) NULL COMMENT '创建人',
createdAt BIGINT NULL COMMENT '创建时间',
updatedBy VARCHAR(32) NULL COMMENT '修改人',
updatedAt BIGINT NULL COMMENT '修改时间',
delFlag TINYINT(1) NULL COMMENT '删除标记',
PRIMARY KEY (id),
INDEX idx_learning_segment_record (recordId),
INDEX idx_learning_segment_user_state (userId, state)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学习教育学习时段';
@@ -0,0 +1,105 @@
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT
'a0f47f6a6d734c8b9c6ce5c0dfd80001',
'',
LPAD(IFNULL(MAX(CAST(path AS UNSIGNED)), 0) + 1, 4, '0'),
'学习教育平台',
'Learning',
'menu',
'',
'',
'ti-book',
1,
0,
'learning',
NULL,
990,
1,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
0,
'PC',
NULL,
NULL,
'x',
0,
0
FROM sys_menu
WHERE (parentId = '' OR parentId IS NULL)
AND CHAR_LENGTH(path) = 4
HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'learning') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'a0f47f6a6d734c8b9c6ce5c0dfd80002', p.id, CONCAT(p.path, '0001'), '课程类型设置', 'Course Type', 'menu', '/platform/learning/course/type', 'data-pjax', '', 1, 0, 'learning.course.type', NULL, 1, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'k', 0, 0
FROM sys_menu p
WHERE p.permission = 'learning'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'learning.course.type') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'a0f47f6a6d734c8b9c6ce5c0dfd80003', p.id, CONCAT(p.path, '0002'), '课程管理', 'Course Manage', 'menu', '/platform/learning/course/manage', 'data-pjax', '', 1, 0, 'learning.course.manage', NULL, 2, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'k', 0, 0
FROM sys_menu p
WHERE p.permission = 'learning'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'learning.course.manage') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'a0f47f6a6d734c8b9c6ce5c0dfd80004', p.id, CONCAT(p.path, '0003'), '章节内容管理', 'Chapter Content', 'menu', '/platform/learning/chapter/content', 'data-pjax', '', 1, 0, 'learning.chapter.content', NULL, 3, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'z', 0, 0
FROM sys_menu p
WHERE p.permission = 'learning'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'learning.chapter.content') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'a0f47f6a6d734c8b9c6ce5c0dfd80005', p.id, CONCAT(p.path, '0004'), '课程展示', 'Course Display', 'menu', '/platform/learning/course/display', 'data-pjax', '', 1, 0, 'learning.course.display', NULL, 4, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'k', 0, 0
FROM sys_menu p
WHERE p.permission = 'learning'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'learning.course.display') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'a0f47f6a6d734c8b9c6ce5c0dfd80006', p.id, CONCAT(p.path, '0005'), '学习活动管理', 'Activity Manage', 'menu', '/platform/learning/activity/manage', 'data-pjax', '', 1, 0, 'learning.activity.manage', NULL, 5, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'x', 0, 0
FROM sys_menu p
WHERE p.permission = 'learning'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'learning.activity.manage') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'a0f47f6a6d734c8b9c6ce5c0dfd80007', p.id, CONCAT(p.path, '0006'), '学习统计', 'Learning Statistics', 'menu', '/platform/learning/statistics', 'data-pjax', '', 1, 0, 'learning.statistics', NULL, 6, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'x', 0, 0
FROM sys_menu p
WHERE p.permission = 'learning'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'learning.statistics') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'a0f47f6a6d734c8b9c6ce5c0dfd80008', p.id, CONCAT(p.path, '0007'), '我的学习记录', 'My Learning Record', 'menu', '/platform/learning/my/record', 'data-pjax', '', 1, 0, 'learning.my.record', NULL, 7, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'w', 0, 0
FROM sys_menu p
WHERE p.permission = 'learning'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'learning.my.record') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'a0f47f6a6d734c8b9c6ce5c0dfd80009', p.id, CONCAT(p.path, '0008'), '我的课堂', 'My Course', 'menu', '/platform/learning/course/h5', 'data-pjax', '', 1, 0, 'h5.learning.course.display', NULL, 8, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'H5', NULL, NULL, 'w', 0, 0
FROM sys_menu p
WHERE p.permission = 'learning'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'h5.learning.course.display') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT 'a0f47f6a6d734c8b9c6ce5c0dfd80010', p.id, CONCAT(p.path, '0009'), '我的学习记录', 'My Learning Record H5', 'menu', '/platform/learning/my/record/h5', 'data-pjax', '', 1, 0, 'h5.learning.my.record', NULL, 9, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'H5', NULL, NULL, 'w', 0, 0
FROM sys_menu p
WHERE p.permission = 'learning'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'h5.learning.my.record') t);
INSERT INTO sys_role_menu (roleId, menuId)
SELECT r.id, m.id
FROM sys_role r
JOIN sys_menu m ON m.permission IN (
'learning',
'learning.course.type',
'learning.course.manage',
'learning.chapter.content',
'learning.course.display',
'learning.activity.manage',
'learning.statistics',
'learning.my.record',
'h5.learning.course.display',
'h5.learning.my.record'
)
LEFT JOIN sys_role_menu rm ON rm.roleId = r.id AND rm.menuId = m.id
WHERE r.code = 'SYSADMIN'
AND rm.roleId IS NULL;
@@ -71,7 +71,7 @@
<script src="https://vxeui.com/umd/xe-utils@3.5.30/dist/xe-utils.umd.min.js"></script>
<script src="https://vxeui.com/umd/vxe-pc-ui@3.1.25/lib/index.umd.min.js"></script>
<script src="https://vxeui.com/umd/vxe-table@3.9.0/lib/index.umd.min.js"></script>
l
<!-- 引入 form-create 和 designer -->
<script src="${base!}/assets/platform/plugins/form-create/form-create.min.js"></script>
<script src="${base!}/assets/platform/plugins/form-create/index.umd.js"></script>
@@ -80,11 +80,12 @@
<script src="${base!}/assets/platform/plugins/swiper/swiper-bundle.js"></script>
<link rel="stylesheet" href="${base!}/assets/platform/plugins/swiper/swiper-bundle.min.css"></link>
<!-- <script src="${base!}/assets/platform/plugins/snaker/SnakerflowDesigner.umd.min.js"></script>-->
<!--<script src="${base!}/assets/platform/plugins/snaker/SnakerflowDesigner.umd.js"></script>-->
<script src="${base!}/components/plugins/sysDict/DictData.js"></script>
<script src="${base!}/assets/platform/js/util/commonUtil.js"></script>
<script src="${base!}/assets/platform/js/util/coordinateUtil.js"></script>
<script src="${base!}/assets/platform/js/util/voiceMenuNavigator.js"></script>
<script src="${base!}/assets/platform/js/main.js"></script>
<script src="${base!}/assets/platform/js/mixin/initTableMixins.js"></script>
<script src="${base!}/assets/platform/js/mixin/styleMixin.js"></script>
@@ -100,13 +101,32 @@
<script src="https://map.qq.com/api/gljs?v=2.exp&key=MLLBZ-GQECI-ASXG7-5GNOZ-XW2OF-H5BVH"></script>
<script type="text/javascript">
window._AMapSecurityConfig = {
securityJsCode: "4fa1e1aeabba7eb9518129cf57ab17c1"
<script nonce="${cspNonce!}">
// 在加载 lodash 后、使用前插入
const originalDefaultsDeep = window._.defaultsDeep;
window._.defaultsDeep = function(...args) {
// 先对所有参数做原型污染清洗
const cleanArgs = args.map(arg => sanitizeForPrototypePollution(arg));
return originalDefaultsDeep.apply(window._, cleanArgs);
};
function sanitizeForPrototypePollution(obj) {
if (obj === null || typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return obj.map(sanitizeForPrototypePollution);
const clean = {};
for (const key in obj) {
if (!Object.hasOwn(obj, key)) continue;
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue;
}
clean[key] = sanitizeForPrototypePollution(obj[key]);
}
return clean;
}
</script>
<script type="text/javascript">
<script nonce="${cspNonce!}" type="text/javascript">
Vue.config.devtools = false
ELEMENT.locale(ELEMENT.lang["${lang,escape}"])
ELEMENT.Dialog.props.closeOnClickModal.default = false
@@ -123,12 +143,12 @@
</script>
<!--广播频道-->
<script type="text/javascript">
<script nonce="${cspNonce!}" type="text/javascript">
window.GlobalBroadcastChannel = new BroadcastChannel("zhgh-global-channel")
</script>
<!--ws-->
<script type="text/javascript">
<script nonce="${cspNonce!}" type="text/javascript">
class WebSocketPubSub {
constructor() {
this.ws = null
@@ -150,6 +170,7 @@
connect() {
const WS_URL = window.location.host + "${base}/websocket"
// const WS_URL = 'zhgh.jshvc.edu.cn' + "${base}/websocket"
const protocol = window.location.protocol === "http:" ? "ws://" : "wss://"
this.ws = new WebSocket(protocol + WS_URL)
}
@@ -162,13 +183,12 @@
this.subscribers.get(type).add(callback)
setTimeout(() => {
console.log(this.ws.readyState)
// 所有订阅完成后,发送 join 消息
if (!this.isSubscribed && this.ws.readyState === WebSocket.OPEN) {
this.isSubscribed = true
this.sendJoinMessage()
}
}, 2000)
}, 1000)
}
// 事件处理
@@ -202,6 +222,7 @@
// )
// 连接建立时不立即发送 join,等待订阅完成
this.ping()
console.info('websocket open success')
}
}
@@ -243,9 +264,9 @@
if (this.ws.readyState === WebSocket.OPEN) {
this.sendJoinMessage()
}
}, 1000)
}, 500)
}
}, 3000) // 延迟3秒重连
}, 1000) // 延迟2秒重连
}
// 窗口事件处理
@@ -277,7 +298,7 @@
</script>
<!--vuex-->
<script type="text/javascript">
<script nonce="${cspNonce!}" type="text/javascript">
window.store = new Vuex.Store({
plugins: [
createPersistedState({
@@ -303,11 +324,11 @@
//设置用户信息
setUser(state, payload) {
state.user = payload
state.room = payload.loginname + ":${@auth.getSessionId()}"
}
},
actions: {}
})
$.get("/platform/sys/user/getLogonUser").then((res) => {
if (res.code === 0) {
window.sessionStorage.setItem("user", JSON.stringify(res.data))
@@ -317,13 +338,13 @@
</script>
<!--beetl全局变量方法-->
<script type="text/javascript">
<script nonce="${cspNonce!}" type="text/javascript">
const base = "${base!}"
const APP_DOMAIN = "${AppDomain!}"
</script>
<!--vue挂载-->
<script type="text/javascript">
<script nonce="${cspNonce!}" type="text/javascript">
Vue.prototype.$moment = moment
Vue.prototype.$businessTool = businessTool
Vue.prototype.$commonUtil = commonUtil
@@ -331,10 +352,20 @@
Vue.prototype.$auth = commonUtil.authService()
Vue.prototype.$axios = commonUtil.axiosService()
Vue.prototype.$downLoad = commonUtil.downLoadService
Vue.prototype.$processStatusMap = {
10: {text: "进行中", class: "doing"},
20: {text: "已完成", class: "finished"},
30: {text: "已撤回", class: "withdraw"},
40: {text: "强行终止", class: "interrupt"},
45: {text: "已拒绝", class: "reject"},
50: {text: "挂起", class: "pending"},
99: {text: "已废弃", class: "abandon"}
}
</script>
<!--vue全局组件-->
<script>
<script nonce="${cspNonce!}">
Vue.component(VueQrcode.name, VueQrcode)
Vue.component("guava", httpVueLoader("/components/plugins/Guava.vue?v=" + new Date().getTime()))
Vue.component("enum-tag", httpVueLoader("/components/plugins/sysEnum/EnumTag.vue?v=" + new Date().getTime()))
@@ -511,6 +542,33 @@
background-color: rgba(255, 255, 255, 0.15);
}
.v4-voice-menu {
color: #ffffff;
cursor: pointer;
padding: 0 14px;
height: 100%;
border: 0;
background: transparent;
border-radius: 0;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
font: inherit;
outline: none;
}
.v4-voice-menu:hover,
.v4-voice-menu.is-listening {
background-color: rgba(255, 255, 255, 0.15);
color: #ffffff;
}
.v4-voice-menu.is-listening i {
color: #ffdf6b;
}
.v4-avatar {
width: 36px;
height: 36px;
@@ -576,6 +634,10 @@
.v4-nav {
display: none;
}
.voice-menu-text {
display: none;
}
}
/* 页脚样式 */
@@ -647,6 +709,10 @@
</div>
<div class="v4-user-section">
<button type="button" class="v4-voice-menu" id="voice-menu-btn" data-platform="PC" title="语音打开菜单">
<i class="fa fa-microphone"></i>
<span class="voice-menu-text">语音</span>
</button>
<div class="v4-user-info">
<span class="v4-user-name">${@auth.getPrincipalProperty('username')} (${@auth.getPrincipalProperty('loginname')})</span>
<!-- <i class="fa fa-angle-down"></i> -->
@@ -657,7 +723,7 @@
</a>
</div>
</header>
<div style="height: 45px"></div>
<div style="height: 64px"></div>
<main class="v4-content">
<!-- 页面内容区域 -->
@@ -679,12 +745,12 @@
</footer>
<script>
<script nonce="${cspNonce!}">
// 处理导航项的active状态
$(document).ready(function () {
// 控制页脚显示的函数
function toggleFooter() {
var currentPath = window.location.pathname
var currentPath = getBaseSubAppPath()
var footer = $("#v4-footer")
if (currentPath === "/platform/v4/home") {
@@ -696,7 +762,7 @@
// 初始化:根据当前URL设置active状态
function setActiveNavItem() {
var currentPath = window.location.pathname
var currentPath = getBaseSubAppPath()
$(".v4-nav-item").removeClass("active")
$(".v4-nav-item").each(function () {
if ($(this).attr("href") === currentPath) {
@@ -1139,7 +1139,7 @@ layout("/layouts/v4/baseLayout.html"){
.oa-grassroots-wrapper {
width: 100%;
background-image: url('https://cdgh.ncu.edu.cn/images/index_bg2.png');
background-image: url(/assets/platform/img/v4/home-bg.png);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
@@ -1513,7 +1513,7 @@ layout("/layouts/v4/baseLayout.html"){
<div class="oa-hero-section">
<!-- 背景图 -->
<div class="oa-background-image">
<img src="${config.AppHomeImg!}" alt=""/>
<img :src="homeBannerList[0] || ''" alt=""/>
</div>
<!-- 搜索和统计区域 -->
@@ -1999,7 +1999,7 @@ layout("/layouts/v4/baseLayout.html"){
</div>
</div>
<script>
<script nonce="${cspNonce!}">
const vue = new Vue({
el: "#v4-home-app",
data() {
@@ -2020,6 +2020,7 @@ layout("/layouts/v4/baseLayout.html"){
recommendServices: [],
favoriteItems: [],
recommendApps: [],
homeBannerList: "${config.AppHomeImg!}".split(",").map(item => item.trim()).filter(item => item),
// 工会网站新闻
websiteNews: {},
+70 -63
View File
@@ -1,54 +1,54 @@
const act = {
template: /*language=HTML*/ `
<!-- 活动 -->
<div class="section-wrapper">
<div class="activity-section-wrapper">
<div class="section-act">
<!-- Swiper容器 -->
<div v-if="actList && actList.length > 0" class="activity-swiper-container">
<div class="activity-swiper-container">
<!-- 活动数据 - Swiper -->
<div class="swiper activity-swiper">
<div class="swiper-wrapper">
<div class="swiper-slide" v-for="(activity, index) in actList" :key="activity.id">
<div class="item" @click="go(activity)">
<div class="img-box">
<img :src="activity.cover || 'https://www.ncu.edu.cn/__local/8/52/2C/644986B4C9A030F7B65300178A6_8679CB08_18467.jpg'"
alt="">
<span class="status">
<div v-if="actList && actList.length > 0" class="activity-swiper-container">
<div class="activity-swiper-container">
<!-- 活动数据 - Swiper -->
<div class="swiper activity-swiper">
<div class="swiper-wrapper">
<div class="swiper-slide" v-for="(activity, index) in actList" :key="activity.id">
<div class="item" @click="go(activity)">
<div class="img-box">
<img :src="activity.cover || 'https://www.ncu.edu.cn/__local/8/52/2C/644986B4C9A030F7B65300178A6_8679CB08_18467.jpg'"
alt="">
<span class="status">
{{getStatusText(activity)}}
</span>
</div>
<h4 class="title">
{{ activity.name}}
</h4>
<!--报名时间-->
<div class="time">
<p>
<i class="fa fa-clock-o"></i>
时间:{{ activity.startDate }}至{{ activity.endDate }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- 导航按钮 -->
<div class="swiper-button-next"></div>
<div class="swiper-button-prev"></div>
<!-- 进度条 -->
<div class="swiper-pagination"></div>
</div>
</div>
<h4 class="title">
{{ activity.name}}
</h4>
<!--报名时间-->
<div class="time">
<p>
<i class="fa fa-clock-o"></i>
时间:{{ activity.startDate }}至{{ activity.endDate }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- 导航按钮 -->
<div class="swiper-button-next"></div>
<div class="swiper-button-prev"></div>
<!-- 进度条 -->
<div class="swiper-pagination"></div>
</div>
</div>
<div v-else class="no-activity-placeholder">
<div class="icon">📅</div>
<p>暂无近期活动</p>
<p class="subtext">敬请关注后续通知</p>
</div>
<div v-else class="no-activity-placeholder">
<div class="icon">📅</div>
<p>暂无近期活动</p>
<p class="subtext">敬请关注后续通知</p>
</div>
</div>
</div>
`,
@@ -77,8 +77,8 @@ const act = {
/*初始化Swiper*/
initSwiper() {
this.swiper = new Swiper('.activity-swiper', {
slidesPerView: 'auto',
spaceBetween: 30,
slidesPerView: 1,
spaceBetween: 18,
centeredSlides: false,
loop: false,
navigation: {
@@ -91,13 +91,13 @@ const act = {
},
breakpoints: {
768: {
slidesPerView: 1,
},
1024: {
slidesPerView: 2,
},
1024: {
slidesPerView: 4,
},
1200: {
slidesPerView: 3,
slidesPerView: 5,
}
}
})
@@ -119,6 +119,11 @@ const act = {
this.$message.warning("管理员未配置活动链接")
return
}
if (act.url === "/platform/activity/apply"
&& !this.$auth.hasRoleOr(["SYSADMIN", "SCHOOL_UNION_ADMIN", "BRANCH_UNION_CHAIRMAN", "BRANCH_UNION_WENTI_SPORTS"])) {
return this.$message.warning("PC端暂未开放报名,请前往移动端报名!")
}
window.open(act.url)
}
},
@@ -126,7 +131,7 @@ const act = {
this.listAct()
},
style: /*language=CSS*/ `
.section-wrapper {
.activity-section-wrapper {
width: 100%;
}
@@ -171,7 +176,7 @@ const act = {
.activity-swiper-container {
position: relative;
width: 100%;
padding: 20px;
padding: 9px 14px;
/*margin-top: 30px;*/
}
@@ -212,7 +217,7 @@ const act = {
/* 进度条样式 */
.activity-swiper-container .swiper-pagination {
position: relative;
margin-top: 30px;
margin-top: 12px;
height: 4px;
}
@@ -234,8 +239,8 @@ const act = {
.activity-swiper .swiper-slide .item .img-box {
width: 100%;
height: 200px;
border-radius: 12px;
height: 128px;
border-radius: 8px;
overflow: hidden;
position: relative;
}
@@ -245,10 +250,12 @@ const act = {
left: 0;
top: 0;
background: #c11623;
padding: 6px 2px;
min-width: 50px;
padding: 4px 2px;
min-width: 42px;
color: #ffffff;
border-radius: 0 0 50% 0;
font-size: 12px;
line-height: 1.2;
}
@@ -264,16 +271,16 @@ const act = {
}
.activity-swiper .swiper-slide .item h4 {
font-size: 20px;
margin-top: 10px;
height: 60px;
font-size: 15px;
margin: 8px 0 0;
height: 42px;
color: #333;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.5;
line-height: 1.4;
text-align: left;
transition: color 0.3s ease;
}
@@ -287,14 +294,14 @@ const act = {
}
.activity-swiper .swiper-slide .item .time p {
font-size: 13px;
font-size: 12px;
margin: 0;
/*line-height: 1.5;*/
line-height: 1.4;
color: #666;
}
.activity-swiper .swiper-slide .item .time i {
margin-right: 5px;
margin-right: 4px;
color: var(--color-primary);
}
+106 -106
View File
@@ -1,39 +1,38 @@
const entry = {
template: /*language=HTML*/ `
<div class="entry-wrapper">
<!-- 标签页导航 -->
<div class="tabs">
<div class="tab-item" :class="{ active: activeCategory === 'serv' }"
@click="setActiveCategory('serv')">
<i class="fa fa-star"></i> 推荐服务
</div>
<div class="tab-item" :class="{ active: activeCategory === 'app' }"
@click="setActiveCategory('app')">
<i class="fa fa-fire"></i> 推荐应用
</div>
<div class="tab-item" :class="{ active: activeCategory === 'fav' }"
@click="setActiveCategory('fav')">
<i class="fa fa-heart"></i> 我的收藏
</div>
</div>
<!-- 应用块区域 -->
<div class="apps-container">
<div class="app-grid">
<div class="app-item" v-for="(item, index) in currentServices" :key="index"
@click="openService(item)">
<div class="app-icon">
<img :src="item.picIcon" alt="" />
</div>
<div class="app-name">{{ item.name }}</div>
</div>
</div>
</div>
</div>
<div class="entry-wrapper">
<div :class="['entry-section', 'entry-section-' + section.key]" v-for="section in entrySections" :key="section.key">
<div class="entry-section-header">
<div class="entry-section-title">
<i :class="section.icon"></i>
<span>{{ section.title }}</span>
<em>/Applications</em>
</div>
</div>
<div class="apps-container">
<div class="app-grid">
<div class="app-item"
v-for="(item, index) in section.list"
:key="item.id || index"
@click="openService(item, section.key)">
<div class="app-icon">
<img :src="item.picIcon" alt="" />
</div>
<div class="app-name">{{ item.name }}</div>
</div>
<el-empty class="app-empty"
v-if="!section.list || section.list.length === 0"
description="暂无数据"
:image-size="72">
</el-empty>
</div>
</div>
</div>
</div>
`,
data() {
return {
activeCategory: 'serv',
hasMore: true,
entries: {
serv: [],
@@ -43,8 +42,12 @@ const entry = {
}
},
computed: {
currentServices() {
return this.entries[this.activeCategory] || [];
entrySections() {
return [
{ key: 'serv', title: '推荐服务', icon: 'fa fa-star', list: this.entries.serv },
{ key: 'app', title: '推荐应用', icon: 'fa fa-fire', list: this.entries.app },
{ key: 'fav', title: '我的收藏', icon: 'fa fa-heart', list: this.entries.fav }
];
}
},
created() {
@@ -53,10 +56,6 @@ const entry = {
this.getFavorite()
},
methods: {
setActiveCategory(category) {
this.activeCategory = category;
},
// 查询推荐服务
async getRecommendService() {
this.$axios.post('/platform/home/listRecommendService', {platform: "PC"}).then(res => {
@@ -84,35 +83,37 @@ const entry = {
})
},
// 点击服务
openService(service) {
if(this.activeCategory === 'serv'){
if(!service.href) this.$message.error('无效的链接地址')
// 点击服务或应用
openService(service, category) {
if (category === 'serv') {
if (!service.href) {
this.$message.error('无效的链接地址')
return
}
window.open(service.href)
return
}
if(this.activeCategory === 'app'){
// 储存到缓存
if (category === 'app') {
window.sessionStorage.setItem("zhgh_sub_app", JSON.stringify(service))
// 新标签页打开
window.open("/platform/v4/subApp?appId=" + service.id, "_blank")
return
}
if(this.activeCategory === 'fav'){
if(!service.href) this.$message.error('无效的链接地址')
window.open(service.href)
return
if(!service.parentId){
// 储存到缓存
window.sessionStorage.setItem("zhgh_sub_app", JSON.stringify(app))
// 新标签页打开
window.open("/platform/v4/subApp?appId=" + app.id, "_blank")
if (category === 'fav') {
if (service.href) {
window.open(service.href)
return
}
}
if (!service.parentId) {
window.sessionStorage.setItem("zhgh_sub_app", JSON.stringify(service))
window.open("/platform/v4/subApp?appId=" + service.id, "_blank")
return
}
this.$message.error('无效的链接地址')
}
}
},
style: /*language=CSS*/ `
@@ -121,37 +122,41 @@ const entry = {
box-sizing: border-box;
}
.tabs {
margin-top: 5px;
display: flex;
gap: 20px;
border-bottom: 2px solid #e8e8e8;
padding-bottom: 10px;
.entry-section + .entry-section {
margin-top: 22px;
}
.tab-item {
/* 临时屏蔽“我的收藏”分组,保留原节点和数据逻辑便于恢复 */
.entry-section-fav {
display: none;
}
.entry-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.entry-section-title {
display: inline-flex;
align-items: baseline;
gap: 6px;
color: #19324d;
font-size: 18px;
font-weight: 700;
}
.entry-section-title i {
color: #409eff;
font-size: 16px;
}
.entry-section-title em {
color: #b5c0d6;
font-size: 14px;
color: #666;
cursor: pointer;
padding-bottom: 8px;
transition: color 0.2s ease;
}
.tab-item:hover {
color: #1890ff;
}
.tab-item.active {
color: #1890ff;
font-weight: bold;
border-bottom: 2px solid #1890ff;
}
.tab-item i {
font-size: 14px;
font-style: normal;
font-weight: 500;
}
.apps-container {
@@ -162,19 +167,17 @@ const entry = {
.app-grid {
display: grid;
/* 改为自适应列数:每格最小 80px,自动换行 */
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
justify-items: center; /* 子项内容居中(图标和文字) */
justify-content: start; /* 整体网格靠左对齐,避免居中 */
gap: 4px;
padding: 12px 4px;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
justify-items: center;
justify-content: start;
gap: 12px 18px;
min-height: 116px;
padding: 18px 20px;
background: #fff;
border: 1px solid #edf1f7;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
overflow-x: hidden;
max-height: calc(350px - 74px);
overflow-y: auto;
}
.app-item {
@@ -187,6 +190,7 @@ const entry = {
width: 100%;
max-width: 100%;
padding: 6px;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
@@ -208,6 +212,7 @@ const entry = {
}
.app-name {
min-height: 34px;
font-size: 12px;
color: #333;
line-height: 1.4;
@@ -215,31 +220,26 @@ const entry = {
text-align: center;
}
/* 响应式设计 */
.app-empty {
grid-column: 1 / -1;
width: 100%;
}
@media (max-width: 768px) {
.tabs {
flex-wrap: wrap;
gap: 10px;
}
.tab-item {
padding: 8px 0;
font-size: 12px;
}
.app-grid {
grid-template-columns: repeat(2, 1fr);
gap: 15px;
grid-template-columns: repeat(3, minmax(72px, 1fr));
gap: 12px;
padding: 14px 10px;
}
.app-item {
padding: 12px;
padding: 8px 4px;
}
}
@media (max-width: 480px) {
.app-grid {
grid-template-columns: 1fr;
grid-template-columns: repeat(2, minmax(72px, 1fr));
gap: 12px;
}
}
@@ -11,13 +11,13 @@ layout("/layouts/v4/baseLayout.html"){
.section-banner .banner-img {
width: 100%;
height: 100%;
height: auto;
}
.section-banner .banner-img img {
width: 100%;
height: 100%;
object-fit: cover;
height: auto;
display: block;
}
.section-wrapper {
@@ -28,7 +28,7 @@ layout("/layouts/v4/baseLayout.html"){
width: 80%;
margin: 20px auto;
display: grid;
grid-template-columns: 2.2fr 0.8fr;
grid-template-columns: 1fr;
gap: 24px;
align-items: stretch;
row-gap: 24px;
@@ -38,16 +38,16 @@ layout("/layouts/v4/baseLayout.html"){
display: flex;
/*align-items: center;*/
min-width: 320px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
padding: 16px;
background: transparent;
border-radius: 0;
box-shadow: none;
padding: 0;
transition: transform 0.2s, box-shadow 0.2s;
}
.entry-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0,0,0,0.12);
transform: none;
box-shadow: none;
}
.act-card {
@@ -55,43 +55,89 @@ layout("/layouts/v4/baseLayout.html"){
align-items: center;
min-width: 320px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
padding: 16px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
padding: 11px;
transition: transform 0.2s, box-shadow 0.2s;
}
.home-section {
width: 100%;
}
.home-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.home-section-title {
display: inline-flex;
align-items: baseline;
gap: 6px;
color: #19324d;
font-size: 18px;
font-weight: 700;
}
.home-section-title i {
color: #409eff;
font-size: 16px;
}
.home-section-title em {
color: #b5c0d6;
font-size: 14px;
font-style: normal;
font-weight: 500;
}
</style>
<div class="v4-container" id="v4-home-app">
<!-- 首页banner -->
<div class="section-banner">
<div class="banner-img">
<img src="${config.AppHomeImg!}" alt=""/>
<img :src="homeBannerList[0] || ''" alt=""/>
<stats></stats>
</div>
</div>
<div class="">
<div class="" style="padding-bottom: 20px">
<user></user>
<div class="home-grid">
<div class="act-card">
<act></act>
</div>
<div class="entry-card">
<entry></entry>
</div>
</div>
<jcdt :list="websiteNews.grassroots"></jcdt>
<div class="home-grid">
<div class="home-section">
<div class="home-section-header">
<div class="home-section-title">
<i class="fa fa-calendar"></i>
<span>最新活动</span>
<em>/Activities</em>
</div>
</div>
<div class="act-card">
<act></act>
</div>
</div>
</div>
<work-template v-if="$auth.hasRoleOr(['SYSADMIN','SCHOOL_UNION_ADMIN'])"></work-template>
<jcdt :list="websiteNews"></jcdt>
</div>
</div>
<script>
<script nonce="${cspNonce!}">
<!--#include("act.js"){}#-->
<!--#include("template.js"){}#-->
<!--#include("jcdt.js"){}#-->
<!--#include("entry.js"){}#-->
<!--#include("user.js"){}#-->
@@ -102,13 +148,13 @@ layout("/layouts/v4/baseLayout.html"){
el: '#v4-home-app',
data(){
return{
websiteNews:{
grassroots:[],
}
websiteNews: [],
homeBannerList: "${config.AppHomeImg!}".split(",").map(item => item.trim()).filter(item => item)
}
},
components: {
'act': act,
'work-template': workTemplate,
'jcdt': jcdt,
'entry': entry,
'user': user,
@@ -2,22 +2,29 @@ const jcdt = {
template: /*language=HTML*/ `
<div class="jcdt-wrapper">
<div class="jcdt-container">
<!-- <div class="jcdt-title">-->
<!-- <span>基层动态</span>-->
<!-- </div> -->
<div class="jcdt-content">
<div class="jcdt-list">
<div class="jcdt-item" v-for="item in list" :key="item.title" @click="onLink(item)">
<div class="date" v-if="item.date">{{item.date}}</div>
<div class="title">{{item.title}}</div>
<div class="image">
<img v-if="item.image" :src="item.image" alt="">
<img v-else src="/assets/platform/img/v4/not-image.png" alt="">
</div>
<div class="summary">{{item.summary}}</div>
<el-carousel height="360px" :interval="10000" indicator-position="none">
<el-carousel-item v-for="item,index in list" :key="index">
<div class="jcdt-title">
<span>{{ item.label }}</span>
</div>
</div>
</div>
<div class="jcdt-content">
<div class="jcdt-list">
<div class="jcdt-item" v-for="o,i in item.value" :key="i" @click="onLink(o)">
<div class="date" v-if="o.time">
<i class="el-icon-alarm-clock"></i>
<span>{{o.time}}</span>
</div>
<div style="display: flex; align-items: center">
<div>
</div>
<div class="title">{{o.text}}</div>
</div>
</div>
</div>
</div>
</el-carousel-item>
</el-carousel>
</div>
</div>
`,
@@ -32,9 +39,12 @@ const jcdt = {
},
methods: {
onLink(item) {
if(!item.url) return
window.open(item.url)
const url = this.isHttpOrHttps(item.href) ? item.href : "https://xgh.cug.edu.cn/" + item.href
window.open(url)
},
isHttpOrHttps(url) {
return /^(http:\/\/|https:\/\/)/i.test(url)
}
},
style: /*language=CSS*/ `
.jcdt-wrapper {
@@ -47,16 +57,14 @@ const jcdt = {
.jcdt-container {
width: 80%;
max-width: 80%;
margin: 0 auto;
margin: 0 auto 10px;
background-color: #FFFFFF;
border-radius: 12px;
padding: 20px;
margin-bottom: 10px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.06);
}
.jcdt-title{
/deep/ .jcdt-title{
font-size: 30px;
color: #000000;
display: block;
@@ -87,14 +95,14 @@ const jcdt = {
margin-left: 0;
vertical-align: middle;
}
.jcdt-list {
/deep/ .jcdt-list {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
.jcdt-item {
/deep/ .jcdt-item {
display: flex;
flex-direction: column;
padding: 16px;
@@ -103,18 +111,17 @@ const jcdt = {
transition: all 0.3s ease;
cursor: pointer;
height: auto;
border-radius: 12px;
}
.jcdt-item:hover{
background: #ffffff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-bottom-color: var(--color-primary);
transform: translateY(-4px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
background: #ffffff;
}
.jcdt-item .date {
/deep/ .jcdt-item:hover{
transform: translateY(-4px);
}
/deep/ .jcdt-item .date {
font-size: 20px;
font-weight: bold;
white-space: nowrap;
@@ -123,19 +130,19 @@ const jcdt = {
transition: opacity 0.3s ease;
}
.jcdt-item .title {
/deep/ .jcdt-item .title {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
/*margin-bottom: 12px;*/
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
height: 45px;
/*height: 45px;*/
}
.jcdt-item .image {
/deep/ .jcdt-item .image {
width: 100%;
height: 180px;
margin: 0 0 16px 0;
@@ -149,14 +156,14 @@ const jcdt = {
transition: transform 0.3s ease;
}
.jcdt-item .image img {
/deep/ .jcdt-item .image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.jcdt-item .summary {
/deep/ .jcdt-item .summary {
font-size: 14px;
color: #666;
line-height: 1.6;
@@ -168,5 +175,8 @@ const jcdt = {
margin-bottom: 12px;
}
/deep/ .jcdt-item i {
color: #007aff;
}
`
};
@@ -7,66 +7,58 @@ const stats = {
<div class="stats-grid">
<!-- 待办 -->
<div
class="stat-item pending"
@click="handleStatClick('todo')"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
class="stat-item pending"
@click="handleStatClick('todo')"
>
<div class="stat-icon">
<i class="fa fa-clock-o"></i>
</div>
<div class="stat-content">
<div class="stat-label">待办</div>
<div class="stat-number">{{ stats.todoCount }}</div>
</div>
<div class="stat-icon">
<i class="fa fa-clock-o"></i>
</div>
</div>
<!-- 已办 -->
<div
class="stat-item completed"
@click="handleStatClick('done')"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
class="stat-item completed"
@click="handleStatClick('done')"
>
<div class="stat-icon">
<i class="fa fa-check-circle"></i>
</div>
<div class="stat-content">
<div class="stat-label">已办</div>
<div class="stat-number">{{ stats.doneCount }}</div>
</div>
<div class="stat-icon">
<i class="fa fa-check-circle"></i>
</div>
</div>
<!-- 消息 -->
<div
class="stat-item messages"
@click="handleStatClick('notification')"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
class="stat-item messages"
@click="handleStatClick('notification')"
>
<div class="stat-icon">
<i class="fa fa-bell"></i>
</div>
<div class="stat-content">
<div class="stat-label">消息</div>
<div class="stat-number">{{ stats.notifications }}</div>
</div>
<div class="stat-icon">
<i class="fa fa-bell"></i>
</div>
</div>
<!-- 发起 -->
<div
class="stat-item reminders"
@click="handleStatClick('started')"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
class="stat-item reminders"
@click="handleStatClick('started')"
>
<div class="stat-icon">
<i class="fa fa-file-text"></i>
</div>
<div class="stat-content">
<div class="stat-label">发起</div>
<div class="stat-number">{{ stats.startedCount }}</div>
</div>
<div class="stat-icon">
<i class="fa fa-file-text"></i>
</div>
</div>
</div>
</div>
@@ -90,15 +82,6 @@ const stats = {
}
},
onMouseEnter(event) {
event.target.style.transform = 'translateY(-2px)';
event.target.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)';
},
onMouseLeave(event) {
event.target.style.transform = 'none';
event.target.style.boxShadow = '0 2px 6px rgba(0,0,0,0.08)';
},
loadStats() {
this.$axios.post("/flow/todoCenter/statistics").then(res => {
if (res.code === 0) {
@@ -122,90 +105,128 @@ const stats = {
display: flex;
justify-content: space-between;
align-items: center;
margin: -130px auto 0;
margin: 20px auto 0;
/*border: 1px solid #e8e8e8;*/
}
/* 子内容临时屏蔽时,同步收起统计容器,避免首页 banner 下方出现空白 */
.stats-section {
display: none;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
width: 32%;
min-width: 360px;
margin-left: auto;
}
/* 临时屏蔽首页右上角“待办、已办、消息、发起”统计卡片,保留原模板便于恢复 */
.stats-grid {
display: none;
}
.stat-item {
background: #f8f9fa;
color: #495057;
padding: 14px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
color: #1f2937;
padding: 20px;
border-radius: 14px;
display: flex;
align-items: center;
gap: 10px;
justify-content: space-between;
gap: 16px;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
width: 200px;
transition: background 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12);
width: 100%;
height: 90px;
}
.stat-item:hover {
background: rgba(255, 255, 255, 0.62);
border-color: rgba(255, 255, 255, 0.55);
transform: translateY(-5px);
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.18);
}
.stat-icon {
font-size: 30px;
width: 50px;
height: 50px;
background: #e6f7ff;
color: #1890ff;
border-radius: 50%;
font-size: 26px;
width: 44px;
height: 44px;
background: rgba(255, 255, 255, 0.42);
color: rgba(31, 41, 55, 0.72);
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-item.pending .stat-icon,
.stat-item.completed .stat-icon,
.stat-item.messages .stat-icon,
.stat-item.reminders .stat-icon {
background: #e6f7ff;
color: #1890ff;
background: rgba(255, 255, 255, 0.42);
}
.stat-item.pending .stat-icon {
color: #2563eb;
}
.stat-item.completed .stat-icon {
color: #52c41a;
color: #16a34a;
}
.stat-item.messages .stat-icon {
color: #faad14;
color: #d97706;
}
.stat-item.reminders .stat-icon {
color: #13c2c2;
color: #0891b2;
}
.stat-content {
display: flex;
flex-direction: column;
margin-left: 10px;
margin-left: 0;
min-width: 0;
text-align: left;
}
.stat-label {
font-size: 15px;
opacity: 0.7;
font-size: 14px;
color: rgba(31, 41, 55, 0.78);
text-transform: uppercase;
letter-spacing: 0.5px;
letter-spacing: 1px;
line-height: 1.2;
}
.stat-number {
font-size: 18px;
font-weight: bold;
margin-top: 2px;
font-size: 28px;
font-weight: 700;
color: #111827;
margin-top: 6px;
line-height: 1;
}
.stats-family {
width: 68%;
}
.stats-family img {
height: 190px !important;
border-radius: 30px;
}
/* 临时屏蔽“欢迎来到职工之家”图片,保留原节点便于恢复 */
.stats-family {
display: none;
}
`
};
@@ -68,10 +68,10 @@ const user = {
<div class="header">
<h3>待办中心</h3>
<el-tabs v-model="activeTab" type="card" @tab-click="handleTabClick">
<el-tab-pane label="待办事宜" name="todo"></el-tab-pane>
<el-tab-pane label="已办事宜" name="done"></el-tab-pane>
<el-tab-pane label="我的发起" name="started"></el-tab-pane>
<el-tab-pane label="办结事务" name="completed"></el-tab-pane>
<el-tab-pane :label="tabLabel('待办事宜', taskStats.todoCount)" name="todo"></el-tab-pane>
<el-tab-pane :label="tabLabel('已办事宜', taskStats.doneCount)" name="done"></el-tab-pane>
<el-tab-pane :label="tabLabel('我的发起', taskStats.startedCount)" name="started"></el-tab-pane>
<el-tab-pane :label="tabLabel('办结事务', taskStats.completedCount)" name="completed"></el-tab-pane>
</el-tabs>
</div>
@@ -97,6 +97,7 @@ const user = {
<el-table-column prop="sender" label="申请人" width="120">
<template slot-scope="{row}">{{row.variable.initiatorName}}</template>
</el-table-column>
<el-table-column prop="taskName" label="当前流程节点" width="140" show-overflow-tooltip></el-table-column>
<el-table-column prop="date" label="发起日期" width="120">
<template slot-scope="{row}">
{{$moment(row.createdAt).format('YYYY-MM-DD')}}
@@ -108,6 +109,7 @@ const user = {
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</div>
</div>
</div>
@@ -123,6 +125,12 @@ const user = {
pageSize: 5,
totalCount: 0
},
taskStats: {
todoCount: 0,
doneCount: 0,
startedCount: 0,
completedCount: 0
},
// 🔆 天气相关数据
weatherData: null,
weatherError: false,
@@ -181,9 +189,10 @@ const user = {
return info;
},
async getConfigKey(key) {
const resp = await this.$axios.post("/open/common/getConfigKey", { key })
return resp.data
getConfigKey(key) {
return this.$axios.post("/open/common/getConfigKey", { key }).then((resp) => {
return resp.data
})
},
// 🔆 获取天气
async fetchWeather() {
@@ -191,6 +200,9 @@ const user = {
// 存localStorage 112.13,32.01 广州 113.27,23.14
const AppMapCenterPointX = await this.getConfigKey("AppMapCenterPointX")
const AppMapCenterPointY = await this.getConfigKey("AppMapCenterPointY")
if (!AppMapCenterPointX || !AppMapCenterPointY){
return
}
// 1. 先通过城市名获取 locationId
this.$axios.get("https://mx5rk62kvj.re.qweatherapi.com/geo/v2/city/lookup?location=" + AppMapCenterPointX + "," + AppMapCenterPointY + "&key=" + this.QWEATHER_KEY).then((geoRes) => {
@@ -224,6 +236,19 @@ const user = {
}
},
// 获取任务列表
tabLabel(title, count) {
return title + "" + (count || 0) + ""
},
async getStatistics() {
try {
const res = await $.post("/flow/todoCenter/statistics")
if (res.code === 0) {
this.taskStats = Object.assign({}, this.taskStats, res.data)
}
} catch (error) {
console.error("获取待办统计失败:", error)
}
},
async getTasks() {
this.loading = true
try {
@@ -241,6 +266,7 @@ const user = {
},
pageData() {
this.getTasks()
this.getStatistics()
},
handleTabClick(tab) {
this.pageData()
@@ -254,7 +280,7 @@ const user = {
this.$message.warning("当前流程没有配置地址")
return
}
window.open(formKey + "?taskId=" + taskId + "bizId=" + businessNo + "taskKey=" + taskKey)
window.open(formKey + "?taskId=" + taskId + "&bizId=" + businessNo + "&taskKey=" + taskKey + "&tab=" + this.activeTab)
},
getRoleNames() {
$.post("/platform/sys/role/getRoleNames").then(res => {
@@ -280,7 +306,7 @@ const user = {
.user-container {
width: 80%;
margin: 20px auto;
margin: 12px auto 20px;
display: grid;
grid-template-columns: 0.7fr 2.3fr;
gap: 24px;
+5 -5
View File
@@ -333,7 +333,7 @@ layout("/layouts/v4/baseLayout.html"){
methods: {
async loadStats() {
try {
const resp = await this.$axios.get('/platform/v4/msg/stats')
const resp = await Promise.resolve(this.$axios.get('/platform/v4/msg/stats'))
if (resp.code === 0) {
this.stats = resp.data
}
@@ -345,7 +345,7 @@ layout("/layouts/v4/baseLayout.html"){
async loadMessages() {
this.loading = true
try {
const resp = await this.$axios.post('/platform/v4/msg/pageData', this.pageForm)
const resp = await Promise.resolve(this.$axios.post('/platform/v4/msg/pageData', this.pageForm))
if (resp.code === 0) {
this.messages = resp.data.list
this.pageForm.totalCount = resp.data.totalCount
@@ -360,7 +360,7 @@ layout("/layouts/v4/baseLayout.html"){
},
async viewMessage(message) {
this.$axios.post(`/platform/v4/msg/detail/` + message.id).then(async res => {
this.$axios.post('/platform/v4/msg/detail/' + message.id).then(async res => {
if (res.code === 0) {
this.currentMessage = res.data
this.detailVisible = true
@@ -376,7 +376,7 @@ layout("/layouts/v4/baseLayout.html"){
async markAsRead(messageId) {
try {
await this.$axios.post(`/platform/v4/msg/read/` + messageId)
await Promise.resolve(this.$axios.post('/platform/v4/msg/read/' + messageId))
} catch (error) {
console.error('标记已读失败:', error)
}
@@ -390,7 +390,7 @@ layout("/layouts/v4/baseLayout.html"){
}).then(async () => {
this.loading = true
try {
const resp = await this.$axios.post('/platform/v4/msg/read/all')
const resp = await Promise.resolve(this.$axios.post('/platform/v4/msg/read/all'))
if (resp.code === 0) {
this.$message.success('操作成功')
this.loadMessages()
@@ -0,0 +1,9 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<el-card shadow="never">学习活动管理模块待完善</el-card>
</div>
<!--#
}
#-->
@@ -0,0 +1,883 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
.learning-outline-layout {
height: calc(100vh - 235px);
}
.learning-outline-tree {
height: 100%;
overflow: auto;
}
.outline-context-menu {
position: fixed;
z-index: 3000;
padding: 5px 0;
margin: 0;
list-style: none;
background: #fff;
border: 1px solid #dcdfe6;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, .1);
}
.outline-context-menu li {
min-width: 120px;
padding: 7px 18px;
cursor: pointer;
}
.outline-context-menu li:hover {
background: #f2f6fc;
}
</style>
<div id="app" v-cloak>
<el-card shadow="never">
<search :is-search-button="false">
<search-item label="课程名称">
<el-select
v-model="currentCourseId"
clearable
filterable
remote
:remote-method="courseNameRemoteSearch"
placeholder="请选择或搜索课程名称"
style="width: 100%"
@change="courseChange"
@clear="courseClear">
<el-option v-for="item in courseOptions" :key="item.id" :label="item.courseName" :value="item.id"></el-option>
</el-select>
</search-item>
<search-item label="课程类型">
<el-select v-model="courseQuery.courseTypeId" clearable filterable placeholder="请选择课程类型" style="width: 100%">
<el-option v-for="item in courseTypeOptions" :key="item.id" :label="item.typeName" :value="item.id"></el-option>
</el-select>
</search-item>
<search-item label="授课讲师">
<el-input v-model="courseQuery.lecturerName" clearable placeholder="请输入授课讲师" style="width: 100%" @keyup.enter.native="loadCourses"></el-input>
</search-item>
<search-item label="课程状态">
<el-select v-model="courseQuery.status" clearable placeholder="请选择课程状态" style="width: 100%">
<el-option v-for="item in statusOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</search-item>
<div class="search-query">
<el-button size="medium" type="primary" icon="el-icon-search" @click="loadCourses">搜索</el-button>
<el-button size="medium" @click="resetCourseQuery">重置</el-button>
</div>
</search>
</el-card>
<el-row :gutter="10" class="mt10 learning-outline-layout">
<el-col :span="6" style="height: 100%">
<el-card shadow="never" style="height: 100%">
<table-tool label="课程大纲">
<el-button size="mini" type="primary" icon="el-icon-plus" @click="openNodeForm('chapter')">新增章</el-button>
</table-tool>
<div class="learning-outline-tree">
<el-tree
ref="outlineTree"
:data="outlineTreeData"
node-key="id"
default-expand-all
highlight-current
:expand-on-click-node="false"
:props="{children:'children', label:'title'}"
@node-click="nodeClick"
@node-contextmenu="openContextMenu">
<span slot-scope="{ node, data }">
<i :class="data.nodeType === 'course' ? 'el-icon-collection' : (data.nodeType === 'chapter' ? 'el-icon-folder' : 'el-icon-document')"></i>
<span>{{data.title}}</span>
<el-tag v-if="data.status === 'disabled'" size="mini" type="info" style="margin-left: 6px">禁用</el-tag>
<el-tag v-if="data.required" size="mini" type="warning" style="margin-left: 6px">必学</el-tag>
</span>
</el-tree>
<el-empty v-if="treeData.length === 0" description="暂无课程大纲"></el-empty>
</div>
</el-card>
</el-col>
<el-col :span="18" style="height: 100%">
<el-card shadow="never" style="height: 100%; overflow: auto">
<el-empty v-if="!selectedNode.id" description="请选择左侧章/节节点"></el-empty>
<el-tabs v-else v-model="activeTab">
<el-tab-pane label="基础信息" name="basic">
<el-form :model="nodeForm" ref="nodeForm" label-width="110px" :rules="nodeRules">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="节点类型">
<el-tag :type="nodeForm.nodeType === 'chapter' ? 'primary' : 'success'">{{nodeForm.nodeType === 'chapter' ? '章' : '节'}}</el-tag>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序值" prop="sortOrder">
<el-input-number v-model="nodeForm.sortOrder" :controls="false" :min="1" :precision="0" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="标题" prop="title">
<el-input v-model="nodeForm.title" maxlength="100" placeholder="请输入章/节标题"></el-input>
</el-form-item>
<el-form-item label="副标题" prop="subtitle">
<el-input v-model="nodeForm.subtitle" maxlength="200" placeholder="请输入副标题"></el-input>
</el-form-item>
<el-form-item label="简介" prop="description">
<el-input v-model="nodeForm.description" type="textarea" :rows="5" maxlength="2000" show-word-limit placeholder="请输入章/节简介"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveNodeBasic">保存基础信息</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="学习资料" name="resource">
<table-tool label="学习资料">
<el-button size="mini" type="primary" icon="el-icon-plus" @click="openResourceForm()">新增资料</el-button>
</table-tool>
<el-table :data="resourceData" border @sort-change="resourcePageOrder">
<el-table-column label="序号" type="index" width="70" :index="resourceIndex"></el-table-column>
<el-table-column label="资料标题" prop="resourceTitle" sortable="custom" show-overflow-tooltip></el-table-column>
<el-table-column label="资料类型" prop="resourceType" sortable="custom" width="110">
<template slot-scope="{row}">{{getResourceTypeName(row.resourceType)}}</template>
</el-table-column>
<el-table-column label="文件格式" prop="fileExt" sortable="custom" width="110"></el-table-column>
<el-table-column label="学习时长(分钟)" prop="durationSeconds" sortable="custom" width="140">
<template slot-scope="{row}">{{formatDurationMinutes(row.durationSeconds)}}</template>
</el-table-column>
<el-table-column label="排序" prop="sortOrder" sortable="custom" width="90"></el-table-column>
<el-table-column label="必学" prop="required" sortable="custom" width="90">
<template slot-scope="{row}">
<el-tag :type="row.required ? 'warning' : 'info'" size="mini">{{row.required ? '是' : '否'}}</el-tag>
</template>
</el-table-column>
<el-table-column label="预览" prop="allowPreview" sortable="custom" width="90">
<template slot-scope="{row}">{{row.allowPreview ? '允许' : '不允许'}}</template>
</el-table-column>
<el-table-column label="下载" prop="allowDownload" sortable="custom" width="90">
<template slot-scope="{row}">{{row.allowDownload ? '允许' : '不允许'}}</template>
</el-table-column>
<el-table-column label="状态" prop="status" sortable="custom" width="90">
<template slot-scope="{row}">
<el-tag :type="row.status === 'enabled' ? 'success' : 'info'" size="mini">{{row.status === 'enabled' ? '启用' : '禁用'}}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150">
<template slot-scope="{row}">
<el-button size="mini" type="primary" @click="openResourceForm(row)">编辑</el-button>
<el-button size="mini" type="danger" @click="deleteResource(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="mt20"
@size-change="resourceSizeChange"
@current-change="resourceNumberChange"
:current-page="resourcePageForm.pageNumber"
:page-sizes="[5,10,20,30,50]"
:page-size="resourcePageForm.pageSize"
layout="total, sizes, prev, pager, next"
:total="resourcePageForm.totalCount">
</el-pagination>
</el-tab-pane>
<el-tab-pane label="完成规则" name="rule">
<el-form :model="ruleForm" label-width="150px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="是否必学">
<el-switch v-model="ruleForm.required"></el-switch>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="学习方式">
<el-select v-model="ruleForm.studyMode" style="width: 100%">
<el-option v-for="item in studyModeOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="完成规则">
<el-select v-model="ruleForm.completionRule" style="width: 100%">
<el-option v-for="item in completionRuleOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="完成比例">
<el-input-number v-model="ruleForm.completePercent" :min="0" :max="100" :precision="0" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="最少学习时长(分钟)">
<el-input-number v-model="ruleForm.minStudyMinutes" :min="0" :precision="0" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="解锁规则">
<el-select v-model="ruleForm.unlockRule" style="width: 100%">
<el-option v-for="item in unlockRuleOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8"><el-form-item label="允许跳过"><el-switch v-model="ruleForm.allowSkip"></el-switch></el-form-item></el-col>
<el-col :span="8"><el-form-item label="允许拖动"><el-switch v-model="ruleForm.allowDrag"></el-switch></el-form-item></el-col>
<el-col :span="8"><el-form-item label="暂停计时"><el-switch v-model="ruleForm.pauseCountTime"></el-switch></el-form-item></el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8"><el-form-item label="隐藏计时"><el-switch v-model="ruleForm.hiddenCountTime"></el-switch></el-form-item></el-col>
<el-col :span="8"><el-form-item label="无操作计时"><el-switch v-model="ruleForm.inactiveCountTime"></el-switch></el-form-item></el-col>
</el-row>
<el-form-item>
<el-button type="primary" @click="saveRule">保存完成规则</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="状态设置" name="status">
<el-form :model="nodeForm" label-width="110px">
<el-form-item label="节点状态">
<el-radio-group v-model="nodeForm.status">
<el-radio label="enabled">启用</el-radio>
<el-radio label="disabled">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="是否必学">
<el-switch v-model="nodeForm.required"></el-switch>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveNodeBasic">保存状态</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</el-card>
</el-col>
</el-row>
<ul v-show="contextVisible" class="outline-context-menu" :style="{left: contextLeft + 'px', top: contextTop + 'px'}">
<li @click="openNodeForm('chapter')">新增章</li>
<li v-if="contextNode.nodeType === 'chapter'" @click="openNodeForm('section', contextNode)">新增节</li>
<li v-if="contextNode.nodeType !== 'course'" @click="openNodeForm(contextNode.nodeType, contextNode)">编辑</li>
<li v-if="contextNode.nodeType !== 'course'" @click="deleteNode(contextNode)">删除</li>
<li v-if="contextNode.nodeType !== 'course'" @click="toggleNode(contextNode)">{{contextNode.status === 'enabled' ? '禁用' : '启用'}}</li>
<li v-if="contextNode.nodeType !== 'course'" @click="moveNode(contextNode, 'up')">上移</li>
<li v-if="contextNode.nodeType !== 'course'" @click="moveNode(contextNode, 'down')">下移</li>
<li v-if="contextNode.nodeType !== 'course'" @click="openResourceForm(null, contextNode)">上传资料</li>
<li v-if="contextNode.nodeType !== 'course'" @click="setRequired(contextNode)">设置必学</li>
</ul>
<el-dialog :title="nodeDialogTitle" :visible.sync="nodeDialogVisible" :close-on-click-modal="false" width="45%">
<el-form :model="nodeDialogForm" ref="nodeDialogForm" label-width="100px" :rules="nodeRules">
<el-form-item label="节点类型">
<el-tag>{{nodeDialogForm.nodeType === 'chapter' ? '章' : '节'}}</el-tag>
</el-form-item>
<el-form-item label="上级节点">
<el-input v-model="nodeDialogForm.parentName" disabled placeholder="上级节点"></el-input>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="nodeDialogForm.title" maxlength="100" placeholder="请输入标题"></el-input>
</el-form-item>
<el-form-item label="副标题">
<el-input v-model="nodeDialogForm.subtitle" maxlength="200" placeholder="请输入副标题"></el-input>
</el-form-item>
<el-form-item label="简介">
<el-input v-model="nodeDialogForm.description" type="textarea" :rows="4" maxlength="2000" show-word-limit></el-input>
</el-form-item>
<el-form-item label="排序值">
<el-input-number v-model="nodeDialogForm.sortOrder" :min="1" :precision="0" style="width: 100%"></el-input-number>
</el-form-item>
<el-form-item label="是否必学">
<el-switch v-model="nodeDialogForm.required"></el-switch>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="nodeDialogForm.status">
<el-radio label="enabled">启用</el-radio>
<el-radio label="disabled">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="nodeDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitNodeDialog">确定</el-button>
</span>
</el-dialog>
<el-dialog :title="resourceForm.id ? '编辑学习资料' : '新增学习资料'" :visible.sync="resourceDialogVisible" :close-on-click-modal="false" width="55%">
<el-form :model="resourceForm" ref="resourceForm" label-width="120px" :rules="resourceRules">
<el-form-item label="所属章节">
<el-input v-model="resourceForm.outlineName" disabled placeholder="所属章节"></el-input>
</el-form-item>
<el-form-item label="资料标题" prop="resourceTitle">
<el-input v-model="resourceForm.resourceTitle" maxlength="100" placeholder="请输入资料标题"></el-input>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="资料类型" prop="resourceType">
<el-select v-model="resourceForm.resourceType" placeholder="请选择资料类型" style="width: 100%" @change="resourceTypeChange">
<el-option v-for="item in resourceTypeOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序值" prop="sortOrder">
<el-input-number v-model="resourceForm.sortOrder" :min="1" :precision="0" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="学习时长(分钟)">
<el-input-number v-model="resourceForm.durationMinutes" :min="0" :precision="0" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态">
<el-radio-group v-model="resourceForm.status">
<el-radio label="enabled">启用</el-radio>
<el-radio label="disabled">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8"><el-form-item label="是否必学"><el-switch v-model="resourceForm.required"></el-switch></el-form-item></el-col>
<el-col :span="8"><el-form-item label="允许预览"><el-switch v-model="resourceForm.allowPreview"></el-switch></el-form-item></el-col>
<el-col :span="8"><el-form-item label="允许下载"><el-switch v-model="resourceForm.allowDownload"></el-switch></el-form-item></el-col>
</el-row>
<el-form-item label="附件" prop="fileData">
<file-upload
:value.sync="resourceForm.fileData"
:upload_number="1"
upload_mode="drag"
upload_result_category="array"
complete_result
:accept="fileAccept">
</file-upload>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="resourceDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitResource">确定</el-button>
</span>
</el-dialog>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
mixins: [initTableMixins],
data() {
return {
courseQuery: {},
courseOptions: [],
courseTypeOptions: [],
currentCourseId: "",
treeData: [],
selectedNode: {},
activeTab: "basic",
nodeForm: {},
nodeDialogVisible: false,
nodeDialogTitle: "",
nodeDialogForm: {},
nodeRules: {
title: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
sortOrder: [{ required: true, message: "必填", trigger: ["blur", "change"] }]
},
contextVisible: false,
contextLeft: 0,
contextTop: 0,
contextNode: {},
resourceData: [],
resourcePageForm: {
pageNumber: 1,
pageSize: 10,
totalCount: 0,
pageOrderName: "",
pageOrderBy: ""
},
resourceDialogVisible: false,
resourceForm: {},
resourceRules: {
resourceTitle: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
resourceType: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
sortOrder: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
fileData: [{ required: true, message: "请上传附件", trigger: ["blur", "change"] }]
},
fileAccept: "",
ruleForm: {},
statusOptions: [
{ name: "草稿", code: "draft" },
{ name: "待发布", code: "pending" },
{ name: "已发布", code: "published" },
{ name: "已下架", code: "offline" }
],
resourceTypeOptions: [
{ name: "视频", code: "video", accept: ".mp4,.mov" },
{ name: "音频", code: "audio", accept: ".mp3,.wav,.aac" },
{ name: "PDF", code: "pdf", accept: ".pdf" },
{ name: "PPT课件", code: "ppt", accept: ".ppt,.pptx" },
{ name: "Word文档", code: "word", accept: ".doc,.docx" },
{ name: "图片", code: "image", accept: ".jpg,.jpeg,.png" },
{ name: "其他", code: "other", accept: "" }
],
studyModeOptions: [
{ name: "视频", code: "video" },
{ name: "音频", code: "audio" },
{ name: "文档", code: "document" },
{ name: "图片", code: "image" },
{ name: "混合资料", code: "mixed" }
],
completionRuleOptions: [
{ name: "完成所有必学资料", code: "all_required_resource" },
{ name: "按资料学习进度", code: "resource_progress" },
{ name: "达到最少学习时长", code: "min_study_time" },
{ name: "管理员手动确认", code: "manual" }
],
unlockRuleOptions: [
{ name: "自由学习", code: "free" },
{ name: "顺序解锁", code: "sequential" }
]
}
},
computed: {
currentCourse() {
return this.courseOptions.find(v => v.id === this.currentCourseId)
},
outlineTreeData() {
if (!this.currentCourseId || !this.currentCourse) {
return []
}
return [{
id: "course-" + this.currentCourseId,
courseId: this.currentCourseId,
title: this.currentCourse.courseName,
nodeType: "course",
children: this.treeData
}]
}
},
methods: {
resetCourseQuery() {
this.courseQuery = {}
this.currentCourseId = ""
this.loadCourses()
},
getQueryParam(name) {
return new URLSearchParams(window.location.search).get(name) || ""
},
async loadCourseTypes() {
const resp = await axios.post(loc() + "/courseTypes")
if (resp.code === 0) {
this.courseTypeOptions = resp.data
}
},
async loadCourses(keepCurrent = false) {
const resp = await axios.post(loc() + "/courses", this.courseQuery)
if (resp.code === 0) {
this.courseOptions = resp.data
if (!keepCurrent && (!this.currentCourseId || !this.courseOptions.some(v => v.id === this.currentCourseId))) {
this.currentCourseId = this.courseOptions.length ? this.courseOptions[0].id : ""
}
await this.loadTree()
}
},
courseNameRemoteSearch(query) {
this.courseQuery.courseName = query
this.loadCourses(true)
},
courseChange() {
this.selectedNode = {}
this.loadTree()
},
courseClear() {
this.currentCourseId = ""
this.selectedNode = {}
this.treeData = []
},
async loadTree() {
if (!this.currentCourseId) {
this.treeData = []
this.selectedNode = {}
return
}
const resp = await axios.post(loc() + "/tree", { courseId: this.currentCourseId })
if (resp.code === 0) {
this.treeData = resp.data
let nodeToSelect = null
if (this.selectedNode.id) {
nodeToSelect = this.findNode(this.treeData, this.selectedNode.id)
}
if (!nodeToSelect) {
nodeToSelect = this.findFirstOutlineNode(this.treeData)
}
if (nodeToSelect) {
this.nodeClick(nodeToSelect)
this.$nextTick(() => {
if (this.$refs.outlineTree) {
this.$refs.outlineTree.setCurrentKey(nodeToSelect.id)
}
})
} else {
this.selectedNode = {}
this.nodeForm = {}
this.resourceData = []
}
}
},
findFirstOutlineNode(nodes) {
if (!nodes || !nodes.length) return null
for (const node of nodes) {
if (node.nodeType !== "course") return node
const child = this.findFirstOutlineNode(node.children || [])
if (child) return child
}
return null
},
findNode(nodes, id) {
for (const node of nodes) {
if (node.id === id) return node
const child = this.findNode(node.children || [], id)
if (child) return child
}
return null
},
getParentName(type, node, course, isEdit) {
if (type === "chapter") {
return course ? course.courseName : ""
}
if (!isEdit) {
return node ? node.title : ""
}
const parent = node && node.parentId ? this.findNode(this.treeData, node.parentId) : null
return parent ? parent.title : (course ? course.courseName : "")
},
nodeClick(data) {
if (data.nodeType === "course") {
this.selectedNode = {}
this.nodeForm = {}
this.resourceData = []
return
}
this.selectedNode = data
this.nodeForm = Object.assign({}, data)
this.activeTab = "basic"
this.loadResources()
this.loadRule()
},
openContextMenu(event, data) {
event.preventDefault()
this.contextNode = data
this.contextVisible = true
this.contextLeft = event.clientX
this.contextTop = event.clientY
},
openNodeForm(type, node = null) {
if (!this.currentCourseId) {
this.$message.warning("请先选择课程")
return
}
const course = this.courseOptions.find(v => v.id === this.currentCourseId)
if (node && node.id && node.nodeType === type) {
this.nodeDialogTitle = "编辑" + (type === "chapter" ? "章" : "节")
this.nodeDialogForm = Object.assign({}, node, {
parentName: this.getParentName(type, node, course, true)
})
} else {
this.nodeDialogTitle = "新增" + (type === "chapter" ? "章" : "节")
this.nodeDialogForm = {
courseId: this.currentCourseId,
courseName: course ? course.courseName : "",
parentId: type === "section" && node ? node.id : "",
parentName: this.getParentName(type, node, course, false),
nodeType: type,
title: "",
subtitle: "",
description: "",
sortOrder: 1,
required: false,
status: "enabled"
}
}
this.nodeDialogVisible = true
this.$nextTick(() => this.$refs.nodeDialogForm && this.$refs.nodeDialogForm.clearValidate())
},
submitNodeDialog() {
this.$refs.nodeDialogForm.validate(async valid => {
if (!valid) return
const resp = await axios.post(loc() + "/saveNode", this.nodeDialogForm)
if (resp.code === 0) {
this.$message.success(resp.msg)
this.nodeDialogVisible = false
await this.loadTree()
} else {
this.$message.warning(resp.msg)
}
})
},
saveNodeBasic() {
this.$refs.nodeForm.validate(async valid => {
if (!valid) return
const resp = await axios.post(loc() + "/saveNode", this.nodeForm)
if (resp.code === 0) {
this.$message.success(resp.msg)
this.selectedNode = Object.assign({}, this.nodeForm)
await this.loadTree()
await this.loadRule()
} else {
this.$message.warning(resp.msg)
}
})
},
deleteNode(node) {
this.$confirm("确定要删除该节点吗?若下级有内容将不允许删除。", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(async () => {
const resp = await axios.post(loc() + "/deleteNode", { id: node.id })
if (resp.code === 0) {
this.$message.success(resp.msg)
this.selectedNode = {}
await this.loadTree()
} else {
this.$message.warning(resp.msg)
}
})
},
async toggleNode(node) {
const status = node.status === "enabled" ? "disabled" : "enabled"
const resp = await axios.post(loc() + "/toggleNode", { id: node.id, status })
if (resp.code === 0) {
this.$message.success(resp.msg)
await this.loadTree()
}
},
async moveNode(node, direction) {
const resp = await axios.post(loc() + "/moveNode", { id: node.id, direction })
if (resp.code === 0) {
await this.loadTree()
}
},
async setRequired(node) {
const next = !node.required
const data = Object.assign({}, node, { required: next })
const resp = await axios.post(loc() + "/saveNode", data)
if (resp.code === 0) {
this.$message.success(resp.msg)
await this.loadTree()
if (this.selectedNode.id === node.id) {
this.nodeClick(Object.assign({}, node, { required: next }))
}
}
},
async loadResources() {
if (!this.selectedNode.id) return
const resp = await axios.post(loc() + "/resourcePage", Object.assign({}, this.resourcePageForm, { outlineId: this.selectedNode.id }))
if (resp.code === 0) {
this.resourceData = resp.data.list
this.resourcePageForm.totalCount = resp.data.totalCount
}
},
resourcePageOrder(column) {
this.resourcePageForm.pageOrderName = column.prop
this.resourcePageForm.pageOrderBy = column.order
this.loadResources()
},
resourceSizeChange(val) {
this.resourcePageForm.pageSize = val
this.loadResources()
},
resourceNumberChange(val) {
this.resourcePageForm.pageNumber = val
this.loadResources()
},
resourceIndex(index) {
return index + (this.resourcePageForm.pageNumber - 1) * this.resourcePageForm.pageSize + 1
},
openResourceForm(row = null, node = null) {
const target = node || this.selectedNode
if (!target.id) {
this.$message.warning("请先选择章/节节点")
return
}
if (row) {
this.resourceForm = Object.assign({}, row, {
outlineName: target.title,
fileData: this.parseFiles(row.fileData),
durationMinutes: this.formatDurationMinutes(row.durationSeconds)
})
} else {
this.resourceForm = {
courseId: target.courseId,
outlineId: target.id,
outlineName: target.title,
resourceTitle: "",
resourceType: "video",
fileExt: "",
fileData: [],
durationSeconds: 0,
durationMinutes: 0,
sortOrder: 1,
required: false,
allowPreview: true,
allowDownload: false,
status: "enabled"
}
}
this.resourceTypeChange(this.resourceForm.resourceType)
this.resourceDialogVisible = true
this.$nextTick(() => this.$refs.resourceForm && this.$refs.resourceForm.clearValidate())
},
resourceTypeChange(val) {
const type = this.resourceTypeOptions.find(v => v.code === val)
this.fileAccept = type ? type.accept : ""
this.autoFillMediaDuration()
},
submitResource() {
this.$refs.resourceForm.validate(async valid => {
if (!valid) return
const data = Object.assign({}, this.resourceForm)
data.durationSeconds = Math.round((data.durationMinutes || 0) * 60)
data.fileExt = this.getFileExt(data.fileData)
if (!this.validResourceExt(data.resourceType, data.fileExt)) {
this.$message.warning("当前资料类型不支持上传 ." + data.fileExt + " 格式文件")
return
}
delete data.outlineName
delete data.durationMinutes
data.fileData = JSON.stringify(data.fileData || [])
const resp = await axios.post(loc() + "/saveResource", data)
if (resp.code === 0) {
this.$message.success(resp.msg)
this.resourceDialogVisible = false
this.loadResources()
} else {
this.$message.warning(resp.msg)
}
})
},
deleteResource(row) {
this.$confirm("确定要删除该学习资料吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(async () => {
const resp = await axios.post(loc() + "/deleteResource", { id: row.id })
if (resp.code === 0) {
this.$message.success(resp.msg)
this.loadResources()
}
})
},
async loadRule() {
if (!this.selectedNode.id) return
const resp = await axios.post(loc() + "/getRule", {
courseId: this.selectedNode.courseId,
targetType: "outline",
targetId: this.selectedNode.id
})
if (resp.code === 0) {
this.ruleForm = Object.assign({}, resp.data, {
minStudyMinutes: this.formatDurationMinutes(resp.data.minStudySeconds)
})
}
},
async saveRule() {
const data = Object.assign({}, this.ruleForm, {
minStudySeconds: Math.round((this.ruleForm.minStudyMinutes || 0) * 60)
})
delete data.minStudyMinutes
const resp = await axios.post(loc() + "/saveRule", data)
if (resp.code === 0) {
this.$message.success(resp.msg)
await this.loadTree()
await this.loadRule()
} else {
this.$message.warning(resp.msg)
}
},
parseFiles(value) {
if (!value) return []
if (Array.isArray(value)) return value
try {
return JSON.parse(value)
} catch (e) {
return []
}
},
getFileUrl(files) {
const file = Array.isArray(files) && files.length ? files[0] : null
if (!file) return ""
return file.url || file.response?.data || file.data || ""
},
getFileExt(files) {
const file = Array.isArray(files) && files.length ? files[0] : null
if (!file) return ""
const name = file.name || file.url || file.response?.data || ""
const index = name.lastIndexOf(".")
return index > -1 ? name.substring(index + 1).toLowerCase() : ""
},
formatDurationMinutes(durationSeconds) {
const seconds = Number(durationSeconds || 0)
return seconds > 0 ? Math.ceil(seconds / 60) : 0
},
autoFillMediaDuration() {
if (!["video", "audio"].includes(this.resourceForm.resourceType)) return
const url = this.getFileUrl(this.resourceForm.fileData)
if (!url) return
const media = document.createElement(this.resourceForm.resourceType === "video" ? "video" : "audio")
media.preload = "metadata"
media.onloadedmetadata = () => {
window.URL.revokeObjectURL(media.src)
if (isFinite(media.duration) && media.duration > 0) {
this.$set(this.resourceForm, "durationMinutes", Math.ceil(media.duration / 60))
}
}
media.onerror = () => {
window.URL.revokeObjectURL(media.src)
}
media.src = url
},
validResourceExt(resourceType, fileExt) {
const item = this.resourceTypeOptions.find(v => v.code === resourceType)
if (!item || !item.accept) return true
return item.accept.split(",").map(v => v.replace(".", "").toLowerCase()).includes(fileExt)
},
getResourceTypeName(code) {
const item = this.resourceTypeOptions.find(v => v.code === code)
return item ? item.name : code
}
},
watch: {
"resourceForm.fileData": {
handler() {
this.autoFillMediaDuration()
},
deep: true
}
},
mounted() {
document.addEventListener("click", () => this.contextVisible = false)
},
async created() {
await this.loadCourseTypes()
const courseId = this.getQueryParam("courseId")
if (courseId) {
this.currentCourseId = courseId
this.courseQuery.courseId = courseId
await this.loadCourses(true)
this.$delete(this.courseQuery, "courseId")
} else {
await this.loadCourses()
}
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,12 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<el-card shadow="never">
<div slot="header">课程介绍</div>
<el-empty description="课程介绍页面后续设计"></el-empty>
</el-card>
</div>
<!--#
}
#-->
@@ -0,0 +1,350 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
.course-display {
padding: 8px 10px 22px;
background: #f4f5f7;
min-height: calc(100vh - 88px);
}
.course-filter {
background: #fff;
border: 1px solid #e5e8ef;
margin-bottom: 28px;
}
.filter-row {
display: flex;
min-height: 70px;
border-bottom: 1px solid #e8ebf0;
}
.filter-row:last-child {
border-bottom: 0;
}
.filter-label {
width: 132px;
padding: 20px 16px;
background: #eef5ff;
color: #1f2d3d;
font-weight: 600;
text-align: center;
}
.filter-options {
flex: 1;
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: 10px 18px;
padding: 16px 18px;
}
.filter-chip {
min-width: 76px;
height: 32px;
padding: 0 18px;
border: 0;
border-radius: 9px;
background: transparent;
color: #526070;
cursor: pointer;
line-height: 32px;
text-align: center;
}
.filter-chip.active {
background: #1e63e9;
color: #fff;
font-weight: 600;
}
.course-result-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
}
.course-count {
color: #1f2d3d;
}
.course-count span {
color: #2468f2;
padding: 0 4px;
}
.course-search {
width: 310px;
}
.course-list {
background: #fff;
border-top: 1px solid #dfe4ec;
}
.course-item {
display: flex;
gap: 20px;
padding: 18px 0;
border-bottom: 1px solid #dfe4ec;
}
.course-cover {
position: relative;
width: 270px;
height: 150px;
flex: 0 0 270px;
background: #e9eef5;
border-radius: 4px;
overflow: hidden;
}
.course-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.course-cover-empty {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #8a97a8;
background: linear-gradient(135deg, #eaf0f8, #d8e3f2);
font-weight: 600;
}
.course-cover-tag {
position: absolute;
right: 0;
bottom: 12px;
min-width: 70px;
height: 24px;
padding: 0 10px;
background: #f5a400;
color: #fff;
font-size: 12px;
line-height: 24px;
text-align: center;
border-radius: 12px 0 0 12px;
}
.course-info {
flex: 1;
min-width: 0;
padding-right: 12px;
}
.course-title {
display: inline-block;
margin: 4px 0 8px;
color: #111827;
font-size: 18px;
font-weight: 700;
cursor: pointer;
}
.course-title:hover {
color: #006fc9;
}
.course-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
color: #8b96a6;
font-size: 13px;
margin-bottom: 12px;
}
.course-intro {
color: #4f5f73;
line-height: 1.8;
margin-bottom: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.course-time {
color: #8b96a6;
font-size: 13px;
}
.recommend-tags {
display: inline-flex;
gap: 6px;
margin-left: 8px;
vertical-align: 2px;
}
.course-empty {
background: #fff;
padding: 80px 0;
text-align: center;
color: #8b96a6;
border-top: 1px solid #dfe4ec;
}
</style>
<div id="app" class="course-display" v-cloak>
<div class="course-filter">
<div class="filter-row">
<div class="filter-label">推荐标识</div>
<div class="filter-options">
<button class="filter-chip" :class="{active: !query.recommendFlag}" @click="selectRecommend('')">全部</button>
<button
v-for="item in recommendOptions"
:key="item.code"
class="filter-chip"
:class="{active: query.recommendFlag === item.code}"
@click="selectRecommend(item.code)">
{{item.name}}
</button>
</div>
</div>
<div class="filter-row">
<div class="filter-label">课程类型</div>
<div class="filter-options">
<button class="filter-chip" :class="{active: !query.courseTypeId}" @click="selectCourseType('')">全部</button>
<button
v-for="item in courseTypeOptions"
:key="item.id"
class="filter-chip"
:class="{active: query.courseTypeId === item.id}"
@click="selectCourseType(item.id)">
{{item.typeName}}
</button>
</div>
</div>
</div>
<div class="course-result-bar">
<div class="course-count">为您找到相关课程<span>{{pageForm.totalCount || 0}}</span></div>
<el-input
class="course-search"
v-model="query.keyword"
clearable
placeholder="搜索关键字"
prefix-icon="el-icon-search"
@keyup.enter.native="doSearch"
@clear="doSearch">
<el-button slot="append" icon="el-icon-search" @click="doSearch"></el-button>
</el-input>
</div>
<div v-if="pageForm.list && pageForm.list.length" class="course-list">
<div v-for="course in pageForm.list" :key="course.id" class="course-item">
<div class="course-cover">
<img v-if="getCoverUrl(course.cover)" :src="getCoverUrl(course.cover)" :alt="course.courseName">
<div v-else class="course-cover-empty">{{course.courseTypeName || '课程'}}</div>
<div v-if="firstRecommendName(course.recommendFlags)" class="course-cover-tag">{{firstRecommendName(course.recommendFlags)}}</div>
</div>
<div class="course-info">
<div>
<span class="course-title" @click="openDetail(course)">{{course.courseName}}</span>
<span class="recommend-tags">
<el-tag v-for="code in splitFlags(course.recommendFlags)" :key="code" size="mini" type="warning">{{getRecommendName(code)}}</el-tag>
</span>
</div>
<div class="course-meta">
<span><i class="el-icon-office-building"></i> {{course.courseTypeName || '未设置课程类型'}}</span>
<span><i class="el-icon-user"></i> {{course.lecturerName || '未设置讲师'}}</span>
</div>
<div class="course-intro">{{course.courseIntro || '暂无课程介绍'}}</div>
<div class="course-time">
<i class="el-icon-time"></i>
<span v-if="course.openType === 'long_term'">长期开放</span>
<span v-else>{{course.startTimeText || '-'}} 至 {{course.endTimeText || '-'}}</span>
</div>
</div>
</div>
</div>
<div v-else class="course-empty">暂无相关课程</div>
<el-pagination
class="mt20"
background
layout="total, sizes, prev, pager, next"
:current-page.sync="pageForm.pageNumber"
:page-size.sync="pageForm.pageSize"
:total="pageForm.totalCount"
@size-change="pageData"
@current-change="pageData">
</el-pagination>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
query: {
keyword: "",
courseTypeId: "",
recommendFlag: ""
},
courseTypeOptions: [],
recommendOptions: [],
pageForm: {
pageNumber: 1,
pageSize: 10,
totalCount: 0,
list: []
}
}
},
methods: {
async pageData() {
const resp = await axios.post(loc() + "/pageData", Object.assign({}, this.query, {
pageNumber: this.pageForm.pageNumber,
pageSize: this.pageForm.pageSize
}))
if (resp.code === 0) {
this.pageForm = resp.data
}
},
doSearch() {
this.pageForm.pageNumber = 1
this.pageData()
},
selectRecommend(code) {
this.query.recommendFlag = code
this.doSearch()
},
selectCourseType(id) {
this.query.courseTypeId = id
this.doSearch()
},
async loadOptions() {
const typeResp = await axios.post(loc() + "/courseTypes")
if (typeResp.code === 0) {
this.courseTypeOptions = typeResp.data
}
const recommendResp = await axios.post(loc() + "/recommendOptions")
if (recommendResp.code === 0) {
this.recommendOptions = recommendResp.data
}
},
getCoverUrl(cover) {
if (!cover) return ""
if (Array.isArray(cover)) {
return cover.length ? (cover[0].url || cover[0].response?.data || cover[0].data || "") : ""
}
if (typeof cover === "string" && cover.trim().startsWith("[")) {
try {
const files = JSON.parse(cover)
return files.length ? (files[0].url || files[0].response?.data || files[0].data || "") : ""
} catch (e) {
return ""
}
}
return cover
},
splitFlags(flags) {
return flags ? flags.split(",").filter(Boolean) : []
},
getRecommendName(code) {
const item = this.recommendOptions.find(v => v.code === code)
return item ? item.name : code
},
firstRecommendName(flags) {
const codes = this.splitFlags(flags)
return codes.length ? this.getRecommendName(codes[0]) : ""
},
openDetail(course) {
window.location.href = loc() + "/study?id=" + course.id
}
},
async created() {
await this.loadOptions()
await this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,755 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
#sidebar-menu,
#menu-toggle-btn,
#menu-overlay {
display: none !important;
}
#sub-app-container-main-content {
padding: 0 !important;
}
#sub-app-container-main-content-body {
height: 100%;
}
.study-page {
min-height: calc(100vh - 50px);
background: #eef3f8;
}
.study-header {
height: 150px;
margin: 12px 14px 0;
padding: 20px 24px;
color: #1f2937;
background: linear-gradient(120deg, #ffffff 0%, #f4f8ff 58%, #eaf2ff 100%);
border: 1px solid #dbe7f5;
border-radius: 10px;
box-shadow: 0 6px 18px rgba(31, 64, 116, .08);
display: flex;
align-items: center;
gap: 22px;
box-sizing: border-box;
}
.study-back {
align-self: flex-start;
margin-left: auto;
order: 5;
color: #1e63c8;
border-color: #b8d0f2;
background: #fff;
}
.study-cover {
width: 190px;
height: 102px;
object-fit: cover;
background: #eef4fb;
border: 1px solid #d7e3f1;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(31, 64, 116, .12);
}
.study-cover-empty {
width: 190px;
height: 102px;
display: flex;
align-items: center;
justify-content: center;
background: #eef4fb;
color: #6b7d90;
border: 1px solid #d7e3f1;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(31, 64, 116, .12);
font-weight: 600;
}
.study-course-main {
flex: 1;
min-width: 0;
}
.study-title {
font-size: 28px;
line-height: 1.2;
font-weight: 700;
margin-bottom: 14px;
}
.study-title .el-tag {
margin-left: 8px;
vertical-align: 5px;
}
.study-meta {
display: flex;
flex-wrap: wrap;
gap: 10px 26px;
color: #526376;
font-size: 14px;
}
.study-body {
display: flex;
gap: 14px;
height: calc(100vh - 212px);
padding: 14px;
box-sizing: border-box;
}
.study-tree-panel {
width: 340px;
background: #fff;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.study-tree-title {
height: 54px;
padding: 0 18px;
display: flex;
align-items: center;
justify-content: space-between;
color: #1768e5;
font-size: 18px;
font-weight: 700;
border-bottom: 1px solid #edf1f7;
}
.study-tree {
flex: 1;
overflow: auto;
padding: 10px;
}
.study-node {
display: inline-flex;
align-items: center;
max-width: 100%;
gap: 6px;
}
.study-node-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.study-content {
flex: 1;
min-width: 0;
background: #fff;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.resource-head {
min-height: 54px;
padding: 0 18px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #edf1f7;
}
.resource-title {
color: #1f2937;
font-size: 16px;
font-weight: 600;
}
.resource-actions {
display: flex;
align-items: center;
gap: 10px;
}
.study-progress {
width: 180px;
}
.resource-view {
flex: 1;
overflow: auto;
padding: 20px;
background: #f8fafc;
}
.media-player {
width: 100%;
max-height: calc(100vh - 300px);
background: #000;
}
.audio-wrap {
display: flex;
align-items: center;
justify-content: center;
height: 320px;
background: linear-gradient(135deg, #132544, #0a1224);
border-radius: 6px;
}
.audio-wrap audio {
width: 80%;
}
.image-preview {
max-width: 100%;
display: block;
margin: 0 auto;
background: #fff;
}
.doc-frame {
width: 100%;
height: calc(100vh - 275px);
border: 0;
background: #fff;
}
.file-fallback {
padding: 60px 20px;
text-align: center;
color: #667085;
background: #fff;
border-radius: 6px;
}
</style>
<div id="app" class="study-page" v-cloak>
<div class="study-header">
<el-button class="study-back" icon="el-icon-back" size="small" @click="goBack">返回</el-button>
<img v-if="coverUrl" class="study-cover" :src="coverUrl" :alt="course.courseName">
<div v-else class="study-cover-empty">课程图片</div>
<div class="study-course-main">
<div class="study-title">
{{course.courseName || '课程学习'}}
<el-tag v-for="code in splitFlags(course.recommendFlags)" :key="code" size="small" type="warning">{{getRecommendName(code)}}</el-tag>
</div>
<div class="study-meta">
<span><i class="el-icon-user"></i> 授课讲师:{{course.lecturerName || '未设置'}}</span>
<span><i class="el-icon-date"></i> {{course.openType === 'long_term' ? '长期开放' : ((course.startTimeText || '-') + ' 至 ' + (course.endTimeText || '-'))}}</span>
<span><i class="el-icon-collection-tag"></i> 课程类型:{{course.courseTypeName || '未设置'}}</span>
</div>
</div>
</div>
<div class="study-body">
<div class="study-tree-panel">
<div class="study-tree-title">
<span>课程安排</span>
<el-button type="text" size="mini" @click="collapseAll">收起</el-button>
</div>
<div class="study-tree">
<el-tree
ref="studyTree"
:data="treeData"
node-key="id"
default-expand-all
highlight-current
:expand-on-click-node="false"
:props="{children:'children', label:'title'}"
@node-click="nodeClick">
<span slot-scope="{ data }" class="study-node">
<i :class="getNodeIcon(data)"></i>
<span class="study-node-title">{{data.title}}</span>
<el-tag v-if="data.required" size="mini" type="warning">必学</el-tag>
</span>
</el-tree>
</div>
</div>
<div class="study-content">
<div class="resource-head">
<div class="resource-title">{{selectedResource.title || '请选择课程资料'}}</div>
<div class="resource-actions">
<el-progress
v-if="selectedResource.id"
class="study-progress"
:percentage="recordProgress"
:stroke-width="8">
</el-progress>
<el-tag v-if="studying" size="mini" type="success">学习中 {{recordStudyTime}}</el-tag>
<el-button v-if="selectedResource.id && !studying" size="mini" type="primary" icon="el-icon-video-play" @click="startStudy">开始学习</el-button>
<el-button v-if="studying" size="mini" type="danger" icon="el-icon-video-pause" @click="finishStudy(false)">结束学习</el-button>
<el-button v-if="fileUrl" size="mini" type="text" icon="el-icon-download" @click="openFile">打开原文件</el-button>
</div>
</div>
<div class="resource-view">
<template v-if="selectedResource.id">
<video
v-if="selectedResource.resourceType === 'video'"
ref="mediaPlayer"
class="media-player"
:src="fileUrl"
controls
controlslist="nodownload"
@play="mediaPlay"
@ended="mediaEnded"
@timeupdate="saveProgress"
@loadedmetadata="mediaReady">
</video>
<div v-else-if="selectedResource.resourceType === 'audio'" class="audio-wrap">
<audio
ref="mediaPlayer"
:src="fileUrl"
controls
controlslist="nodownload"
@play="mediaPlay"
@ended="mediaEnded"
@timeupdate="saveProgress"
@loadedmetadata="mediaReady">
</audio>
</div>
<img v-else-if="selectedResource.resourceType === 'image'" class="image-preview" :src="fileUrl" :alt="selectedResource.title">
<iframe v-else-if="canInlinePreview(selectedResource)" class="doc-frame" :src="inlinePreviewUrl"></iframe>
<div v-else class="file-fallback">
<file-preview :files="selectedResource.fileData" complete_result></file-preview>
<el-button class="mt20" type="primary" icon="el-icon-view" @click="openFile">打开资料</el-button>
</div>
</template>
<el-empty v-else description="请选择左侧课程资料"></el-empty>
</div>
</div>
</div>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
courseId: "",
apiBase: "/platform/learning/course/display",
recordApi: "/platform/learning/study/record",
course: {},
treeData: [],
selectedResource: {},
recommendOptions: [],
studying: false,
currentSegmentId: "",
recordProgress: 0,
recordStudyTime: "0秒",
pendingSeconds: 0,
heartbeatTimer: null,
startingStudy: false,
autoResumeKey: "",
lastPositionSyncAt: 0,
lastActiveAt: Date.now(),
inactiveLimit: 60000
}
},
computed: {
coverUrl() {
return this.getFileUrl(this.course.cover)
},
fileUrl() {
return this.getFileUrl(this.selectedResource.fileData)
},
inlinePreviewUrl() {
const id = this.getFileId(this.selectedResource.fileData)
return id ? this.apiBase + "/pdfPreview?id=" + encodeURIComponent(id) : this.fileUrl
}
},
methods: {
getQuery(name) {
return new URLSearchParams(window.location.search).get(name) || ""
},
async loadCourse() {
const resp = await axios.post(this.apiBase + "/courseInfo", { id: this.courseId })
if (resp.code === 0) {
this.course = resp.data
} else {
this.$message.warning(resp.msg)
}
},
async loadTree() {
const resp = await axios.post(this.apiBase + "/studyTree", { courseId: this.courseId })
if (resp.code === 0) {
this.treeData = resp.data || []
const first = this.findFirstResource(this.treeData)
if (first) {
this.nodeClick(first)
this.$nextTick(() => this.$refs.studyTree && this.$refs.studyTree.setCurrentKey(first.id))
}
}
},
async loadRecommendOptions() {
const resp = await axios.post(this.apiBase + "/recommendOptions")
if (resp.code === 0) {
this.recommendOptions = resp.data
}
},
findFirstResource(nodes) {
for (const node of nodes || []) {
if (node.type === "resource") return node
const child = this.findFirstResource(node.children || [])
if (child) return child
}
return null
},
nodeClick(data) {
if (data.type !== "resource") return
if (this.studying && this.selectedResource.id !== data.id) {
this.$message.warning("请先结束当前章节资料的学习")
return
}
this.selectedResource = data
this.recordProgress = data.progressPercent || 0
this.recordStudyTime = this.formatSeconds(Number(data.studySeconds || 0))
},
getNodeIcon(data) {
if (data.type === "resource") {
const map = {
video: "el-icon-video-camera",
audio: "el-icon-headset",
image: "el-icon-picture-outline",
pdf: "el-icon-document",
word: "el-icon-document",
ppt: "el-icon-document"
}
return map[data.resourceType] || "el-icon-paperclip"
}
return data.nodeType === "chapter" ? "el-icon-folder" : "el-icon-notebook-2"
},
getFileUrl(value) {
if (!value) return ""
if (Array.isArray(value)) {
return value.length ? (value[0].url || value[0].response?.data || value[0].data || "") : ""
}
if (typeof value === "string" && value.trim().startsWith("[")) {
try {
const files = JSON.parse(value)
return files.length ? (files[0].url || files[0].response?.data || files[0].data || "") : ""
} catch (e) {
return ""
}
}
return value
},
getFileId(value) {
const url = this.getFileUrl(value)
if (!url) return ""
const matched = url.match(/[?&]id=([^&]+)/)
if (matched) return decodeURIComponent(matched[1])
if (!url.includes("/") && !url.includes(".")) return url
return ""
},
canInlinePreview(resource) {
const type = resource.resourceType
const ext = (resource.fileExt || "").toLowerCase()
return ["pdf", "ppt", "word"].includes(type) || ["pdf", "ppt", "pptx", "doc", "docx"].includes(ext)
},
progressKey() {
return "learning-progress-" + this.courseId + "-" + this.selectedResource.id
},
mediaReady() {
this.askResume()
},
askResume() {
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return
if (this.studying || this.startingStudy) return
const currentKey = this.progressKey()
if (this.autoResumeKey === currentKey) return
this.autoResumeKey = currentKey
const player = this.$refs.mediaPlayer
const localSaved = Number(localStorage.getItem(this.progressKey()) || 0)
const serverSaved = Number(this.selectedResource.lastPositionSeconds || 0)
const saved = Math.max(localSaved, serverSaved)
if (!player || !saved || saved < 5 || saved >= player.duration - 5) return
this.$confirm("检测到上次学习到 " + this.formatSeconds(saved) + ",请选择继续学习或从头开始。", "继续学习", {
confirmButtonText: "继续学习",
cancelButtonText: "从头开始",
type: "info"
}).then(() => {
this.startStudy({ autoPlay: true, seekTo: saved })
}).catch(() => {
localStorage.removeItem(this.progressKey())
this.saveServerPosition(0)
this.startStudy({ autoPlay: true, seekTo: 0 })
})
},
async mediaPlay() {
if (this.studying || this.startingStudy) return
await this.startStudy({ fromMedia: true })
},
mediaEnded() {
this.finishStudy(false, { silent: true })
localStorage.removeItem(this.progressKey())
},
async playSelectedMedia(seekTo) {
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return
await this.$nextTick()
const player = this.$refs.mediaPlayer
if (!player) return
try {
if (typeof seekTo === "number" && !Number.isNaN(seekTo)) {
await this.seekBeforePlay(player, seekTo)
}
const playPromise = player.play && player.play()
if (playPromise && playPromise.catch) {
await playPromise.catch(() => {
this.$message.warning("浏览器阻止了自动播放,请点击播放器播放按钮继续")
})
}
} catch (e) {
this.$message.warning("播放器定位失败,请手动点击播放后继续")
}
},
async seekBeforePlay(player, seekTo) {
await this.waitForMediaReady(player)
const target = this.normalizeSeekTarget(player, seekTo)
if (target === null) return
if (Math.abs((player.currentTime || 0) - target) <= 1) return
player.pause()
player.currentTime = target
await this.waitForSeek(player, target, 1800)
if (Math.abs((player.currentTime || 0) - target) > 1) {
player.currentTime = target
await this.waitForSeek(player, target, 1200)
}
},
waitForMediaReady(player) {
if (player.readyState >= 1 && !Number.isNaN(player.duration)) {
return Promise.resolve()
}
return new Promise(resolve => {
const done = () => {
player.removeEventListener("loadedmetadata", done)
player.removeEventListener("durationchange", done)
resolve()
}
player.addEventListener("loadedmetadata", done, { once: true })
player.addEventListener("durationchange", done, { once: true })
setTimeout(done, 2000)
})
},
waitForSeek(player, target, timeout) {
return new Promise(resolve => {
const done = () => {
player.removeEventListener("seeked", done)
player.removeEventListener("timeupdate", done)
resolve()
}
player.addEventListener("seeked", done, { once: true })
player.addEventListener("timeupdate", done, { once: true })
setTimeout(done, timeout)
})
},
normalizeSeekTarget(player, seekTo) {
const raw = Math.max(0, Number(seekTo || 0))
if (Number.isNaN(raw)) return null
if (!Number.isNaN(player.duration) && player.duration > 0) {
return Math.min(raw, Math.max(0, player.duration - 1))
}
return raw
},
saveProgress() {
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return
const player = this.$refs.mediaPlayer
if (player && player.currentTime > 0) {
const position = Math.floor(player.currentTime)
localStorage.setItem(this.progressKey(), String(position))
this.$set(this.selectedResource, "lastPositionSeconds", position)
if (Date.now() - this.lastPositionSyncAt > 5000) {
this.saveServerPosition(position)
}
}
},
saveServerPosition(position, useBeacon) {
if (!this.selectedResource.id || !["video", "audio"].includes(this.selectedResource.resourceType)) return
const seconds = Math.max(0, Math.floor(Number(position || 0)))
this.lastPositionSyncAt = Date.now()
if (useBeacon && navigator.sendBeacon) {
const body = new URLSearchParams()
body.append("courseId", this.courseId)
body.append("resourceId", this.selectedResource.id)
body.append("positionSeconds", String(seconds))
const blob = new Blob([body.toString()], { type: "application/x-www-form-urlencoded;charset=UTF-8" })
navigator.sendBeacon(this.recordApi + "/position", blob)
return
}
this.$axios.post(this.recordApi + "/position", {
courseId: this.courseId,
resourceId: this.selectedResource.id,
positionSeconds: seconds
})
},
async startStudy(options) {
const config = options && !options.target ? options : {}
if (!this.selectedResource.id) {
this.$message.warning("请选择左侧课程资料")
return
}
if (this.studying) {
if (config.autoPlay) {
this.playSelectedMedia(config.seekTo)
}
return
}
this.startingStudy = true
let resp
try {
resp = await axios.post(this.recordApi + "/start", {
courseId: this.courseId,
resourceId: this.selectedResource.id,
positionSeconds: typeof config.seekTo === "number" ? Math.floor(config.seekTo) : this.getMediaPosition()
})
} finally {
this.startingStudy = false
}
if (resp.code !== 0) {
this.$message.warning(resp.msg)
return
}
this.currentSegmentId = resp.data.segmentId
this.studying = true
this.pendingSeconds = 0
this.lastActiveAt = Date.now()
this.applyRecordState(resp.data)
this.startHeartbeatTimer()
if (config.autoPlay !== false && !config.fromMedia) {
this.playSelectedMedia(config.seekTo)
}
this.$message.success("已开始学习")
},
async heartbeat() {
if (!this.studying || !this.currentSegmentId || this.pendingSeconds <= 0) return
const seconds = this.pendingSeconds
this.pendingSeconds = 0
const resp = await axios.post(this.recordApi + "/heartbeat", {
segmentId: this.currentSegmentId,
activeSeconds: seconds,
positionSeconds: this.getMediaPosition()
})
if (resp.code === 0) {
this.applyRecordState(resp.data)
} else {
this.stopHeartbeatTimer()
this.studying = false
this.currentSegmentId = ""
this.$message.warning(resp.msg)
}
},
async finishStudy(force, options) {
const config = options || {}
if (!this.currentSegmentId) return
const segmentId = this.currentSegmentId
const seconds = this.pendingSeconds
this.pendingSeconds = 0
this.stopHeartbeatTimer()
this.studying = false
this.currentSegmentId = ""
if (!force) {
this.pauseSelectedMedia()
}
if (force && navigator.sendBeacon) {
this.saveServerPosition(this.getMediaPosition(), true)
const formData = new FormData()
formData.append("segmentId", segmentId)
formData.append("activeSeconds", String(seconds))
formData.append("positionSeconds", String(this.getMediaPosition()))
navigator.sendBeacon(this.recordApi + "/finish", formData)
return
}
const resp = await axios.post(this.recordApi + "/finish", {
segmentId: segmentId,
activeSeconds: seconds,
positionSeconds: this.getMediaPosition()
})
if (resp.code === 0 && resp.data) {
this.applyRecordState(resp.data)
if (!config.silent) {
this.$message.success("学习已结束")
}
}
},
pauseSelectedMedia() {
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return
const player = this.$refs.mediaPlayer
if (player && !player.paused) {
player.pause()
}
},
startHeartbeatTimer() {
this.stopHeartbeatTimer()
this.heartbeatTimer = setInterval(() => {
if (!this.studying) return
if (this.isEffectiveLearning()) {
this.pendingSeconds += 1
}
if (this.pendingSeconds >= 15) {
this.heartbeat()
}
}, 1000)
},
stopHeartbeatTimer() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
},
isEffectiveLearning() {
if (document.hidden) return false
if (Date.now() - this.lastActiveAt > this.inactiveLimit) return false
if (["video", "audio"].includes(this.selectedResource.resourceType)) {
const player = this.$refs.mediaPlayer
return !!player && !player.paused && !player.ended
}
return true
},
markActive() {
this.lastActiveAt = Date.now()
},
applyRecordState(data) {
this.recordProgress = Number(data.progressPercent || 0)
this.recordStudyTime = data.studyTimeText || this.formatSeconds(Number(data.studySeconds || 0))
if (this.selectedResource.id && data.lastPositionSeconds !== undefined) {
this.$set(this.selectedResource, "lastPositionSeconds", Number(data.lastPositionSeconds || 0))
}
},
getMediaPosition() {
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return 0
const player = this.$refs.mediaPlayer
return player ? Math.floor(player.currentTime || 0) : Number(this.selectedResource.lastPositionSeconds || 0)
},
formatSeconds(seconds) {
const hour = Math.floor(seconds / 3600)
const minute = Math.floor(seconds % 3600 / 60)
const second = Math.floor(seconds % 60)
if (hour > 0) return hour + "小时" + minute + "分" + second + "秒"
if (minute > 0) return minute + "分" + second + "秒"
return second + "秒"
},
splitFlags(flags) {
return flags ? flags.split(",").filter(Boolean) : []
},
getRecommendName(code) {
const item = this.recommendOptions.find(v => v.code === code)
return item ? item.name : code
},
openFile() {
if (this.fileUrl) window.open(this.fileUrl)
},
collapseAll() {
const nodesMap = this.$refs.studyTree && this.$refs.studyTree.store.nodesMap
Object.keys(nodesMap || {}).forEach(key => {
nodesMap[key].expanded = false
})
},
async goBack() {
this.saveServerPosition(this.getMediaPosition())
await this.finishStudy(false, { silent: true })
window.location.href = this.apiBase
}
},
async created() {
this.courseId = this.getQuery("id")
window.addEventListener("mousemove", this.markActive)
window.addEventListener("keydown", this.markActive)
window.addEventListener("click", this.markActive)
window.addEventListener("scroll", this.markActive, true)
window.addEventListener("beforeunload", () => this.finishStudy(true))
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
this.saveServerPosition(this.getMediaPosition(), true)
}
})
await this.loadRecommendOptions()
await this.loadCourse()
await this.loadTree()
},
beforeDestroy() {
this.finishStudy(true)
this.stopHeartbeatTimer()
window.removeEventListener("mousemove", this.markActive)
window.removeEventListener("keydown", this.markActive)
window.removeEventListener("click", this.markActive)
window.removeEventListener("scroll", this.markActive, true)
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,396 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<guava ref="guava">
<el-card shadow="never">
<search :is-search-button="false">
<search-item label="课程名称">
<el-input v-model="pageForm.courseName" clearable placeholder="请输入课程名称" style="width: 100%" @keyup.enter.native="doSearch"></el-input>
</search-item>
<search-item label="课程类型">
<el-select v-model="pageForm.courseTypeId" clearable filterable placeholder="请选择课程类型" style="width: 100%">
<el-option v-for="item in courseTypeOptions" :key="item.id" :label="item.typeName" :value="item.id"></el-option>
</el-select>
</search-item>
<search-item label="开课时间">
<el-date-picker
v-model="pageForm.courseTime"
type="daterange"
start-placeholder="开始时间"
end-placeholder="结束时间"
range-separator="至"
value-format="yyyy-MM-dd HH:mm:ss"
style="width: 100%">
</el-date-picker>
</search-item>
<search-item label="授课讲师">
<el-input v-model="pageForm.lecturerName" clearable placeholder="请输入授课讲师" style="width: 100%" @keyup.enter.native="doSearch"></el-input>
</search-item>
<search-item label="课程状态">
<el-select v-model="pageForm.status" clearable placeholder="请选择课程状态" style="width: 100%">
<el-option v-for="item in statusOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</search-item>
<search-item label="推荐标识">
<el-select v-model="pageForm.recommendFlag" clearable placeholder="请选择推荐标识" style="width: 100%">
<el-option v-for="item in recommendOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</search-item>
<div class="search-query">
<el-button size="medium" type="primary" icon="el-icon-search" @click="doSearch">搜索</el-button>
<el-button size="medium" @click="resetSearch">重置</el-button>
</div>
</search>
</el-card>
<el-card class="mt10" shadow="never">
<table-tool :app="this" label="课程列表">
<el-button @click="openAdd" size="medium" type="primary">
<i class="el-icon-plus"></i>
新增课程
</el-button>
</table-tool>
<el-table
v-loading="tableLoading"
:data="tableData"
:size="tableSize"
border
@sort-change="pageOrder">
<el-table-column :index="indexMethod" align="center" header-align="center" label="序号" type="index" width="80"></el-table-column>
<el-table-column align="center" header-align="center" label="课程名称" prop="courseName" sortable="custom" show-overflow-tooltip min-width="180"></el-table-column>
<el-table-column align="center" header-align="center" label="课程类型" prop="courseTypeName" sortable="custom" show-overflow-tooltip width="140"></el-table-column>
<el-table-column align="center" header-align="center" label="授课讲师" prop="lecturerName" sortable="custom" show-overflow-tooltip width="140"></el-table-column>
<el-table-column align="center" header-align="center" label="开课时间" prop="startTime" sortable="custom" width="230">
<template slot-scope="{row}">
<span v-if="row.openType === 'long_term'">长期开放</span>
<span v-else>{{formatTime(row.startTime)}} 至 {{formatTime(row.endTime)}}</span>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="课程状态" prop="status" sortable="custom" width="120">
<template slot-scope="{row}">
<el-tag :type="getStatusType(row.status)" size="mini">{{getStatusName(row.status)}}</el-tag>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="推荐标识" prop="recommendFlags" sortable="custom" show-overflow-tooltip min-width="140">
<template slot-scope="{row}">
<el-tag
v-for="code in splitValue(row.recommendFlags)"
:key="code"
:type="getRecommendType(code)"
size="mini"
style="margin: 2px 3px">
{{getDictName(code, recommendOptions)}}
</el-tag>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="排序编码" prop="sortNum" sortable="custom" width="120"></el-table-column>
<el-table-column align="center" header-align="center" label="操作" width="260">
<template slot-scope="{row}">
<el-button @click="openEdit(row)" size="mini" type="primary">编辑</el-button>
<el-button @click="goChapterContent(row)" size="mini" type="success">章节内容</el-button>
<el-button @click="doDelete(row)" size="mini" type="danger">删除</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</guava>
<el-dialog :title="title" :visible.sync="dialogVisible" :close-on-click-modal="false" width="72%">
<el-form :model="formData" ref="courseForm" :rules="formRules" label-width="110px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="课程名称" prop="courseName">
<el-input v-model="formData.courseName" maxlength="100" placeholder="请输入课程名称"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="课程类型" prop="courseTypeId">
<el-select v-model="formData.courseTypeId" filterable placeholder="请选择课程类型" style="width: 100%">
<el-option v-for="item in courseTypeOptions" :key="item.id" :label="item.typeName" :value="item.id"></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="授课讲师" prop="lecturerName">
<el-input v-model="formData.lecturerName" maxlength="100" placeholder="请输入教师姓名"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="课程状态" prop="status">
<el-select v-model="formData.status" placeholder="请选择课程状态" style="width: 100%">
<el-option v-for="item in statusOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="开课方式" prop="openType">
<el-radio-group v-model="formData.openType">
<el-radio label="fixed">固定开课时间</el-radio>
<el-radio label="long_term">长期开放</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="开课时间" prop="openTime" v-if="formData.openType !== 'long_term'">
<el-date-picker
v-model="formData.openTime"
type="datetimerange"
start-placeholder="开始时间"
end-placeholder="结束时间"
range-separator="至"
value-format="yyyy-MM-dd HH:mm:ss"
style="width: 100%">
</el-date-picker>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="推荐标识" prop="recommendFlagList">
<el-select v-model="formData.recommendFlagList" multiple clearable placeholder="请选择推荐标识" style="width: 100%">
<el-option v-for="item in recommendOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序编码" prop="sortNum">
<el-input-number v-model="formData.sortNum" :controls="false" :min="0" :precision="0" style="width: 100%" placeholder="请输入排序编码"></el-input-number>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="课程介绍" prop="courseIntro">
<el-input v-model="formData.courseIntro" type="textarea" maxlength="2000" :rows="4" show-word-limit placeholder="请输入课程简介"></el-input>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="适合人群" prop="suitablePeople">
<el-input v-model="formData.suitablePeople" type="textarea" maxlength="500" :rows="3" show-word-limit placeholder="请输入适合人群"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="学习目标" prop="learningGoal">
<el-input v-model="formData.learningGoal" type="textarea" maxlength="500" :rows="3" show-word-limit placeholder="请输入学习目标"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="课程封面" prop="cover">
<file-upload
:upload_number="1"
:upload_size="5242880"
:value.sync="formData.cover"
accept=".jpg,.jpeg,.png"
complete_result
upload_mode="image"
upload_result_category="interval">
</file-upload>
<div class="el-upload__tip">只能上传1个文件;只能上传.jpg,.jpeg,.png文件;单个文件大小不能超过5M。</div>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :disabled="subDis" @click="operation">确定</el-button>
</span>
</el-dialog>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
mixins: [initTableMixins],
data() {
return {
title: "",
dialogVisible: false,
subDis: false,
courseTypeOptions: [],
recommendOptions: [],
statusOptions: [
{ name: "草稿", code: "draft" },
{ name: "待发布", code: "pending" },
{ name: "已发布", code: "published" },
{ name: "已下架", code: "offline" }
],
formData: {},
formRules: {
courseName: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
courseTypeId: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
lecturerName: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
status: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
sortNum: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
cover: [{ required: true, message: "请上传课程封面", trigger: ["blur", "change"] }]
}
}
},
methods: {
buildPageParams() {
const params = Object.assign({}, this.pageForm)
params.startTime = params.courseTime && params.courseTime.length ? params.courseTime[0] : ""
params.endTime = params.courseTime && params.courseTime.length ? params.courseTime[1] : ""
return params
},
pageData(data = null) {
const address = this.pageDataUrl ? this.pageDataUrl : loc() + "/pageData"
this.tableLoading = true
this.$axios.post(address, data ? data : this.buildPageParams()).then((res) => {
this.tableLoading = false
if (res.code === 0) {
this.tableData = res.data.list
this.pageForm.totalCount = res.data.totalCount
}
})
},
resetSearch() {
this.pageForm.courseName = ""
this.pageForm.courseTypeId = ""
this.pageForm.courseTime = []
this.pageForm.lecturerName = ""
this.pageForm.status = ""
this.pageForm.recommendFlag = ""
this.doSearch()
},
openAdd() {
this.title = "新增课程"
this.formData = {
courseName: "",
courseTypeId: "",
lecturerName: "",
courseIntro: "",
suitablePeople: "",
learningGoal: "",
openType: "fixed",
openTime: [],
status: "draft",
recommendFlagList: [],
sortNum: 0,
cover: ""
}
this.dialogVisible = true
this.$nextTick(() => {
this.$refs.courseForm && this.$refs.courseForm.clearValidate()
})
},
openEdit(row) {
this.title = "编辑课程"
this.formData = Object.assign({}, row, {
openTime: row.startTime && row.endTime ? [this.formatTime(row.startTime), this.formatTime(row.endTime)] : [],
recommendFlagList: this.splitValue(row.recommendFlags)
})
this.dialogVisible = true
this.$nextTick(() => {
this.$refs.courseForm && this.$refs.courseForm.clearValidate()
})
},
goChapterContent(row) {
const url = "/platform/learning/chapter/content?courseId=" + encodeURIComponent(row.id || "")
if (window.commonUtil && commonUtil.pjaxPush) {
commonUtil.pjaxPush(url)
} else {
window.location.href = url
}
},
operation() {
this.$refs.courseForm.validate(async (valid) => {
if (!valid) return
if (this.formData.openType !== "long_term" && (!this.formData.openTime || this.formData.openTime.length !== 2)) {
this.$message.warning("请选择开课时间")
return
}
const data = Object.assign({}, this.formData)
data.startTime = data.openType === "long_term" ? "" : data.openTime[0]
data.endTime = data.openType === "long_term" ? "" : data.openTime[1]
data.recommendFlags = (data.recommendFlagList || []).join(",")
delete data.openTime
delete data.recommendFlagList
const method = data.id ? "/doEdit" : "/doAdd"
this.subDis = true
const resp = await axios.post(loc() + method, data)
this.subDis = false
if (resp.code === 0) {
this.dialogVisible = false
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.warning(resp.msg)
}
})
},
doDelete(row) {
this.$confirm("确定要删除该课程吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(async () => {
const resp = await axios.post(loc() + "/doDelete", { id: row.id })
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.error(resp.msg)
}
})
},
async getCourseTypes() {
const resp = await axios.post(loc() + "/courseTypes")
if (resp.code === 0) {
this.courseTypeOptions = resp.data
}
},
async getLearningDict(name) {
const resp = await axios.post(loc() + "/learningDictOptions", { name })
return resp.code === 0 ? resp.data : []
},
splitValue(value) {
return value ? value.split(",").filter(Boolean) : []
},
getStatusName(code) {
const item = this.statusOptions.find(v => v.code === code)
return item ? item.name : ""
},
getStatusType(code) {
const map = {
draft: "info",
pending: "warning",
published: "success",
offline: "danger"
}
return map[code] || ""
},
getDictName(code, options) {
const item = options.find(v => v.code === code)
return item ? item.name : code
},
getDictNames(value, options) {
const values = this.splitValue(value)
return values.map(code => {
const item = options.find(v => v.code === code)
return item ? item.name : code
}).join("、")
},
getRecommendType(code) {
const name = this.getDictName(code, this.recommendOptions)
if (name.includes("热门") || name.includes("推荐")) return "warning"
if (name.includes("新")) return "success"
return ""
},
formatTime(time) {
return time ? this.$moment(time).format("YYYY-MM-DD HH:mm:ss") : ""
}
},
async created() {
this.pageForm.courseTime = []
await this.getCourseTypes()
this.recommendOptions = await this.getLearningDict("推荐标识")
this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,189 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<guava ref="guava">
<el-card shadow="never">
<search :is-search-button="false">
<search-item label="课程类型名称">
<el-input
v-model="pageForm.typeName"
clearable
placeholder="请输入课程类型名称"
style="width: 100%"
@keyup.enter.native="doSearch">
</el-input>
</search-item>
<div class="search-query">
<el-button size="medium" type="primary" icon="el-icon-search" @click="doSearch">搜索</el-button>
<el-button size="medium" @click="resetSearch">重置</el-button>
</div>
</search>
</el-card>
<el-card class="mt10" shadow="never">
<table-tool :app="this" label="课程类型列表">
<el-button @click="openAdd" size="medium" type="primary">
<i class="el-icon-plus"></i>
新建
</el-button>
</table-tool>
<el-table
v-loading="tableLoading"
:data="tableData"
:size="tableSize"
border>
<el-table-column :index="indexMethod" align="center" header-align="center" label="序号" type="index" width="80"></el-table-column>
<el-table-column align="center" header-align="center" label="课程类型名称" prop="typeName" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="备注" prop="remark" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="排序编号" prop="sortNum" width="140"></el-table-column>
<el-table-column align="center" header-align="center" label="是否启用" prop="enabled" width="140">
<template slot-scope="{row}">
<el-switch
v-model="row.enabled"
active-color="#13ce66"
inactive-color="#ff4949"
@change="switchChange(row)">
</el-switch>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="操作" width="180">
<template slot-scope="{row}">
<el-button @click="openEdit(row)" size="mini" type="primary">编辑</el-button>
<el-button @click="doDelete(row)" size="mini" type="danger">删除</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</guava>
<el-dialog :title="title" :visible.sync="dialogVisible" :close-on-click-modal="false" width="40%">
<el-form :model="formData" ref="courseTypeForm" :rules="formRules" label-width="120px">
<el-form-item label="课程类型名称" prop="typeName">
<el-input v-model="formData.typeName" maxlength="100" placeholder="请输入课程类型名称"></el-input>
</el-form-item>
<el-form-item label="排序编号" prop="sortNum">
<el-input-number
v-model="formData.sortNum"
:controls="false"
:min="0"
:precision="0"
placeholder="请输入排序编号"
style="width: 100%">
</el-input-number>
</el-form-item>
<el-form-item label="是否启用" prop="enabled">
<el-switch v-model="formData.enabled" active-color="#13ce66" inactive-color="#ff4949"></el-switch>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
maxlength="500"
show-word-limit
:rows="4"
placeholder="请输入备注">
</el-input>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :disabled="subDis" @click="operation">确定</el-button>
</span>
</el-dialog>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
mixins: [initTableMixins],
data() {
return {
title: "",
dialogVisible: false,
subDis: false,
formData: {},
formRules: {
typeName: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
sortNum: [{ required: true, message: "必填", trigger: ["blur", "change"] }]
}
}
},
methods: {
resetSearch() {
this.pageForm.typeName = ""
this.doSearch()
},
openAdd() {
this.title = "新建课程类型"
this.formData = {
typeName: "",
sortNum: 0,
enabled: true,
remark: ""
}
this.dialogVisible = true
this.$nextTick(() => {
this.$refs.courseTypeForm && this.$refs.courseTypeForm.clearValidate()
})
},
openEdit(row) {
this.title = "编辑课程类型"
this.formData = Object.assign({}, row)
this.dialogVisible = true
this.$nextTick(() => {
this.$refs.courseTypeForm && this.$refs.courseTypeForm.clearValidate()
})
},
operation() {
const method = this.formData.id ? "/doEdit" : "/doAdd"
this.$refs.courseTypeForm.validate(async (valid) => {
if (!valid) return
this.subDis = true
const resp = await axios.post(loc() + method, this.formData)
this.subDis = false
if (resp.code === 0) {
this.dialogVisible = false
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.warning(resp.msg)
}
})
},
async switchChange(row) {
const resp = await axios.post(loc() + "/doEdit", row)
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.error(resp.msg)
row.enabled = !row.enabled
}
},
doDelete(row) {
this.$confirm("确定要删除该课程类型吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(async () => {
const resp = await axios.post(loc() + "/doDelete", { id: row.id })
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.error(resp.msg)
}
})
}
},
created() {
this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,180 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<guava ref="guava">
<el-card shadow="never">
<search :is-search-button="false">
<search-item label="工号/姓名">
<el-input
v-model="pageForm.keyword"
clearable
placeholder="请输入工号或姓名"
style="width: 100%"
@keyup.enter.native="doSearch">
</el-input>
</search-item>
<search-item label="所属分工会" v-if="unionOptions.length">
<el-select v-model="pageForm.unionId" clearable filterable placeholder="请选择所属分工会" style="width: 100%">
<el-option v-for="item in unionOptions" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
</search-item>
<search-item label="课程名称">
<el-select v-model="pageForm.courseId" clearable filterable placeholder="请选择课程" style="width: 100%">
<el-option v-for="item in courseOptions" :key="item.id" :label="item.courseName" :value="item.id"></el-option>
</el-select>
</search-item>
<search-item label="是否完成">
<el-select v-model="pageForm.completeStatus" clearable placeholder="请选择完成状态" style="width: 100%">
<el-option v-for="item in statusOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</search-item>
<div class="search-query">
<el-button size="medium" type="primary" icon="el-icon-search" @click="doSearch">搜索</el-button>
<el-button size="medium" @click="resetSearch">重置</el-button>
</div>
</search>
</el-card>
<el-card class="mt10" shadow="never">
<table-tool :app="this" label="学习列表"></table-tool>
<el-table
v-loading="tableLoading"
:data="tableData"
:size="tableSize"
border
@sort-change="pageOrder">
<el-table-column :index="indexMethod" align="center" header-align="center" label="序号" type="index" width="70"></el-table-column>
<el-table-column align="center" header-align="center" label="工号" prop="loginName" sortable="custom" show-overflow-tooltip width="120"></el-table-column>
<el-table-column align="center" header-align="center" label="姓名" prop="userName" sortable="custom" show-overflow-tooltip width="110"></el-table-column>
<el-table-column align="center" header-align="center" label="所属分工会" prop="unionName" sortable="custom" show-overflow-tooltip min-width="150"></el-table-column>
<el-table-column align="center" header-align="center" label="课程名称" prop="courseName" sortable="custom" show-overflow-tooltip min-width="180"></el-table-column>
<el-table-column align="center" header-align="center" label="章节名称" prop="outlineName" sortable="custom" show-overflow-tooltip min-width="160"></el-table-column>
<el-table-column align="center" header-align="center" label="学习开始时间" prop="firstStudyTime" sortable="custom" width="170">
<template slot-scope="{row}">{{formatTime(row.firstStudyTime)}}</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="最近学习时间" prop="latestStudyTime" sortable="custom" width="170">
<template slot-scope="{row}">{{formatTime(row.latestStudyTime)}}</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="学习时长" prop="studySeconds" sortable="custom" width="130">
<template slot-scope="{row}">{{row.studyTimeText}}</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="学习进度" prop="progressPercent" sortable="custom" width="180">
<template slot-scope="{row}">
<el-progress :percentage="Number(row.progressPercent || 0)" :stroke-width="8"></el-progress>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="是否完成" prop="completeStatus" sortable="custom" width="110">
<template slot-scope="{row}">
<el-tag :type="statusType(row.completeStatus)" size="mini">{{statusName(row.completeStatus)}}</el-tag>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="操作" width="100">
<template slot-scope="{row}">
<el-button size="mini" type="danger" icon="el-icon-delete" @click="deleteRecord(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</guava>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
mixins: [initTableMixins],
data() {
return {
apiBase: "/platform/learning/study/record",
courseOptions: [],
unionOptions: [],
statusOptions: [
{ name: "未开始", code: "not_started", type: "info" },
{ name: "学习中", code: "studying", type: "warning" },
{ name: "已完成", code: "completed", type: "success" }
]
}
},
methods: {
pageData() {
this.tableLoading = true
this.$axios.post(this.apiBase + "/pageData", this.pageForm).then(resp => {
this.tableLoading = false
if (resp.code === 0) {
this.tableData = resp.data.list || []
this.pageForm.totalCount = resp.data.totalCount
} else {
this.$message.warning(resp.msg)
}
}).catch(() => {
this.tableLoading = false
})
},
pageOrder(column) {
this.pageForm.pageOrderName = column.prop
this.pageForm.pageOrderBy = column.order
this.pageData()
},
doSearch() {
this.pageForm.pageNumber = 1
this.pageData()
},
resetSearch() {
this.pageForm.keyword = ""
this.pageForm.unionId = ""
this.pageForm.courseId = ""
this.pageForm.completeStatus = ""
this.pageForm.pageNumber = 1
this.pageData()
},
async loadOptions() {
const courseResp = await axios.post(this.apiBase + "/courseOptions")
if (courseResp.code === 0) {
this.courseOptions = courseResp.data || []
}
const unionResp = await axios.post(this.apiBase + "/unionOptions")
if (unionResp.code === 0) {
this.unionOptions = unionResp.data || []
}
},
statusName(code) {
const item = this.statusOptions.find(v => v.code === code)
return item ? item.name : "未开始"
},
statusType(code) {
const item = this.statusOptions.find(v => v.code === code)
return item ? item.type : "info"
},
formatTime(value) {
return value ? this.$moment(value).format("YYYY-MM-DD HH:mm:ss") : ""
},
deleteRecord(row) {
this.$confirm("确定要删除该学习记录吗?删除后该章节的学习时段也会一并清理。", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(async () => {
const resp = await axios.post(this.apiBase + "/delete", { id: row.id })
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.warning(resp.msg)
}
})
}
},
async created() {
this.$set(this.pageForm, "keyword", "")
this.$set(this.pageForm, "unionId", "")
this.$set(this.pageForm, "courseId", "")
this.$set(this.pageForm, "completeStatus", "")
await this.loadOptions()
this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,167 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<guava ref="guava">
<el-card shadow="never">
<search :is-search-button="false">
<search-item label="课程名称">
<el-select
v-model="pageForm.courseId"
clearable
filterable
remote
:remote-method="queryCourseOptions"
placeholder="请选择课程"
style="width: 100%">
<el-option v-for="item in courseOptions" :key="item.id" :label="item.courseName" :value="item.id"></el-option>
</el-select>
</search-item>
<search-item label="所属分工会" v-if="unionOptions.length">
<el-select v-model="pageForm.unionId" clearable filterable placeholder="请选择所属分工会" style="width: 100%">
<el-option v-for="item in unionOptions" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
</search-item>
<div class="search-query">
<el-button size="medium" type="primary" icon="el-icon-search" @click="doSearch">搜索</el-button>
<el-button size="medium" @click="resetSearch">重置</el-button>
</div>
</search>
</el-card>
<el-card class="mt10" shadow="never">
<el-tabs v-model="activeTab" @tab-click="tabChange">
<el-tab-pane label="课程统计" name="course"></el-tab-pane>
<el-tab-pane label="分工会统计" name="union"></el-tab-pane>
</el-tabs>
<table-tool :app="this" :label="activeTab === 'course' ? '课程统计' : '分工会统计'"></table-tool>
<el-table
v-if="activeTab === 'course'"
v-loading="tableLoading"
:data="tableData"
:size="tableSize"
border
@sort-change="pageOrder">
<el-table-column :index="indexMethod" align="center" header-align="center" label="序号" type="index" width="70"></el-table-column>
<el-table-column align="center" header-align="center" label="课程名称" prop="courseName" sortable="custom" show-overflow-tooltip min-width="220" width="360"></el-table-column>
<el-table-column align="center" header-align="center" label="学习人数" prop="learnerCount" sortable="custom" width="130"></el-table-column>
<el-table-column align="center" header-align="center" label="完成人数" prop="completedCount" sortable="custom" width="130"></el-table-column>
<el-table-column align="center" header-align="center" label="平均学习时长" prop="avgStudySeconds" sortable="custom" width="170">
<template slot-scope="{row}">{{row.avgStudyTimeText}}</template>
</el-table-column>
</el-table>
<el-table
v-else
v-loading="tableLoading"
:data="tableData"
:size="tableSize"
border
@sort-change="pageOrder">
<el-table-column :index="indexMethod" align="center" header-align="center" label="序号" type="index" width="70"></el-table-column>
<el-table-column align="center" header-align="center" label="课程名称" prop="courseName" sortable="custom" show-overflow-tooltip min-width="220"></el-table-column>
<el-table-column align="center" header-align="center" label="分工会名称" prop="unionName" sortable="custom" show-overflow-tooltip min-width="170"></el-table-column>
<el-table-column align="center" header-align="center" label="学习人数" prop="learnerCount" sortable="custom" width="130"></el-table-column>
<el-table-column align="center" header-align="center" label="学习时长" prop="studySeconds" sortable="custom" width="160">
<template slot-scope="{row}">{{row.studyTimeText}}</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="完成率" prop="completeRate" sortable="custom" width="130"></el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</guava>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
mixins: [initTableMixins],
data() {
return {
apiBase: "/platform/learning/statistics",
activeTab: "course",
courseOptions: [],
unionOptions: []
}
},
methods: {
pageData() {
this.tableLoading = true
const url = this.activeTab === "course" ? "/coursePageData" : "/unionPageData"
this.$axios.post(this.apiBase + url, this.pageForm).then(resp => {
this.tableLoading = false
if (resp.code === 0) {
this.tableData = resp.data.list || []
this.pageForm.totalCount = resp.data.totalCount
} else {
this.$message.warning(resp.msg)
}
}).catch(() => {
this.tableLoading = false
})
},
pageOrder(column) {
this.pageForm.pageOrderName = column.prop
this.pageForm.pageOrderBy = column.order
this.pageData()
},
async doSearch() {
this.pageForm.pageNumber = 1
await this.ensureUnionDefaultCourse()
this.pageData()
},
async resetSearch() {
this.pageForm.courseId = ""
this.pageForm.unionId = ""
this.pageForm.pageOrderName = ""
this.pageForm.pageOrderBy = ""
this.pageForm.pageNumber = 1
await this.queryCourseOptions("")
await this.ensureUnionDefaultCourse()
this.pageData()
},
async tabChange() {
this.tableData = []
this.pageForm.pageNumber = 1
this.pageForm.pageOrderName = ""
this.pageForm.pageOrderBy = ""
await this.ensureUnionDefaultCourse()
this.pageData()
},
async queryCourseOptions(query) {
const resp = await axios.post(this.apiBase + "/courseOptions", { courseName: query || "" })
if (resp.code === 0) {
this.courseOptions = resp.data || []
}
},
async ensureUnionDefaultCourse() {
if (this.activeTab !== "union" || this.pageForm.courseId) {
return
}
if (!this.courseOptions.length) {
await this.queryCourseOptions("")
}
if (this.courseOptions.length) {
this.pageForm.courseId = this.courseOptions[0].id
}
},
async loadUnionOptions() {
const resp = await axios.post(this.apiBase + "/unionOptions")
if (resp.code === 0) {
this.unionOptions = resp.data || []
}
}
},
async created() {
this.$set(this.pageForm, "courseId", "")
this.$set(this.pageForm, "unionId", "")
await this.queryCourseOptions("")
await this.loadUnionOptions()
this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,494 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<div id="app" v-cloak>
<div class="honor-showcase">
<van-nav-bar
title="劳模先进"
left-text="返回"
left-arrow
fixed
placeholder
@click-left="goBack"
></van-nav-bar>
<div class="honor-tabs">
<button
type="button"
class="honor-tab"
:class="{ active: activeTab === 'labor' }"
@click="switchTab('labor')">
劳模风采
</button>
<button
type="button"
class="honor-tab"
:class="{ active: activeTab === 'studio' }"
@click="switchTab('studio')">
创新工作室
</button>
</div>
<section class="honor-intro">
<div class="honor-intro__corner honor-intro__corner--left"></div>
<div class="honor-intro__corner honor-intro__corner--right"></div>
<p>一份坚守,淬炼出奋斗的生命价值;一份专注,迸发不断创新的发展动力。</p>
<p>他们扎根岗位、追求卓越,以实干书写担当,用匠心点亮前行方向。</p>
<p>致敬劳模先进,礼赞奋斗精神。</p>
</section>
<van-loading v-if="loading" class="honor-loading" color="#fff">加载中...</van-loading>
<div v-else class="honor-list">
<article v-for="item in honorList" :key="item.id" class="honor-card">
<div class="honor-card__image-wrap">
<img v-if="item.cover" class="honor-card__image" :src="item.cover" alt="">
<div v-else class="honor-card__placeholder">
<div class="honor-card__placeholder-title">{{ getDisplayName(item) }}</div>
<div class="honor-card__placeholder-sub">劳模先进</div>
</div>
<div class="honor-card__name">{{ getDisplayName(item) }}</div>
</div>
<div class="honor-card__body">
<div class="honor-card__gold-line"></div>
<div class="honor-card__prize">《{{ item.prizeName || '荣誉奖项' }}》</div>
<div class="honor-card__date" v-if="item.grantDate">授予时间:{{ item.grantDate }}</div>
</div>
</article>
<van-empty v-if="honorList.length === 0" image="search" description="暂无当年荣誉"></van-empty>
</div>
</div>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data: function () {
return {
activeTab: "labor",
currentYear: new Date().getFullYear(),
honorTypeList: [],
honorTypeMap: {
labor: null,
studio: null
},
honorList: [],
loading: false
}
},
methods: {
goBack: function () {
this.returnHome()
},
returnHome: function () {
if (window.store && typeof window.store.commit === "function") {
window.store.commit("setActiveTarBar", "home")
}
if (!window.$ || !$.support || !$.support.pjax) {
window.location.replace("/platform/h5/home")
return
}
var fallbackTimer = window.setTimeout(function () {
window.location.replace("/platform/h5/home")
}, 1200)
$(document).one("pjax:complete", function () {
window.clearTimeout(fallbackTimer)
})
$(document).one("pjax:error pjax:timeout", function () {
window.clearTimeout(fallbackTimer)
window.location.replace("/platform/h5/home")
})
$.pjax({
url: "/platform/h5/home",
container: "#container",
fragment: "#container",
push: false,
replace: true,
timeout: 8000
})
},
switchTab: function (tab) {
if (this.activeTab === tab) {
return
}
this.activeTab = tab
this.listHonor()
},
getDisplayName: function (item) {
if (this.activeTab === "studio") {
return item.applyUnionName || item.unionName || item.userName || "创新工作室"
}
return item.userName || item.applyUnionName || item.unionName || "劳模先进"
},
getHonorTypes: function () {
var self = this
return this.$axios.post("/platform/honor/basic/settings/getHonorType").then(function (res) {
if (res.code === 0) {
self.honorTypeList = res.data || []
self.honorTypeMap.labor = self.findHonorType("HONOR_SINGLE", "个人荣誉")
self.honorTypeMap.studio = self.findHonorType("HONOR_LIST", "集体荣誉")
}
})
},
findHonorType: function (queryTypeCode, name) {
var target = this.honorTypeList.find(function (item) {
return item.queryTypeCode === queryTypeCode
})
if (!target) {
target = this.honorTypeList.find(function (item) {
return item.name === name
})
}
return target || null
},
listHonor: function () {
var self = this
var type = this.activeTab === "studio" ? this.honorTypeMap.studio : this.honorTypeMap.labor
if (!type) {
this.honorList = []
return
}
this.loading = true
this.$axios.post("/platform/honor/manage/h5Data", {
year: this.currentYear,
honorType: type.id,
pageNumber: 1,
pageSize: 50
}).then(function (res) {
if (res.code === 0) {
var list = (res.data && res.data.list) || []
self.honorList = list.map(function (item) {
item.cover = ""
item.photoId = self.getFirstPhotoId(item.photoFiles)
return item
})
self.loadPhotoMap()
} else {
self.honorList = []
}
}).finally(function () {
self.loading = false
})
},
getFirstPhotoId: function (photoFiles) {
var files = []
if (Array.isArray(photoFiles)) {
files = photoFiles
} else if (photoFiles) {
try {
files = JSON.parse(photoFiles)
} catch (e) {
files = []
}
}
if (!files.length) {
return ""
}
var first = files[0]
var data = first && first.response ? first.response.data : first
if (!data) {
return ""
}
data = String(data)
return data.indexOf("=") > -1 ? data.substring(data.lastIndexOf("=") + 1) : data
},
loadPhotoMap: function () {
var self = this
var ids = this.honorList.map(function (item) {
return item.photoId
}).filter(function (id) {
return !!id
})
if (!ids.length) {
return
}
this.$axios.post("/platform/sys/file/previewFileData", { ids: JSON.stringify(ids) }).then(function (res) {
if (res.code !== 0) {
return
}
var fileMap = {}
;(res.data || []).forEach(function (file) {
fileMap[file.id] = file.thumbnail || file.downloadPath || ""
})
self.honorList = self.honorList.map(function (item) {
item.cover = fileMap[item.photoId] || ""
return item
})
})
}
},
created: function () {
var self = this
this.getHonorTypes().then(function () {
self.listHonor()
})
},
style: /*language=CSS*/ `
body {
background: #c80019;
}
.honor-showcase {
min-height: 100vh;
padding: 0 14px 28px;
box-sizing: border-box;
background:
radial-gradient(circle at 50% 0, rgba(255, 113, 89, .42) 0, rgba(255, 113, 89, 0) 32%),
linear-gradient(180deg, #d6001c 0%, #bd0018 56%, #a90016 100%);
color: #fff;
}
.honor-showcase /deep/ .van-nav-bar {
background: #fff;
}
.honor-showcase /deep/ .van-nav-bar__title {
color: #20242a;
font-weight: 700;
}
.honor-showcase /deep/ .van-nav-bar .van-icon,
.honor-showcase /deep/ .van-nav-bar__text {
color: #1989fa;
}
.honor-showcase /deep/ .van-hairline--bottom::after {
border-color: #eef1f6;
}
.honor-tabs {
display: flex;
justify-content: space-around;
align-items: center;
height: 76px;
}
.honor-tab {
position: relative;
border: 0;
background: transparent;
color: rgba(255, 247, 220, .76);
font-size: 19px;
font-weight: 700;
line-height: 28px;
padding: 0 8px 14px;
outline: none;
}
.honor-tab.active {
color: #fff6d9;
}
.honor-tab.active::after {
content: "";
position: absolute;
left: 50%;
bottom: 4px;
width: 38px;
height: 5px;
border-radius: 999px;
background: #ffe6a3;
transform: translateX(-50%);
box-shadow: 0 2px 8px rgba(255, 230, 163, .38);
}
.honor-intro {
position: relative;
margin-bottom: 18px;
padding: 20px 20px 46px;
border-radius: 8px;
overflow: hidden;
color: #ffe9bd;
background:
linear-gradient(165deg, rgba(255, 255, 255, .12), rgba(255, 255, 255, 0) 46%),
linear-gradient(180deg, #d73535 0%, #c9262f 100%);
box-shadow: 0 10px 22px rgba(90, 0, 8, .22);
}
.honor-intro::before,
.honor-intro::after {
content: "";
position: absolute;
left: -30px;
right: -30px;
height: 58px;
border: 5px solid rgba(255, 223, 151, .86);
border-top: 0;
border-radius: 0 0 50% 50%;
pointer-events: none;
}
.honor-intro::before {
bottom: 14px;
transform: rotate(-2deg);
}
.honor-intro::after {
bottom: -12px;
opacity: .75;
transform: rotate(2deg);
}
.honor-intro p {
position: relative;
z-index: 1;
margin: 0 0 9px;
font-size: 14px;
line-height: 24px;
}
.honor-intro p:last-child {
margin-bottom: 0;
}
.honor-intro__corner {
position: absolute;
top: 14px;
width: 24px;
height: 24px;
border-top: 3px solid rgba(255, 224, 161, .82);
}
.honor-intro__corner--left {
left: 14px;
border-left: 3px solid rgba(255, 224, 161, .82);
}
.honor-intro__corner--right {
right: 14px;
border-right: 3px solid rgba(255, 224, 161, .82);
}
.honor-loading {
margin-top: 36px;
text-align: center;
}
.honor-list {
padding-bottom: 8px;
}
.honor-card {
margin-bottom: 18px;
border-radius: 8px;
overflow: hidden;
background: #be0018;
box-shadow: 0 10px 22px rgba(83, 0, 9, .28);
}
.honor-card__image-wrap {
position: relative;
height: 206px;
overflow: hidden;
background: #e6b06d;
}
.honor-card__image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.honor-card__placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #fff5d8;
background:
radial-gradient(circle at 78% 30%, rgba(255, 221, 148, .36), rgba(255, 221, 148, 0) 26%),
linear-gradient(140deg, #d93a31 0%, #b60018 100%);
}
.honor-card__placeholder-title {
font-size: 24px;
font-weight: 700;
}
.honor-card__placeholder-sub {
margin-top: 10px;
font-size: 14px;
letter-spacing: 0;
color: rgba(255, 245, 216, .82);
}
.honor-card__image-wrap::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 58px;
background: linear-gradient(165deg, rgba(177, 0, 24, .86) 0%, rgba(177, 0, 24, .74) 62%, rgba(177, 0, 24, .1) 63%);
}
.honor-card__name {
position: absolute;
left: 20px;
bottom: 16px;
z-index: 1;
color: #fff;
font-size: 22px;
line-height: 28px;
font-weight: 800;
text-shadow: 0 2px 8px rgba(89, 0, 12, .32);
}
.honor-card__body {
position: relative;
min-height: 82px;
padding: 22px 20px 20px;
box-sizing: border-box;
color: #ffe5ad;
background:
radial-gradient(circle at 92% 14%, rgba(255, 230, 168, .4), rgba(255, 230, 168, 0) 16%),
#be0018;
}
.honor-card__body::after {
content: "★";
position: absolute;
right: 18px;
top: -20px;
color: #ffd886;
font-size: 42px;
text-shadow: 0 3px 8px rgba(85, 0, 9, .28);
transform: rotate(-12deg);
}
.honor-card__gold-line {
width: 44px;
height: 3px;
border-radius: 999px;
background: #e7b13c;
margin-bottom: 12px;
}
.honor-card__prize {
font-size: 15px;
line-height: 22px;
font-weight: 700;
color: #ffe5ad;
}
.honor-card__date {
margin-top: 5px;
font-size: 12px;
line-height: 18px;
color: rgba(255, 229, 173, .72);
}
.honor-showcase /deep/ .van-empty__description {
color: rgba(255, 255, 255, .78);
}
`
})
</script>
<!--#
}
#-->
@@ -129,6 +129,7 @@ layout("/layouts/platform_h5.html"){
<van-field label="姓名" readonly v-model="signupForm.userName"></van-field>
<van-field label="工号" readonly v-model="signupForm.jobNo"></van-field>
<van-field label="身份证号码" v-model="signupForm.idCard" maxlength="30" placeholder="请填写身份证号码"></van-field>
<van-field label="性别" readonly v-model="signupForm.gender"></van-field>
<van-field label="年龄" readonly :value="staffAge(signupForm)"></van-field>
<van-field label="手机号" v-model="signupForm.mobile" type="tel" maxlength="30" placeholder="请填写手机号"></van-field>
<van-field label="所属工会" readonly v-model="signupForm.unionName"></van-field>
@@ -156,7 +156,7 @@ layout("/layouts/platform_h5.html"){
}
.tour-line-shortcuts {
display: grid;
display: none;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-top: 10px;
@@ -212,29 +212,54 @@ layout("/layouts/platform_h5.html"){
padding-right: 16px;
}
.tour-line-period-entry {
width: 100%;
min-height: 40px;
border: 0;
border-radius: 8px;
padding: 0 12px;
background: #ffffff;
color: #0f172a;
.tour-line-period-list {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 2px;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
}
.tour-line-period-list::-webkit-scrollbar {
display: none;
}
.tour-line-period-chip {
min-width: 78px;
max-width: 168px;
min-height: 34px;
flex: 0 0 auto;
border: 1px solid #dbe3ee;
border-radius: 17px;
padding: 0 12px;
background: #ffffff;
color: #334155;
font-size: 14px;
line-height: 1.4;
line-height: 32px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.05);
box-sizing: border-box;
}
.tour-line-period-entry--active {
color: #0b75bd;
.tour-line-period-chip--active {
border-color: #0b75bd;
background: #0b75bd;
color: #ffffff;
font-weight: 700;
}
.tour-line-period-placeholder {
color: #64748b;
.tour-line-period-tip {
margin-top: 6px;
color: #d97706;
font-size: 12px;
line-height: 18px;
}
.tour-line-list {
@@ -465,14 +490,18 @@ layout("/layouts/platform_h5.html"){
</div>
</div>
<div class="tour-line-filter-bar">
<button
type="button"
class="tour-line-period-entry"
:class="{'tour-line-period-entry--active': !!pageForm.travelPeriod}"
@click="openTravelPeriodPopup">
<span :class="{'tour-line-period-placeholder': !pageForm.travelPeriod}">{{ selectedTravelPeriodText() }}</span>
<van-icon name="arrow-down"></van-icon>
</button>
<div class="tour-line-period-list">
<button
v-for="item in visibleTravelPeriodList"
:key="item.value || 'all'"
type="button"
class="tour-line-period-chip"
:class="{'tour-line-period-chip--active': pageForm.travelPeriod === item.value}"
@click="selectTravelPeriod(item)">
{{ item.name }}
</button>
</div>
<div class="tour-line-period-tip">可选择指定出行时间,快速查看对应线路。</div>
</div>
</div>
@@ -485,7 +514,7 @@ layout("/layouts/platform_h5.html"){
<div class="tour-line-list">
<div class="tour-line-card" v-for="row in list" :key="row.matterId" @click="onLine(row)">
<div class="tour-line-thumb">
<van-image v-if="thumbUrl(row)" :src="thumbUrl(row)" fit="cover" lazy-load>
<van-image v-if="row._thumbUrl" :src="row._thumbUrl" fit="cover">
<template #loading>
<div class="tour-line-thumb-empty">图片加载中</div>
</template>
@@ -556,25 +585,6 @@ layout("/layouts/platform_h5.html"){
</div>
</van-popup>
<van-popup v-model="showTravelPeriodPopup" position="bottom" round>
<div class="tour-line-union-popup">
<div class="tour-line-union-popup__title">选择出行时段</div>
<div class="tour-line-union-popup__list">
<van-cell
v-for="item in travelPeriodList"
:key="item.value || 'all'"
clickable
class="tour-line-union-popup__item"
:class="{'tour-line-union-popup__item--active': pageForm.travelPeriod === item.value}"
:title="item.name"
@click="selectTravelPeriod(item)">
<template #right-icon>
<van-icon v-if="pageForm.travelPeriod === item.value" name="success" color="#0b75bd"></van-icon>
</template>
</van-cell>
</div>
</div>
</van-popup>
</div>
<script nonce="${cspNonce!}">
@@ -588,13 +598,10 @@ layout("/layouts/platform_h5.html"){
finished: false,
lineChecking: false,
showUnionPopup: false,
showTravelPeriodPopup: false,
unionList: [
{id: "", name: "全部分工会"}
],
travelPeriodList: [
{value: "", name: "全部出行时段"}
],
travelPeriodList: [],
pageForm: {
pageNumber: 1,
pageSize: 10,
@@ -610,9 +617,56 @@ layout("/layouts/platform_h5.html"){
}
}
},
computed: {
visibleTravelPeriodList() {
return this.travelPeriodList.filter((item) => item && item.value)
}
},
methods: {
thumbUrl(row) {
return row.lineMobileThumb || row.agencyMobileThumb || ""
const thumb = this.resolveThumbPath(row && (row.lineMobileThumb || row.agencyMobileThumb))
if (!thumb) {
return ""
}
if (thumb.indexOf("http://") === 0 || thumb.indexOf("https://") === 0 || thumb.indexOf("//") === 0 || thumb.indexOf("data:") === 0) {
return thumb
}
const domain = "${AppFileDomain!}" || "${AppDomain!}" || ""
if (!domain) {
return thumb
}
const prefix = domain.lastIndexOf("/") === domain.length - 1 ? domain.substring(0, domain.length - 1) : domain
const path = thumb.indexOf("/") === 0 ? thumb : "/" + thumb
return prefix + path
},
resolveThumbPath(value) {
if (!value) {
return ""
}
if (Array.isArray(value)) {
return value.length ? this.resolveThumbPath(value[0]) : ""
}
if (typeof value === "object") {
return value.thumbnail || value.downloadPath || value.url || value.data || value.path || ""
}
const text = String(value).trim()
if (!text) {
return ""
}
if (text.indexOf("[") === 0 || text.indexOf("{") === 0) {
try {
return this.resolveThumbPath(JSON.parse(text))
} catch (e) {
return text
}
}
return text.split(",").map((item) => item.trim()).filter((item) => item)[0] || ""
},
normalizeLineRows(rows) {
return (rows || []).map((row) => {
row._thumbUrl = this.thumbUrl(row)
return row
})
},
lineTypeBadge(lineType) {
if (lineType === "省内线路" || lineType === "省内") {
@@ -623,13 +677,6 @@ layout("/layouts/platform_h5.html"){
}
return ""
},
selectedTravelPeriodText() {
if (!this.pageForm.travelPeriod) {
return "选择出行时段"
}
const selected = this.travelPeriodList.find((item) => item.value === this.pageForm.travelPeriod)
return selected && selected.name ? selected.name : this.pageForm.travelPeriod
},
isSigned(row) {
return !!(row && (row.signed === true || row.signed === 1 || row.signed === "1" || row.ledgerId))
},
@@ -769,12 +816,6 @@ layout("/layouts/platform_h5.html"){
this.loadUnionOptions()
}
},
openTravelPeriodPopup() {
this.showTravelPeriodPopup = true
if (this.travelPeriodList.length <= 1) {
this.loadTravelPeriodOptions()
}
},
selectUnion(item) {
const unionId = item && item.id ? item.id : ""
this.showUnionPopup = false
@@ -786,8 +827,9 @@ layout("/layouts/platform_h5.html"){
},
selectTravelPeriod(item) {
const travelPeriod = item && item.value ? item.value : ""
this.showTravelPeriodPopup = false
if (this.pageForm.travelPeriod === travelPeriod) {
this.pageForm.travelPeriod = ""
this.doSearch()
return
}
this.pageForm.travelPeriod = travelPeriod
@@ -817,19 +859,14 @@ layout("/layouts/platform_h5.html"){
return
}
const rows = res.data || []
this.travelPeriodList = [{value: "", name: "全部出行时段"}].concat(rows.map((item) => {
this.travelPeriodList = rows.map((item) => {
return {
value: item.travelPeriod || "",
name: item.travelPeriod || "未设置出行时间"
}
}).filter((item) => item.value))
})
},
showSignupNotice() {
vant.Dialog.alert({
title: "温馨提示",
message: "请确认并选择对应的出行路线时间段进行报名",
confirmButtonColor: "#1867b0"
}).filter((item) => item.value).sort((a, b) => {
return String(b.value).localeCompare(String(a.value))
})
})
},
loadData() {
@@ -846,7 +883,7 @@ layout("/layouts/platform_h5.html"){
return
}
const data = res.data || {}
const rows = data.list || []
const rows = this.normalizeLineRows(data.list || [])
this.pageForm.totalCount = data.totalCount || 0
this.list = this.pageForm.pageNumber === 1 ? rows : this.list.concat(rows)
if (this.list.length >= this.pageForm.totalCount || rows.length === 0) {
@@ -899,7 +936,6 @@ layout("/layouts/platform_h5.html"){
}
},
created() {
this.showSignupNotice()
this.loadUnionOptions()
this.loadTravelPeriodOptions()
this.loadData()
@@ -0,0 +1,612 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<style>
.learning-course-page {
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
background: #f4f7fb;
padding-bottom: env(safe-area-inset-bottom);
box-sizing: border-box;
}
.learning-course-page .van-nav-bar {
background: #f4f7fb;
}
.learning-banner-wrap {
flex-shrink: 0;
padding: 10px 14px 0;
}
.learning-banner {
height: 128px;
overflow: hidden;
border-radius: 6px;
background: #dce7f5;
}
.learning-banner-item {
position: relative;
height: 128px;
overflow: hidden;
background: linear-gradient(135deg, #0b75bd, #33b3a6);
}
.learning-banner-item img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.learning-banner-fallback {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
padding: 0 22px;
color: #ffffff;
box-sizing: border-box;
background:
radial-gradient(circle at 14% 20%, rgba(255,255,255,.26), transparent 22%),
linear-gradient(135deg, #0b75bd, #35b6a8 55%, #f6b24d);
}
.learning-banner-title {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 28px 14px 12px;
color: #ffffff;
font-size: 16px;
font-weight: 700;
line-height: 1.35;
background: linear-gradient(to top, rgba(15, 23, 42, .64), transparent);
box-sizing: border-box;
}
.learning-banner-fallback .learning-banner-title {
position: static;
padding: 0;
background: transparent;
}
.learning-filter-card {
flex-shrink: 0;
margin: 14px 14px 0;
padding: 10px;
border-radius: 8px;
background: #ffffff;
}
.learning-list-scroll {
min-height: 0;
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
box-sizing: border-box;
}
.learning-filter-group {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
min-height: 72px;
overflow: hidden;
border-radius: 6px;
background: #eef6ff;
}
.learning-filter-group + .learning-filter-group {
margin-top: 8px;
}
.learning-filter-category {
display: flex;
align-items: center;
padding: 12px;
color: #ffffff;
box-sizing: border-box;
}
.learning-filter-category-text {
min-width: 0;
}
.learning-filter-category-title {
font-size: 15px;
font-weight: 800;
line-height: 1.2;
}
.learning-filter-options {
display: flex;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
background: rgba(255, 255, 255, .55);
}
.learning-filter-options::-webkit-scrollbar {
display: none;
}
.learning-filter-item {
flex: 0 0 72px;
min-width: 72px;
padding: 8px 2px 7px;
border-left: 1px solid rgba(255, 255, 255, .72);
text-align: center;
color: #5f6b7a;
font-size: 11px;
box-sizing: border-box;
}
.learning-filter-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin: 0 auto 5px;
border-radius: 50%;
color: #4f87c8;
font-size: 18px;
background: rgba(255, 255, 255, .78);
}
.learning-filter-item.no-icon {
display: flex;
align-items: center;
justify-content: center;
min-height: 58px;
color: #0b75bd;
font-weight: 700;
}
.learning-filter-item.active {
color: #0b75bd;
font-weight: 700;
background: rgba(255, 255, 255, .86);
}
.learning-filter-item.active .learning-filter-icon {
color: #ffffff;
background: #0b75bd;
}
.learning-filter-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.learning-section-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 16px 10px;
}
.learning-section-title {
color: #202733;
font-size: 20px;
font-weight: 800;
line-height: 1.2;
}
.learning-section-total {
color: #8b96a6;
font-size: 13px;
}
.learning-course-list {
padding: 0 12px 18px;
}
.learning-course-card {
display: flex;
gap: 14px;
min-height: 104px;
margin-bottom: 12px;
padding: 14px;
border-radius: 8px;
background: #ffffff;
box-sizing: border-box;
}
.learning-course-cover {
position: relative;
flex: 0 0 142px;
width: 142px;
height: 82px;
overflow: hidden;
border-radius: 4px;
background: #e6edf6;
}
.learning-course-cover img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.learning-course-cover-empty {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 8px;
color: #ffffff;
font-size: 13px;
text-align: center;
box-sizing: border-box;
background: linear-gradient(135deg, #3a7bd5, #8b5cf6);
}
.learning-course-info {
flex: 1;
min-width: 0;
}
.learning-course-title {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
}
.learning-course-title-text {
min-width: 0;
color: #202733;
font-size: 17px;
font-weight: 700;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.learning-course-badge {
flex-shrink: 0;
max-width: 72px;
padding: 1px 6px;
border: 1px solid #ffd1a8;
border-radius: 4px;
color: #f28a35;
font-size: 11px;
font-weight: 500;
line-height: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: #fff7ee;
}
.learning-course-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 9px;
}
.learning-course-intro {
margin-top: 8px;
color: #7a8798;
font-size: 12px;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.learning-empty {
padding: 22px 0 36px;
}
@media (max-width: 360px) {
.learning-filter-card {
margin-left: 10px;
margin-right: 10px;
padding: 8px;
}
.learning-filter-group {
grid-template-columns: 94px minmax(0, 1fr);
}
.learning-filter-category {
padding: 10px 8px;
}
.learning-filter-category-title {
font-size: 13px;
}
.learning-filter-icon {
width: 26px;
height: 26px;
font-size: 17px;
}
.learning-filter-item {
font-size: 11px;
}
.learning-course-cover {
flex-basis: 120px;
width: 120px;
}
}
</style>
<div id="app" class="learning-course-page" v-cloak>
<van-nav-bar title="学习教育" left-text="返回" left-arrow @click-left="historyBack" fixed placeholder></van-nav-bar>
<div class="learning-banner-wrap">
<van-swipe class="learning-banner" :autoplay="3500" indicator-color="#ffffff">
<van-swipe-item v-for="course in bannerCourses" :key="course.id" @click="openCourse(course)">
<div class="learning-banner-item">
<img v-if="getCoverUrl(course.cover)" :src="getCoverUrl(course.cover)" :alt="course.courseName">
<div v-else class="learning-banner-fallback">
<div class="learning-banner-title">{{ course.courseName || '学习教育' }}</div>
</div>
<div v-if="getCoverUrl(course.cover)" class="learning-banner-title">{{ course.courseName || '学习教育' }}</div>
</div>
</van-swipe-item>
<van-swipe-item v-if="!bannerCourses.length">
<div class="learning-banner-item learning-banner-fallback">
<div class="learning-banner-title">学习教育</div>
</div>
</van-swipe-item>
</van-swipe>
</div>
<div class="learning-filter-card">
<div class="learning-filter-group" v-for="group in filterGroups" :key="group.key">
<div class="learning-filter-category" :style="{background: group.color}">
<div class="learning-filter-category-text">
<div class="learning-filter-category-title">{{ group.name }}</div>
</div>
</div>
<div class="learning-filter-options">
<div
v-for="item in group.items"
:key="item.key"
class="learning-filter-item"
:class="{active: isFilterActive(item), 'no-icon': !item.icon}"
@click="selectFilter(item)">
<div v-if="item.icon" class="learning-filter-icon">
<van-icon :name="item.icon"></van-icon>
</div>
<div class="learning-filter-name">{{ item.name }}</div>
</div>
</div>
</div>
</div>
<div class="learning-list-scroll">
<div class="learning-section-head">
<div class="learning-section-title">{{ listTitle }}</div>
<div class="learning-section-total">共 {{ pageForm.totalCount || 0 }} 门</div>
</div>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model="loading"
:finished="finished"
:immediate-check="false"
finished-text="没有更多了"
@load="onLoad">
<div class="learning-course-list">
<div
class="learning-course-card"
v-for="course in courseList"
:key="course.id"
@click="openCourse(course)">
<div class="learning-course-cover">
<img v-if="getCoverUrl(course.cover)" :src="getCoverUrl(course.cover)" :alt="course.courseName">
<div v-else class="learning-course-cover-empty">{{ course.courseTypeName || '课程' }}</div>
</div>
<div class="learning-course-info">
<div class="learning-course-title">
<div class="learning-course-title-text">{{ course.courseName || '未命名课程' }}</div>
<div v-if="firstRecommendName(course.recommendFlags)" class="learning-course-badge">{{ firstRecommendName(course.recommendFlags) }}</div>
</div>
<div class="learning-course-meta">
<van-tag v-if="course.courseTypeName" plain type="primary">{{ course.courseTypeName }}</van-tag>
<van-tag v-if="course.lecturerName" plain type="success">{{ course.lecturerName }}</van-tag>
</div>
<div class="learning-course-intro">{{ course.courseIntro || '暂无课程介绍' }}</div>
</div>
</div>
</div>
</van-list>
</van-pull-refresh>
<van-empty v-if="!loading && !courseList.length" class="learning-empty" description="暂无相关课程"></van-empty>
</div>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
store,
data() {
return {
apiBase: "/platform/learning/course/display",
query: {
keyword: "",
courseTypeId: "",
recommendFlag: ""
},
pageForm: {
pageNumber: 1,
pageSize: 10,
totalCount: 0
},
courseList: [],
bannerCourses: [],
courseTypeOptions: [],
recommendOptions: [],
loading: false,
finished: false,
refreshing: false,
requestSeq: 0
}
},
computed: {
filterGroups() {
const recommendItems = this.recommendOptions.map((item, index) => ({
key: "recommend_" + item.code,
type: "recommend",
value: item.code,
name: item.name,
icon: this.recommendIcon(index)
}))
const typeItems = this.courseTypeOptions.map((item, index) => ({
key: "type_" + item.id,
type: "type",
value: item.id,
name: item.typeName,
icon: this.typeIcon(index)
}))
return [
{
key: "recommend",
name: "推荐标识",
color: "linear-gradient(135deg, #ff8a3d, #ffbf5e)",
items: recommendItems
},
{
key: "type",
name: "课程类型",
color: "linear-gradient(135deg, #2878d7, #64a8f4)",
items: typeItems
}
]
},
listTitle() {
const recommend = this.recommendOptions.find(item => item.code === this.query.recommendFlag)
const type = this.courseTypeOptions.find(item => item.id === this.query.courseTypeId)
if (recommend && type) {
return recommend.name + " · " + type.typeName
}
if (recommend) {
return recommend.name
}
if (type) {
return type.typeName
}
return "我的课堂"
}
},
methods: {
historyBack,
async loadOptions() {
const [typeResp, recommendResp] = await Promise.all([
axios.post(this.apiBase + "/courseTypes"),
axios.post(this.apiBase + "/recommendOptions")
])
if (typeResp.code === 0) {
this.courseTypeOptions = typeResp.data || []
}
if (recommendResp.code === 0) {
this.recommendOptions = recommendResp.data || []
}
},
onRefresh() {
this.finished = false
this.pageForm.pageNumber = 1
this.courseList = []
this.onLoad()
},
onLoad() {
this.fetchCourses()
},
fetchCourses() {
this.loading = true
const requestSeq = ++this.requestSeq
this.$axios.post(this.apiBase + "/pageData", {
keyword: this.query.keyword,
courseTypeId: this.query.courseTypeId,
recommendFlag: this.query.recommendFlag,
pageNumber: this.pageForm.pageNumber,
pageSize: this.pageForm.pageSize
}).then((res) => {
if (requestSeq !== this.requestSeq) return
if (res.code === 0) {
const data = res.data || {}
const list = data.list || []
if (this.refreshing || this.pageForm.pageNumber === 1) {
this.courseList = []
}
this.courseList = this.courseList.concat(list)
this.pageForm.totalCount = data.totalCount || 0
this.pageForm.pageNumber += 1
this.finished = this.courseList.length >= this.pageForm.totalCount
this.syncBannerCourses()
} else {
this.finished = true
this.$toast(res.msg || "查询失败")
}
}).finally(() => {
if (requestSeq === this.requestSeq) {
this.loading = false
this.refreshing = false
}
})
},
doSearch() {
this.pageForm.pageNumber = 1
this.pageForm.totalCount = 0
this.courseList = []
this.finished = false
this.loading = true
this.onLoad()
},
selectFilter(item) {
if (!item) return
if (item.type === "recommend") {
this.query.recommendFlag = item.value || ""
} else if (item.type === "type") {
this.query.courseTypeId = item.value || ""
}
this.doSearch()
},
isFilterActive(item) {
if (!item) return false
if (item.type === "recommend") {
return (this.query.recommendFlag || "") === (item.value || "")
}
if (item.type === "type") {
return (this.query.courseTypeId || "") === (item.value || "")
}
return false
},
syncBannerCourses() {
const withCover = this.courseList.filter(item => this.getCoverUrl(item.cover)).slice(0, 5)
this.bannerCourses = (withCover.length ? withCover : this.courseList.slice(0, 5))
},
getCoverUrl(cover) {
if (!cover) return ""
if (Array.isArray(cover)) {
return cover.length ? (cover[0].url || (cover[0].response && cover[0].response.data) || cover[0].data || "") : ""
}
if (typeof cover === "string") {
const text = cover.trim()
if (!text) return ""
if (text.startsWith("[")) {
try {
const files = JSON.parse(text)
return files.length ? (files[0].url || (files[0].response && files[0].response.data) || files[0].data || "") : ""
} catch (e) {
return ""
}
}
return text
}
return ""
},
openCourse(course) {
if (!course || !course.id) return
pjaxReplace("/platform/learning/course/h5/study?id=" + course.id)
},
splitFlags(flags) {
return flags ? flags.split(",").filter(Boolean) : []
},
getRecommendName(code) {
const item = this.recommendOptions.find(v => v.code === code)
return item ? item.name : code
},
firstRecommendName(flags) {
const codes = this.splitFlags(flags)
return codes.length ? this.getRecommendName(codes[0]) : ""
},
recommendIcon(index) {
const icons = ["fire-o", "star-o", "award-o", "gem-o", "flag-o", "good-job-o"]
return icons[index % icons.length]
},
typeIcon(index) {
const icons = ["video-o", "records-o", "description-o", "bookmark-o", "cluster-o", "desktop-o"]
return icons[index % icons.length]
},
palette(index) {
const colors = ["#28a6df", "#f2b84b", "#f46d5f", "#7b8fda", "#59bbb2", "#2d7db8", "#ee755b", "#f3c256", "#ef9461", "#8ba4dc"]
return colors[index % colors.length]
}
},
async mounted() {
await this.loadOptions()
this.onLoad()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,732 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<style>
.learning-study-page {
height: 100vh;
height: 100dvh;
overflow: hidden;
display: flex;
flex-direction: column;
background: #f5f6f8;
}
.study-hero {
position: relative;
flex-shrink: 0;
height: 195px;
background: linear-gradient(135deg, #7554e8, #48b7f0);
overflow: hidden;
}
.study-hero img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.study-hero-empty {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 28px;
color: #ffffff;
font-size: 26px;
font-weight: 800;
line-height: 1.35;
text-align: center;
box-sizing: border-box;
}
.study-back {
position: absolute;
top: 10px;
left: 8px;
z-index: 2;
width: 38px;
height: 38px;
border-radius: 50%;
color: #ffffff;
font-size: 24px;
background: rgba(15, 23, 42, .22);
display: flex;
align-items: center;
justify-content: center;
}
.study-title-block {
flex-shrink: 0;
padding: 16px 18px 10px;
background: #ffffff;
}
.study-title {
color: #202733;
font-size: 20px;
font-weight: 800;
line-height: 1.4;
}
.study-meta {
display: flex;
align-items: center;
gap: 18px;
margin-top: 14px;
color: #8a93a3;
font-size: 14px;
}
.study-meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.study-tabs {
min-height: 0;
flex: 1;
display: flex;
flex-direction: column;
background: #ffffff;
}
.study-tabs .van-tabs__content {
min-height: 0;
flex: 1;
overflow: hidden;
}
.study-tabs .van-tabs__wrap {
flex-shrink: 0;
}
.study-tabs .van-tabs__track {
height: 100%;
}
.study-tabs .van-tab__pane {
height: 100%;
overflow: hidden;
}
.study-intro-scroll,
.study-catalog-scroll {
height: 100%;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
background: #f5f6f8;
}
.study-summary {
display: flex;
justify-content: space-between;
padding: 18px;
color: #8b94a3;
font-size: 16px;
}
.study-summary strong {
color: #202733;
font-size: 20px;
}
.study-card {
margin: 18px;
padding: 14px 0;
border-radius: 10px;
background: #ffffff;
box-shadow: 0 8px 20px rgba(15, 23, 42, .04);
overflow: hidden;
}
.study-chapter {
padding: 0 18px;
}
.study-chapter-head {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0 16px;
color: #202733;
font-size: 17px;
font-weight: 800;
}
.study-chapter-dot {
width: 14px;
height: 14px;
border: 2px solid #ff9b2f;
border-radius: 50%;
box-sizing: border-box;
flex-shrink: 0;
}
.study-resource {
display: flex;
gap: 10px;
padding: 14px 0 12px 18px;
border-top: 1px solid #f0f2f5;
}
.study-resource-icon {
width: 21px;
height: 21px;
margin-top: 2px;
border-radius: 3px;
color: #ffffff;
font-size: 15px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.study-resource-icon.video {
background: #ffbd2e;
}
.study-resource-icon.audio {
background: #33b3a6;
}
.study-resource-icon.image {
background: #5a94f2;
}
.study-resource-icon.doc {
background: #ff6868;
}
.study-resource-main {
min-width: 0;
flex: 1;
}
.study-resource-title {
color: #5c6470;
font-size: 15px;
line-height: 1.45;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.study-resource-status {
margin-top: 8px;
color: #a2a9b5;
font-size: 15px;
}
.study-intro {
padding: 18px;
color: #4e5969;
font-size: 15px;
line-height: 1.8;
background: #ffffff;
min-height: 180px;
}
.study-player-popup {
width: 100%;
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
border-radius: 0;
background: #000000;
}
.study-player-head {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
color: #ffffff;
background: #101828;
border-bottom: 1px solid rgba(255, 255, 255, .08);
}
.study-player-title {
min-width: 0;
flex: 1;
color: #ffffff;
font-size: 16px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.study-player-body {
min-height: 0;
flex: 1;
overflow: auto;
padding: 0;
box-sizing: border-box;
background: #000000;
}
.study-media {
width: 100%;
height: 100%;
max-height: none;
background: #000000;
border-radius: 0;
object-fit: contain;
}
.study-audio-wrap {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 220px;
border-radius: 0;
background: linear-gradient(135deg, #10233f, #0f766e);
}
.study-audio-wrap audio {
width: 88%;
}
.study-image-preview {
display: block;
max-width: 100%;
margin: 0 auto;
border-radius: 0;
background: #ffffff;
}
.study-doc-frame {
width: 100%;
height: 100%;
border: 0;
border-radius: 0;
background: #ffffff;
}
.study-file-fallback {
padding: 46px 18px;
color: #7a8798;
font-size: 15px;
text-align: center;
background: #ffffff;
border-radius: 0;
}
.study-player-exit {
flex-shrink: 0;
margin-left: 12px;
color: #ffffff;
font-size: 14px;
}
.study-player-actions {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 14px;
margin-left: 12px;
color: #ffffff;
font-size: 14px;
}
</style>
<div id="app" class="learning-study-page" v-cloak>
<div class="study-hero">
<div class="study-back" @click="goBack"><van-icon name="arrow-left"></van-icon></div>
<img v-if="coverUrl" :src="coverUrl" :alt="course.courseName">
<div v-else class="study-hero-empty">{{ course.courseName || '课程学习' }}</div>
</div>
<div class="study-title-block">
<div class="study-title">{{ course.courseName || '课程学习' }}</div>
<div class="study-meta">
<span class="study-meta-item"><van-icon name="eye-o"></van-icon>{{ studyCountText }}</span>
<span class="study-meta-item"><van-icon name="bookmark-o"></van-icon>收藏</span>
<span class="study-meta-item"><van-icon name="share-o"></van-icon>分享</span>
</div>
</div>
<van-tabs v-model="activeTab" class="study-tabs" color="#2d9cff" line-width="34px" title-active-color="#202733">
<van-tab title="课程介绍" name="intro">
<div class="study-intro-scroll">
<div class="study-intro">{{ course.courseIntro || '暂无课程介绍' }}</div>
</div>
</van-tab>
<van-tab title="课程目录" name="catalog">
<div class="study-catalog-scroll">
<div class="study-summary">
<div><strong>{{ chapterCount }}</strong> 个章节,<strong>{{ totalMinutes }}</strong> 分钟</div>
<div>总学习进度 <strong>{{ totalProgress }}%</strong></div>
</div>
<div v-for="chapter in catalogList" :key="chapter.id" class="study-card">
<div class="study-chapter">
<div class="study-chapter-head" @click="selectChapter(chapter)">
<span class="study-chapter-dot"></span>
<span>{{ chapter.title }}</span>
</div>
<div
v-for="resource in chapter.resources"
:key="resource.id"
class="study-resource"
@click="openResource(resource)">
<div class="study-resource-icon" :class="resourceIconClass(resource)">
<van-icon :name="resourceIcon(resource)"></van-icon>
</div>
<div class="study-resource-main">
<div class="study-resource-title">{{ resource.title }}</div>
<div class="study-resource-status">{{ statusText(resource) }}</div>
</div>
</div>
</div>
</div>
<van-empty v-if="!catalogList.length" description="暂无课程目录"></van-empty>
</div>
</van-tab>
</van-tabs>
<van-popup v-model="playerVisible" position="bottom" class="study-player-popup" :overlay="false">
<div class="study-player-head">
<div class="study-player-title">{{ selectedResource.title || '学习资料' }}</div>
<div class="study-player-actions">
<span v-if="selectedResource.resourceType === 'video'" @click="enterVideoFullscreen">横屏</span>
<span @click="closePlayer">退出</span>
</div>
</div>
<div class="study-player-body">
<video
v-if="selectedResource.resourceType === 'video'"
ref="mediaPlayer"
class="study-media"
:src="fileUrl"
controls
playsinline
webkit-playsinline
x5-video-player-type="h5"
x5-video-orientation="landscape|portrait"
x-webkit-airplay="allow"
@play="startStudy"
@timeupdate="saveProgress"
@ended="finishStudy(false)">
</video>
<div v-else-if="selectedResource.resourceType === 'audio'" class="study-audio-wrap">
<audio
ref="mediaPlayer"
:src="fileUrl"
controls
@play="startStudy"
@timeupdate="saveProgress"
@ended="finishStudy(false)">
</audio>
</div>
<img v-else-if="selectedResource.resourceType === 'image'" class="study-image-preview" :src="fileUrl" :alt="selectedResource.title">
<iframe v-else-if="canInlinePreview(selectedResource)" class="study-doc-frame" :src="inlinePreviewUrl"></iframe>
<div v-else class="study-file-fallback">
<p>该资料暂不支持内嵌预览</p>
<van-button type="info" size="small" @click="openFile">打开资料</van-button>
</div>
</div>
</van-popup>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
courseId: "",
apiBase: "/platform/learning/course/display",
recordApi: "/platform/learning/study/record",
activeTab: "catalog",
course: {},
treeData: [],
selectedResource: {},
playerVisible: false,
currentSegmentId: "",
studying: false,
pendingSeconds: 0,
heartbeatTimer: null,
navigatingBack: false
}
},
computed: {
coverUrl() {
return this.getFileUrl(this.course.cover)
},
fileUrl() {
return this.getFileUrl(this.selectedResource.fileData)
},
inlinePreviewUrl() {
const id = this.getFileId(this.selectedResource.fileData)
return id ? this.apiBase + "/pdfPreview?id=" + encodeURIComponent(id) : this.fileUrl
},
catalogList() {
const result = []
;(this.treeData || []).forEach(node => this.appendCatalogNode(node, result))
return result
},
chapterCount() {
return this.catalogList.length
},
totalMinutes() {
const seconds = this.catalogList.reduce((sum, chapter) => {
return sum + chapter.resources.reduce((value, item) => value + Number(item.durationSeconds || 0), 0)
}, 0)
return Math.max(0, Math.ceil(seconds / 60))
},
totalProgress() {
const resources = []
this.catalogList.forEach(chapter => resources.push.apply(resources, chapter.resources))
if (!resources.length) return 0
const total = resources.reduce((sum, item) => sum + Number(item.progressPercent || 0), 0)
return Math.floor(total / resources.length)
},
studyCountText() {
return this.course.lecturerName ? this.course.lecturerName : "开始学习"
}
},
methods: {
getQuery(name) {
return new URLSearchParams(window.location.search).get(name) || ""
},
async loadCourse() {
const resp = await axios.post(this.apiBase + "/courseInfo", { id: this.courseId })
if (resp.code === 0) {
this.course = resp.data || {}
} else {
this.$toast(resp.msg || "课程不存在")
}
},
async loadTree() {
const resp = await axios.post(this.apiBase + "/studyTree", { courseId: this.courseId })
if (resp.code === 0) {
this.treeData = resp.data || []
}
},
appendCatalogNode(node, result) {
if (!node) return
if (node.type === "resource") return
const resources = this.collectResources(node.children || [])
if (node.nodeType === "chapter" || resources.length) {
result.push({
id: node.id,
title: node.title,
resources: resources
})
}
;(node.children || []).forEach(child => {
if (child.type !== "resource" && child.nodeType === "chapter") {
this.appendCatalogNode(child, result)
}
})
},
collectResources(nodes) {
const resources = []
;(nodes || []).forEach(node => {
if (node.type === "resource") {
resources.push(node)
} else {
resources.push.apply(resources, this.collectResources(node.children || []))
}
})
return resources
},
selectChapter(chapter) {
if (chapter && chapter.resources && chapter.resources.length) {
this.openResource(chapter.resources[0])
}
},
openResource(resource) {
if (!resource || !resource.id) return
this.pauseMedia()
this.finishStudy(false)
this.selectedResource = resource
this.playerVisible = true
if (!["video", "audio"].includes(resource.resourceType)) {
this.$nextTick(() => this.startStudy())
} else {
this.$nextTick(() => this.resumeMediaPosition())
}
},
resumeMediaPosition() {
const player = this.$refs.mediaPlayer
const position = Number(this.selectedResource.lastPositionSeconds || 0)
if (player && position > 0) {
player.currentTime = position
}
},
async startStudy() {
if (!this.selectedResource.id || this.studying) return
const resp = await axios.post(this.recordApi + "/start", {
courseId: this.courseId,
resourceId: this.selectedResource.id,
positionSeconds: this.getMediaPosition()
})
if (resp.code !== 0) {
this.$toast(resp.msg || "开始学习失败")
return
}
this.currentSegmentId = resp.data.segmentId
this.studying = true
this.pendingSeconds = 0
this.applyRecordState(resp.data)
this.startHeartbeatTimer()
},
async heartbeat() {
if (!this.currentSegmentId || this.pendingSeconds <= 0) return
const seconds = this.pendingSeconds
this.pendingSeconds = 0
const resp = await axios.post(this.recordApi + "/heartbeat", {
segmentId: this.currentSegmentId,
activeSeconds: seconds,
positionSeconds: this.getMediaPosition()
})
if (resp.code === 0) {
this.applyRecordState(resp.data)
}
},
async finishStudy(force) {
this.pauseMedia()
if (!this.currentSegmentId) return
const segmentId = this.currentSegmentId
const seconds = this.pendingSeconds
this.pendingSeconds = 0
this.stopHeartbeatTimer()
this.studying = false
this.currentSegmentId = ""
await axios.post(this.recordApi + "/finish", {
segmentId: segmentId,
activeSeconds: seconds,
positionSeconds: this.getMediaPosition()
})
if (force) return
this.loadTree()
},
startHeartbeatTimer() {
this.stopHeartbeatTimer()
this.heartbeatTimer = setInterval(() => {
if (!this.studying || document.hidden) return
if (["video", "audio"].includes(this.selectedResource.resourceType)) {
const player = this.$refs.mediaPlayer
if (!player || player.paused || player.ended) return
}
this.pendingSeconds += 1
if (this.pendingSeconds >= 15) {
this.heartbeat()
}
}, 1000)
},
stopHeartbeatTimer() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
},
saveProgress() {
const position = this.getMediaPosition()
if (!position || !this.selectedResource.id) return
this.$set(this.selectedResource, "lastPositionSeconds", position)
},
applyRecordState(data) {
if (!this.selectedResource.id || !data) return
this.$set(this.selectedResource, "progressPercent", Number(data.progressPercent || 0))
this.$set(this.selectedResource, "studySeconds", Number(data.studySeconds || 0))
this.$set(this.selectedResource, "completeStatus", data.completeStatus || "studying")
if (data.lastPositionSeconds !== undefined) {
this.$set(this.selectedResource, "lastPositionSeconds", Number(data.lastPositionSeconds || 0))
}
},
getMediaPosition() {
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return 0
const player = this.$refs.mediaPlayer
return player ? Math.floor(player.currentTime || 0) : Number(this.selectedResource.lastPositionSeconds || 0)
},
statusText(resource) {
if (!resource || resource.completeStatus === "not_started") return "未学习"
if (resource.completeStatus === "completed") return "已完成"
return "学习中 " + Number(resource.progressPercent || 0) + "%"
},
resourceIcon(resource) {
if (resource.resourceType === "video") return "video"
if (resource.resourceType === "audio") return "music-o"
if (resource.resourceType === "image") return "photo-o"
return "description"
},
resourceIconClass(resource) {
if (resource.resourceType === "video") return "video"
if (resource.resourceType === "audio") return "audio"
if (resource.resourceType === "image") return "image"
return "doc"
},
getFileUrl(value) {
if (!value) return ""
if (Array.isArray(value)) {
return value.length ? (value[0].url || (value[0].response && value[0].response.data) || value[0].data || "") : ""
}
if (typeof value === "string") {
const text = value.trim()
if (!text) return ""
if (text.startsWith("[")) {
try {
const files = JSON.parse(text)
return files.length ? (files[0].url || (files[0].response && files[0].response.data) || files[0].data || "") : ""
} catch (e) {
return ""
}
}
return text
}
return ""
},
getFileId(value) {
const url = this.getFileUrl(value)
if (!url) return ""
const matched = url.match(/[?&]id=([^&]+)/)
if (matched) return decodeURIComponent(matched[1])
if (!url.includes("/") && !url.includes(".")) return url
return ""
},
canInlinePreview(resource) {
const type = resource.resourceType || ""
const ext = (resource.fileExt || "").toLowerCase()
return ["pdf", "ppt", "word"].includes(type) || ["pdf", "ppt", "pptx", "doc", "docx"].includes(ext)
},
openFile() {
if (this.fileUrl) {
window.open(this.fileUrl)
}
},
pauseMedia() {
const player = this.$refs.mediaPlayer
if (player && player.pause) {
player.pause()
}
},
enterVideoFullscreen() {
const player = this.$refs.mediaPlayer
if (!player) return
const requestFullscreen = player.requestFullscreen || player.webkitRequestFullscreen || player.mozRequestFullScreen || player.msRequestFullscreen
if (requestFullscreen) {
requestFullscreen.call(player)
} else if (player.webkitEnterFullscreen) {
player.webkitEnterFullscreen()
}
if (screen.orientation && screen.orientation.lock) {
screen.orientation.lock("landscape").catch(() => {})
}
},
closePlayer() {
this.pauseMedia()
this.finishStudy(false)
this.playerVisible = false
},
async goBack() {
if (this.navigatingBack) return
this.navigatingBack = true
try {
await this.finishStudy(true)
} finally {
this.navigateBack()
}
},
navigateBack() {
if (window.history.length > 1) {
window.history.back()
return
}
$.pjax({
url: "/platform/learning/course/h5",
container: "#container",
maxCacheLength: 0,
push: false,
replace: true,
fragment: "#container",
timeout: 8000
})
}
},
async mounted() {
this.courseId = this.getQuery("id")
if (!this.courseId) {
this.$toast("请选择课程")
return
}
await this.loadCourse()
await this.loadTree()
},
beforeDestroy() {
this.finishStudy(true)
this.stopHeartbeatTimer()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,371 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<style>
.my-study-page {
min-height: 100vh;
background: #f3f7fb;
color: #202733;
}
.my-study-hero {
position: relative;
padding: 86px 20px 72px;
color: #ffffff;
background: linear-gradient(135deg, #0ca7dd 0%, #0699d6 58%, #0f8fd7 100%);
overflow: hidden;
}
.my-study-hero::after {
content: "";
position: absolute;
right: -74px;
bottom: -108px;
width: 260px;
height: 260px;
border-radius: 50%;
background: rgba(255, 255, 255, .08);
}
.my-study-status {
display: block;
position: relative;
z-index: 1;
}
.my-study-time {
font-size: 15px;
color: rgba(255, 255, 255, .82);
}
.my-study-time strong {
margin: 0 3px;
color: #ffffff;
font-size: 26px;
font-weight: 800;
}
.my-study-stats {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0;
margin-top: 26px;
text-align: center;
}
.my-study-stat-value {
font-size: 23px;
font-weight: 800;
line-height: 1.2;
}
.my-study-stat-label {
margin-top: 7px;
color: rgba(255, 255, 255, .78);
font-size: 13px;
line-height: 1.25;
}
.my-study-main {
position: relative;
margin-top: -36px;
padding: 0 20px 28px;
z-index: 2;
}
.my-study-assistant {
display: flex;
align-items: center;
gap: 14px;
min-height: 70px;
padding: 13px 14px;
border-radius: 8px;
background: #eef6ff;
box-sizing: border-box;
box-shadow: 0 8px 18px rgba(17, 101, 166, .08);
}
.my-study-ai {
flex-shrink: 0;
font-size: 27px;
font-weight: 900;
font-style: italic;
color: #1a75ff;
letter-spacing: 0;
}
.my-study-ai span {
color: #6b46ff;
}
.my-study-assistant-text {
flex: 1;
min-width: 0;
color: #30445c;
font-size: 15px;
line-height: 1.35;
}
.my-study-assistant-btn {
flex-shrink: 0;
padding: 7px 16px;
border-radius: 18px;
color: #5d8fd8;
font-size: 14px;
font-weight: 700;
background: #ffffff;
}
.my-study-section {
margin-top: 20px;
padding: 16px 0;
border-radius: 8px;
background: #ffffff;
}
.my-study-section-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px 14px;
}
.my-study-section-title {
font-size: 20px;
font-weight: 800;
}
.my-study-all {
display: inline-flex;
align-items: center;
color: #9aa3b1;
font-size: 13px;
}
.my-study-course-scroll {
display: flex;
gap: 14px;
overflow-x: auto;
overflow-y: hidden;
padding: 0 14px;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.my-study-course-scroll::-webkit-scrollbar {
display: none;
}
.my-study-course-card {
flex: 0 0 154px;
width: 154px;
overflow: hidden;
border-radius: 5px;
background: #f7fbff;
}
.my-study-cover {
width: 154px;
height: 88px;
background: linear-gradient(135deg, #8657f2, #56c4ec);
}
.my-study-cover img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.my-study-cover-empty {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 10px;
color: #ffffff;
font-size: 14px;
font-weight: 700;
text-align: center;
box-sizing: border-box;
}
.my-study-course-name {
height: 48px;
padding: 8px 10px 0;
color: #293241;
font-size: 15px;
font-weight: 700;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.my-study-course-foot {
padding: 8px 10px 10px;
color: #b5bdc8;
font-size: 13px;
text-align: right;
}
.my-study-list {
margin-top: 18px;
}
.my-study-list-card {
display: flex;
gap: 12px;
margin-bottom: 12px;
padding: 12px;
border-radius: 8px;
background: #ffffff;
}
.my-study-list-cover {
flex: 0 0 108px;
width: 108px;
height: 66px;
border-radius: 5px;
overflow: hidden;
background: linear-gradient(135deg, #8657f2, #56c4ec);
}
.my-study-list-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.my-study-list-info {
min-width: 0;
flex: 1;
}
.my-study-list-title {
color: #202733;
font-size: 16px;
font-weight: 700;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.my-study-list-meta {
margin-top: 10px;
color: #8f99a8;
font-size: 13px;
}
</style>
<div id="app" class="my-study-page" v-cloak>
<div class="my-study-hero">
<div class="my-study-status">
<div class="my-study-time">
累计学习时长 <strong>{{ summary.studyHour || 0 }}</strong> 小时 <strong>{{ summary.studyMinute || 0 }}</strong> 分钟
</div>
</div>
<div class="my-study-stats">
<div v-for="item in statItems" :key="item.label">
<div class="my-study-stat-value">{{ item.value }}</div>
<div class="my-study-stat-label">{{ item.label }}</div>
</div>
</div>
</div>
<div class="my-study-main">
<div class="my-study-assistant">
<div class="my-study-ai"><span>AI</span>学助手</div>
<div class="my-study-assistant-text">来问问AI学习助手,获取专属学习计划</div>
<div class="my-study-assistant-btn">查看</div>
</div>
<div class="my-study-section">
<div class="my-study-section-head">
<div class="my-study-section-title">我学习的课程</div>
<div class="my-study-all" @click="scrollToList">全部 <van-icon name="arrow"></van-icon></div>
</div>
<div class="my-study-course-scroll">
<div
v-for="course in courses"
:key="course.courseId"
class="my-study-course-card"
@click="openCourse(course)">
<div class="my-study-cover">
<img v-if="getCoverUrl(course.cover)" :src="getCoverUrl(course.cover)" :alt="course.courseName">
<div v-else class="my-study-cover-empty">{{ course.courseName || '课程' }}</div>
</div>
<div class="my-study-course-name">{{ course.courseName || '未命名课程' }}</div>
<div class="my-study-course-foot">已学完{{ course.completedOutlineCount || 0 }}讲</div>
</div>
</div>
<van-empty v-if="!loading && !courses.length" description="暂无学习课程"></van-empty>
</div>
<div ref="courseList" class="my-study-list">
<div
v-for="course in courses"
:key="'list_' + course.courseId"
class="my-study-list-card"
@click="openCourse(course)">
<div class="my-study-list-cover">
<img v-if="getCoverUrl(course.cover)" :src="getCoverUrl(course.cover)" :alt="course.courseName">
<div v-else class="my-study-cover-empty">{{ course.courseName || '课程' }}</div>
</div>
<div class="my-study-list-info">
<div class="my-study-list-title">{{ course.courseName || '未命名课程' }}</div>
<div class="my-study-list-meta">学习进度 {{ course.progressPercent || 0 }}% · {{ course.studyTimeText || '0秒' }}</div>
</div>
</div>
</div>
</div>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
apiBase: "/platform/learning/study/record",
summary: {},
courses: [],
loading: false
}
},
computed: {
statItems() {
return [
{ label: "已学完课程", value: this.summary.completedCourseCount || 0 },
{ label: "学习专题", value: 0 }
]
}
},
methods: {
async loadData() {
this.loading = true
try {
const [summaryResp, courseResp] = await Promise.all([
this.$axios.post(this.apiBase + "/h5Summary"),
this.$axios.post(this.apiBase + "/h5Courses")
])
if (summaryResp.code === 0) {
this.summary = summaryResp.data || {}
}
if (courseResp.code === 0) {
this.courses = courseResp.data || []
}
} finally {
this.loading = false
}
},
getCoverUrl(cover) {
if (!cover) return ""
if (Array.isArray(cover)) {
return cover.length ? (cover[0].url || (cover[0].response && cover[0].response.data) || cover[0].data || "") : ""
}
if (typeof cover === "string") {
const text = cover.trim()
if (!text) return ""
if (text.startsWith("[")) {
try {
const files = JSON.parse(text)
return files.length ? (files[0].url || (files[0].response && files[0].response.data) || files[0].data || "") : ""
} catch (e) {
return ""
}
}
return text
}
return ""
},
openCourse(course) {
if (!course || !course.courseId) return
pjaxReplace("/platform/learning/course/h5/study?id=" + course.courseId)
},
scrollToList() {
this.$refs.courseList && this.$refs.courseList.scrollIntoView({ behavior: "smooth", block: "start" })
}
},
mounted() {
this.loadData()
}
})
</script>
<!--#
}
#-->
@@ -35,9 +35,12 @@ const apps = {
<!-- 右侧内容 -->
<div class="tree-content">
<!-- 应用列表 -->
<div v-if="loading" class="module-loading">
<van-loading size="24px">加载中...</van-loading>
</div>
<div :class="['app-grid', { 'app-grid-single': applications.length === 1 }]"
v-if="applications.length > 0">
v-else-if="isFixedCategory && applications.length > 0">
<div v-for="app in applications"
:key="app.id"
class="app-grid-item"
@@ -46,20 +49,38 @@ const apps = {
@touchstart="onTouchStart(app)"
@touchend="onTouchEnd"
@touchmove="onTouchMove">
<!-- 图标 -->
<div class="app-grid-icon">
<van-icon v-if="app.picIcon" :name="app.picIcon" size="45"></van-icon>
<van-icon v-else-if="app.icon" :name="app.icon" size="28"></van-icon>
<van-icon v-else name="apps-o" size="28"></van-icon>
</div>
<!-- 名称 -->
<div class="app-grid-name">{{ app.name }}</div>
</div>
</div>
<div v-else-if="!isFixedCategory && moduleSections.length > 0" class="module-sections">
<div v-for="section in moduleSections" :key="section.id" class="module-section">
<div class="module-title">{{ section.name }}</div>
<div :class="['module-grid', { 'module-grid-single': section.menus.length === 1 }]">
<div
v-for="menu in section.menus"
:key="menu.id"
class="module-feature"
@click="navigateToMenu(menu)"
>
<img v-if="menu.picIcon" :src="menu.picIcon" :alt="menu.name || ''" class="module-feature-img"/>
<div v-else class="module-feature-icon">
<van-icon :name="menu.icon || 'apps-o'" size="24"></van-icon>
</div>
<div class="module-feature-name">{{ menu.name }}</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<van-empty v-else description="暂无应用"></van-empty>
</div>
</div>
@@ -93,6 +114,7 @@ const apps = {
return {
// 应用列表
applications: [],
moduleSections: [],
// 固定分类列表
categories: [
{id: "all", name: "全部应用", icon: "fa fa-th-large"},
@@ -111,6 +133,7 @@ const apps = {
showMenuPopup: false,
currentApp: {},
currentMenus: [],
requestSeq: 0,
touchTimer: null,
isTouchMoved: false,
@@ -124,6 +147,9 @@ const apps = {
// 当前选中的分类ID
currentCategoryId() {
return this.allCategories[this.activeTab]?.id || "all"
},
isFixedCategory() {
return ["all", "favorites", "recommended"].includes(this.currentCategoryId)
}
},
mounted() {
@@ -145,7 +171,9 @@ const apps = {
// 加载应用数据
loadApps() {
const requestSeq = ++this.requestSeq
this.loading = true
this.moduleSections = []
// 构建请求参数
const params = {
@@ -172,12 +200,14 @@ const apps = {
// 如果是"推荐应用"分类
if (this.currentCategoryId === "recommended") {
this.$axios.post("/platform/home/listRecommendApp", { platform: "H5" }).then((result) => {
if (requestSeq !== this.requestSeq) return
if (result.code === 0) {
this.applications = result.data || []
this.finishLoadApps(requestSeq)
} else {
console.error("获取推荐应用失败:", result.msg)
this.loading = false
}
this.loading = false
})
return
}
@@ -185,28 +215,105 @@ const apps = {
// 发送请求获取普通应用列表
this.$axios.post("/platform/v4/apps/list?" + queryString)
.then((result) => {
if (requestSeq !== this.requestSeq) return
if (result.code === 0) {
this.applications = result.data || []
this.finishLoadApps(requestSeq)
} else {
console.error("获取应用列表失败:", result.msg)
this.loading = false
}
this.loading = false
})
},
// 加载收藏的应用
loadFavorites() {
this.$axios.post("/platform/v4/apps/favorite")
const requestSeq = this.requestSeq
this.$axios.post("/platform/v4/apps/favorite", {platform: 'H5'})
.then((result) => {
if (requestSeq !== this.requestSeq) return
if (result.code === 0) {
this.applications = result.data || []
this.finishLoadApps(requestSeq)
} else {
console.error("获取收藏应用失败:", result.msg)
this.loading = false
}
this.loading = false
})
},
finishLoadApps(requestSeq) {
if (requestSeq !== this.requestSeq) return
if (this.isFixedCategory) {
this.moduleSections = []
this.loading = false
return
}
this.loadModuleSections(this.applications, requestSeq)
},
// 加载每个模块下的功能菜单
loadModuleSections(apps, requestSeq) {
const list = apps || []
if (!list.length) {
this.moduleSections = []
this.loading = false
return
}
Promise.all(list.map(app => {
return this.$axios.post("/platform/sys/user/subAppMenus", {appId: app.id, platform: "H5"}).then((res) => {
const menus = res.code === 0 ? (res.data || []) : []
return {
id: app.id,
name: app.name,
app: app,
menus: this.resolveModuleMenus(app, menus)
}
}).catch(() => {
return {
id: app.id,
name: app.name,
app: app,
menus: this.resolveModuleMenus(app, [])
}
})
})).then(sections => {
if (requestSeq !== this.requestSeq) return
this.moduleSections = sections.filter(section => section.menus.length > 0)
this.loading = false
})
},
resolveModuleMenus(app, menus) {
const items = this.flattenMenus(menus || [])
if (items.length > 0) {
return items
}
if (app && app.href) {
return [{
id: app.id,
name: app.name,
href: app.href,
icon: app.icon,
picIcon: app.picIcon,
aliasName: app.aliasName
}]
}
return []
},
flattenMenus(menus) {
const result = []
;(menus || []).forEach(menu => {
if (menu.children && menu.children.length > 0) {
result.push.apply(result, this.flattenMenus(menu.children))
} else if (menu.href) {
result.push(menu)
}
})
return result
},
// 切换应用收藏状态
toggleFavorite(appId) {
const app = this.applications.find((a) => a.id === appId)
@@ -254,6 +361,7 @@ const apps = {
// 搜索
onSearch() {
this.applications = []
this.moduleSections = []
this.loadApps()
},
@@ -267,12 +375,13 @@ const apps = {
onClickNav(index) {
this.activeTab = index
this.applications = []
this.moduleSections = []
this.loadApps()
},
// 打开应用
openApp(app) {
this.$axios.post("/platform/sys/user/subAppMenus", {appId: app.id}).then((res) => {
this.$axios.post("/platform/sys/user/subAppMenus", {appId: app.id, platform: "H5"}).then((res) => {
if (res.code === 0) {
this.showAppMenus(app, res.data)
}
@@ -321,12 +430,15 @@ const apps = {
.apps-container {
display: flex;
flex-direction: column;
height: 100%;
height: calc(100vh - 50px);
overflow: hidden;
}
/* 自定义树形选择组件样式 */
.custom-tree-select {
display: flex;
min-height: 0;
flex: 1;
height: calc(100vh - 150px);
background: #fff;
}
@@ -403,10 +515,109 @@ const apps = {
.tree-content {
flex: 1;
min-width: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
background: #fff;
}
.module-loading {
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
color: #8a94a6;
}
.module-sections {
padding: 12px 12px 18px;
background: #fff;
}
.module-section {
padding: 0 0 18px;
}
.module-section + .module-section {
border-top: 1px solid #f1f3f6;
padding-top: 14px;
}
.module-title {
position: relative;
padding-left: 10px;
margin-bottom: 10px;
color: #202733;
font-size: 15px;
font-weight: 700;
line-height: 1.4;
}
.module-title::before {
content: '';
position: absolute;
left: 0;
top: 4px;
width: 3px;
height: 14px;
border-radius: 2px;
background: #1989fa;
}
.module-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px 8px;
}
.module-grid-single {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.module-feature {
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 2px;
border-radius: 8px;
background: #fff;
}
.module-feature:active {
background: #f2f7ff;
}
.module-feature-img {
width: 42px;
height: 42px;
object-fit: contain;
margin-bottom: 6px;
}
.module-feature-icon {
width: 42px;
height: 42px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 6px;
color: #ffffff;
background: linear-gradient(135deg, #2f8af5 0%, #49c3dc 100%);
}
.module-feature-name {
width: 100%;
color: #323233;
font-size: 13px;
line-height: 1.25;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-grid {
display: grid;
/* 改为自适应列数:每格最小 80px,自动换行 */
@@ -414,7 +625,7 @@ const apps = {
justify-items: center; /* 子项内容居中(图标和文字) */
justify-content: start; /* 整体网格靠左对齐,避免居中 */
gap: 12px;
padding: 12px;
background: #fff;
@@ -590,6 +801,6 @@ const apps = {
font-size: 16px;
color: #c8c9cc;
}
`
}
@@ -0,0 +1,303 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<div id="app" v-cloak>
<div class="featured-page">
<van-nav-bar
title="精彩活动"
left-text="返回"
left-arrow
fixed
placeholder
@click-left="goBack"
></van-nav-bar>
<div class="featured-banner">
<img :src="topImage" alt="">
<div class="featured-banner__shade"></div>
<div class="featured-banner__text">
<div class="featured-banner__title">精彩活动</div>
<div class="featured-banner__sub">发现更多校园工会活动</div>
</div>
</div>
<div class="featured-list">
<template v-if="activityOptions && activityOptions.length > 0">
<div v-for="item in activityOptions" :key="item.id" class="act-item" @click="enterAct(item)">
<van-tag class="act-item-wrapper-tag" v-if="$moment().unix() > $moment(item.endDate).unix()">
已结束
</van-tag>
<van-tag class="act-item-wrapper-tag" type="success"
v-else-if="$moment().unix() > $moment(item.startDate).unix() && $moment().unix() < $moment(item.endDate).unix()">
进行中
</van-tag>
<van-tag class="act-item-wrapper-tag" type="success" v-else-if="$moment().unix() < $moment(item.startDate).unix()">
即将开始
</van-tag>
<div class="act-item-cover">
<van-image v-if="item.cover" width="120" height="84" :src="item.cover"></van-image>
</div>
<div class="act-item-wrapper">
<div class="content-title">{{ item.name }}</div>
<div class="content-text">
<div>开始时间:{{ $moment(item.startDate).format('MM-DD HH:mm') }}</div>
<div class="mt5">结束时间:{{ $moment(item.endDate).format('MM-DD HH:mm') }}</div>
</div>
</div>
</div>
</template>
<van-empty v-if="!activityOptions || activityOptions.length === 0" description="暂无精彩活动"></van-empty>
</div>
</div>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data: function () {
return {
topImage: "/assets/mobile/img/home/home1.png",
activityOptions: []
}
},
methods: {
goBack: function () {
this.returnHome()
},
returnHome: function () {
if (window.store && typeof window.store.commit === "function") {
window.store.commit("setActiveTarBar", "home")
}
if (!window.$ || !$.support || !$.support.pjax) {
window.location.replace("/platform/h5/home")
return
}
var fallbackTimer = window.setTimeout(function () {
window.location.replace("/platform/h5/home")
}, 1200)
$(document).one("pjax:complete", function () {
window.clearTimeout(fallbackTimer)
})
$(document).one("pjax:error pjax:timeout", function () {
window.clearTimeout(fallbackTimer)
window.location.replace("/platform/h5/home")
})
$.pjax({
url: "/platform/h5/home",
container: "#container",
fragment: "#container",
push: false,
replace: true,
timeout: 8000
})
},
loadTopImage: function () {
var self = this
this.$axios.post("/open/common/getConfigKey", { key: "AppFeaturedActivityImg" }).then(function (res) {
if (res.code === 0 && res.data) {
self.topImage = String(res.data).split(",")[0]
}
})
},
listActivity: function () {
var self = this
this.$axios.post("/platform/home/listHomeActivity").then(function (resp) {
if (resp.code === 0) {
self.activityOptions = (resp.data || []).filter(function (item) {
return !self.isWelfareItem(item)
})
}
})
},
isWelfareItem: function (item) {
var url = ((item && item.url) || "") + "," + ((item && item.h5Url) || "")
return url.indexOf("/welfare/") > -1
},
enterAct: function (item) {
if (this.$moment().unix() < this.$moment(item.startDate).unix()) {
this.$toast("活动即将开始")
return
}
this.$pjaxReplace(item.h5Url)
}
},
created: function () {
this.loadTopImage()
this.listActivity()
},
style: /*language=CSS*/ `
body {
background-color: #f4f7fb;
}
.featured-page {
min-height: 100vh;
background-color: #f4f7fb;
padding-bottom: 18px;
box-sizing: border-box;
}
.featured-page /deep/ .van-nav-bar {
background-color: #fff;
}
.featured-page /deep/ .van-nav-bar__title {
color: #1f2937;
font-size: 17px;
font-weight: 600;
}
.featured-page /deep/ .van-nav-bar .van-icon,
.featured-page /deep/ .van-nav-bar__text {
color: #1989fa;
}
.featured-banner {
position: relative;
height: 140px;
margin: 12px 12px 10px;
border-radius: 12px;
overflow: hidden;
background-color: #e9eef6;
box-shadow: 0 8px 22px rgba(31, 41, 55, .1);
}
.featured-banner img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.featured-banner__shade {
position: absolute;
inset: 0;
background: linear-gradient(90deg, rgba(0, 0, 0, .42), rgba(0, 0, 0, .08));
}
.featured-banner__text {
position: absolute;
left: 18px;
bottom: 18px;
color: #fff;
text-shadow: 0 2px 10px rgba(0, 0, 0, .26);
}
.featured-banner__title {
font-size: 24px;
line-height: 30px;
font-weight: 700;
}
.featured-banner__sub {
margin-top: 5px;
font-size: 13px;
line-height: 18px;
opacity: .92;
}
.featured-list {
margin: 0 10px;
background-color: #fff;
border-radius: 10px;
padding: 8px 0;
overflow: hidden;
}
.act-item {
position: relative;
display: flex;
padding: 10px 12px;
background-color: #fff;
box-sizing: border-box;
}
.act-item + .act-item {
border-top: 1px solid #f0f2f5;
}
.featured-list /deep/ .act-item-wrapper-tag {
position: absolute !important;
top: 10px !important;
left: 12px !important;
z-index: 2;
width: auto !important;
min-width: 42px;
height: 20px !important;
flex: none !important;
padding: 0 6px !important;
margin: 0 !important;
border: 0 !important;
border-radius: 2px !important;
display: inline-flex !important;
align-items: center;
justify-content: center;
color: #fff !important;
background-color: #1f73b7 !important;
font-size: 12px;
line-height: 20px;
white-space: nowrap !important;
text-align: center;
}
.featured-list /deep/ .act-item-wrapper-tag.van-tag--success {
background-color: #1f73b7 !important;
}
.act-item-cover {
width: 120px;
height: 84px;
flex: 0 0 120px;
overflow: hidden;
border-radius: 4px;
background-color: #f2f3f5;
}
.act-item-cover /deep/ .van-image,
.act-item-cover /deep/ img {
display: block;
width: 120px;
height: 84px;
object-fit: cover;
}
.act-item-wrapper {
min-width: 0;
flex: 1;
padding-left: 12px;
display: flex;
flex-direction: column;
justify-content: center;
}
.content-title {
color: #111827;
font-size: 15px;
line-height: 21px;
font-weight: 600;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.content-text {
margin-top: 9px;
color: #6b7280;
font-size: 12px;
line-height: 17px;
}
.mt5 {
margin-top: 5px;
}
`
})
</script>
<!--#
}
#-->
@@ -0,0 +1,303 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<div id="app" v-cloak>
<div class="festival-page">
<van-nav-bar
title="节日福利"
left-text="返回"
left-arrow
fixed
placeholder
@click-left="goBack"
></van-nav-bar>
<div class="festival-banner">
<img :src="topImage" alt="">
<div class="festival-banner__shade"></div>
<div class="festival-banner__text">
<div class="festival-banner__title">节日福利</div>
<div class="festival-banner__sub">查看可参与的节日福利活动</div>
</div>
</div>
<div class="festival-list">
<template v-if="activityOptions && activityOptions.length > 0">
<div v-for="item in activityOptions" :key="item.id" class="act-item" @click="enterAct(item)">
<van-tag class="act-item-wrapper-tag" v-if="$moment().unix() > $moment(item.endDate).unix()">
已结束
</van-tag>
<van-tag class="act-item-wrapper-tag" type="success"
v-else-if="$moment().unix() > $moment(item.startDate).unix() && $moment().unix() < $moment(item.endDate).unix()">
进行中
</van-tag>
<van-tag class="act-item-wrapper-tag" type="success" v-else-if="$moment().unix() < $moment(item.startDate).unix()">
即将开始
</van-tag>
<div class="act-item-cover">
<van-image v-if="item.cover" width="120" height="84" :src="item.cover"></van-image>
</div>
<div class="act-item-wrapper">
<div class="content-title">{{ item.name }}</div>
<div class="content-text">
<div>开始时间:{{ $moment(item.startDate).format('MM-DD HH:mm') }}</div>
<div class="mt5">结束时间:{{ $moment(item.endDate).format('MM-DD HH:mm') }}</div>
</div>
</div>
</div>
</template>
<van-empty v-if="!activityOptions || activityOptions.length === 0" description="暂无节日福利"></van-empty>
</div>
</div>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data: function () {
return {
topImage: "/assets/mobile/img/home/home2.png",
activityOptions: []
}
},
methods: {
goBack: function () {
this.returnHome()
},
returnHome: function () {
if (window.store && typeof window.store.commit === "function") {
window.store.commit("setActiveTarBar", "home")
}
if (!window.$ || !$.support || !$.support.pjax) {
window.location.replace("/platform/h5/home")
return
}
var fallbackTimer = window.setTimeout(function () {
window.location.replace("/platform/h5/home")
}, 1200)
$(document).one("pjax:complete", function () {
window.clearTimeout(fallbackTimer)
})
$(document).one("pjax:error pjax:timeout", function () {
window.clearTimeout(fallbackTimer)
window.location.replace("/platform/h5/home")
})
$.pjax({
url: "/platform/h5/home",
container: "#container",
fragment: "#container",
push: false,
replace: true,
timeout: 8000
})
},
loadTopImage: function () {
var self = this
this.$axios.post("/open/common/getConfigKey", { key: "AppFestivalBenefitImg" }).then(function (res) {
if (res.code === 0 && res.data) {
self.topImage = String(res.data).split(",")[0]
}
})
},
listActivity: function () {
var self = this
this.$axios.post("/platform/home/listHomeActivity").then(function (resp) {
if (resp.code === 0) {
self.activityOptions = (resp.data || []).filter(function (item) {
return self.isWelfareItem(item)
})
}
})
},
isWelfareItem: function (item) {
var url = ((item && item.url) || "") + "," + ((item && item.h5Url) || "")
return url.indexOf("/welfare/") > -1
},
enterAct: function (item) {
if (this.$moment().unix() < this.$moment(item.startDate).unix()) {
this.$toast("活动即将开始")
return
}
this.$pjaxReplace(item.h5Url)
}
},
created: function () {
this.loadTopImage()
this.listActivity()
},
style: /*language=CSS*/ `
body {
background-color: #f4f7fb;
}
.festival-page {
min-height: 100vh;
background-color: #f4f7fb;
padding-bottom: 18px;
box-sizing: border-box;
}
.festival-page /deep/ .van-nav-bar {
background-color: #fff;
}
.festival-page /deep/ .van-nav-bar__title {
color: #1f2937;
font-size: 17px;
font-weight: 600;
}
.festival-page /deep/ .van-nav-bar .van-icon,
.festival-page /deep/ .van-nav-bar__text {
color: #1989fa;
}
.festival-banner {
position: relative;
height: 140px;
margin: 12px 12px 10px;
border-radius: 12px;
overflow: hidden;
background-color: #e9eef6;
box-shadow: 0 8px 22px rgba(31, 41, 55, .1);
}
.festival-banner img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.festival-banner__shade {
position: absolute;
inset: 0;
background: linear-gradient(90deg, rgba(0, 0, 0, .42), rgba(0, 0, 0, .08));
}
.festival-banner__text {
position: absolute;
left: 18px;
bottom: 18px;
color: #fff;
text-shadow: 0 2px 10px rgba(0, 0, 0, .26);
}
.festival-banner__title {
font-size: 24px;
line-height: 30px;
font-weight: 700;
}
.festival-banner__sub {
margin-top: 5px;
font-size: 13px;
line-height: 18px;
opacity: .92;
}
.festival-list {
margin: 0 10px;
background-color: #fff;
border-radius: 10px;
padding: 8px 0;
overflow: hidden;
}
.act-item {
position: relative;
display: flex;
padding: 10px 12px;
background-color: #fff;
box-sizing: border-box;
}
.act-item + .act-item {
border-top: 1px solid #f0f2f5;
}
.festival-list /deep/ .act-item-wrapper-tag {
position: absolute !important;
top: 10px !important;
left: 12px !important;
z-index: 2;
width: auto !important;
min-width: 42px;
height: 20px !important;
flex: none !important;
padding: 0 6px !important;
margin: 0 !important;
border: 0 !important;
border-radius: 2px !important;
display: inline-flex !important;
align-items: center;
justify-content: center;
color: #fff !important;
background-color: #1f73b7 !important;
font-size: 12px;
line-height: 20px;
white-space: nowrap !important;
text-align: center;
}
.festival-list /deep/ .act-item-wrapper-tag.van-tag--success {
background-color: #1f73b7 !important;
}
.act-item-cover {
width: 120px;
height: 84px;
flex: 0 0 120px;
overflow: hidden;
border-radius: 4px;
background-color: #f2f3f5;
}
.act-item-cover /deep/ .van-image,
.act-item-cover /deep/ img {
display: block;
width: 120px;
height: 84px;
object-fit: cover;
}
.act-item-wrapper {
min-width: 0;
flex: 1;
padding-left: 12px;
display: flex;
flex-direction: column;
justify-content: center;
}
.content-title {
color: #111827;
font-size: 15px;
line-height: 21px;
font-weight: 600;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.content-text {
margin-top: 9px;
color: #6b7280;
font-size: 12px;
line-height: 17px;
}
.mt5 {
margin-top: 5px;
}
`
})
</script>
<!--#
}
#-->
@@ -1,37 +1,88 @@
const home = {
template: /*language=HTML*/ `
<div>
<van-nav-bar title="首页" class="nav-bar" placeholder fixed></van-nav-bar>
<van-swipe :autoplay="100000" :height="200" class="home__swipe">
<van-swipe-item>
<img src="/assets/mobile/img/home/home1.png" alt="">
</van-swipe-item>
<van-swipe-item>
<img src="/assets/mobile/img/home/home2.png" alt="">
</van-swipe-item>
<van-swipe-item>
<img src="/assets/mobile/img/home/home3.png" alt="">
</van-swipe-item>
</van-swipe>
<!--快捷入口-->
<div class="quick-grid">
<div class="section-title">推荐服务</div>
<van-grid :column-num="4" icon-size="46" square clickable :border="false">
<van-grid-item v-for="item in quickEntries"
@click="menuClick(item)"
:key="item.id"
:text="item.name">
<img :src="item.picIcon"
slot="icon"
style="width: 40px; height: 40px"/>
</van-grid-item>
</van-grid>
<div class="home-hero">
<img class="home-hero__bg" :src="currentBanner.src" alt="">
<div class="home-hero__mask"></div>
<div class="home-hero__logo">
<img v-if="appLogo" class="home-hero__logo-img" :src="appLogo" alt="">
<div v-else class="home-hero__logo-mark"></div>
<div class="home-hero__logo-title" :class="{ 'is-placeholder': !appName }">
<span v-if="appName">{{ appName }}</span>
<template v-else>
<i></i>
<i></i>
</template>
</div>
</div>
<van-swipe
:autoplay="4000"
:height="193"
class="home__swipe"
indicator-color="#e13b4c"
@change="bannerChange">
<van-swipe-item v-for="banner in bannerList" :key="banner.src">
<div class="home__swipe-card">
<img :src="banner.src" alt="">
</div>
</van-swipe-item>
</van-swipe>
</div>
<!--活动列表-->
<div class="act-list">
<div class="section-title">最新活动</div>
<!--嵎鍏ュ彛-->
<div class="quick-grid">
<div class="section-title">推荐服务</div>
<div class="quick-scroll">
<div class="quick-scroll-grid">
<div class="quick-entry-item"
v-for="item in quickEntries"
@click="menuClick(item)"
:key="item.id">
<div class="quick-entry-icon">
<img v-if="item.picIcon" :src="item.picIcon" alt="">
<van-icon v-else name="apps-o" size="28"></van-icon>
</div>
<div class="quick-entry-name">{{ item.name }}</div>
</div>
</div>
</div>
</div>
<!--娲诲姩鍒楄-->
<div class="feature-panel">
<div
class="feature-card"
:class="[item.className, { 'feature-card--large': item.large }]"
v-for="item in featuredEntries"
:key="item.name"
@click="featureClick(item)">
<div class="feature-card__text">
<div class="feature-card__title">{{ item.name }}</div>
<div class="feature-card__desc">{{ item.desc }}</div>
</div>
<van-icon class="feature-card__icon" :name="item.icon"></van-icon>
</div>
</div>
<div class="classroom-panel" @click="enterClassroom">
<div class="classroom-panel__title">我的课堂</div>
<div class="classroom-panel__course">{{ currentClassroomCourseName }}</div>
<div class="classroom-panel__action">
<span>个人学习</span>
<van-icon name="arrow"></van-icon>
</div>
</div>
<div class="policy-panel">
<div class="policy-panel__title">政策文件</div>
<div class="policy-panel__action">
<span>更多</span>
<van-icon name="arrow"></van-icon>
</div>
</div>
<div class="act-list" v-if="showLatestActivity">
<div class="section-title section-title--hidden">最新活动</div>
<template v-if="activityOptions && activityOptions.length > 0">
<div v-for="item in activityOptions" :key="item.id" class="act-item" @click="enterAct(item)">
<van-tag class="act-item-wrapper-tag" v-if="$moment().unix() > $moment(item.endDate).unix()">
@@ -68,57 +119,759 @@ const home = {
data() {
return {
activityOptions: [],
quickEntries: []
quickEntries: [],
classroomCourses: [],
classroomCourseIndex: 0,
classroomCourseTimer: null,
showLatestActivity: false,
featuredEntries: [
{
name: "精彩活动",
desc: "更多精彩活动",
icon: "fire-o",
className: "feature-card--activity",
large: true,
href: "/platform/h5/featuredActivity"
},
{
name: "节日福利",
desc: "领取节日福利",
icon: "gift-o",
className: "feature-card--benefit",
href: "/platform/h5/festivalBenefit"
},
{
name: "劳模先进",
desc: "先进风采展示",
icon: "medal-o",
className: "feature-card--model",
href: "/platform/honor/manage/h5"
},
{
name: "普惠信息",
desc: "普惠服务信息",
icon: "bullhorn-o",
className: "feature-card--inclusive",
href: ""
}
],
appLogo: "",
appName: "",
activeBannerIndex: 0,
bannerLoadSeq: 0,
bannerImageCache: {},
defaultBannerList: [
{ src: "/assets/mobile/img/home/home1.png" },
{ src: "/assets/mobile/img/home/home2.png" },
{ src: "/assets/mobile/img/home/home3.png" }
],
bannerList: [
{ src: "/assets/mobile/img/home/home1.png" },
{ src: "/assets/mobile/img/home/home2.png" },
{ src: "/assets/mobile/img/home/home3.png" }
],
homeCachePrefix: "zhgh:h5:home:",
homeCacheTtl: {
banner: 30 * 60 * 1000,
brand: 30 * 60 * 1000,
recommend: 10 * 60 * 1000,
classroom: 5 * 60 * 1000,
activity: 2 * 60 * 1000
}
}
},
computed: {
currentBanner() {
return this.bannerList[this.activeBannerIndex] || this.bannerList[0] || {}
},
currentClassroomCourseName() {
const course = this.classroomCourses[this.classroomCourseIndex]
return course && course.courseName ? course.courseName : ""
}
},
methods: {
getCacheKey(key) {
const userId = this.$store && this.$store.state.user ? (this.$store.state.user.id || "anonymous") : "anonymous"
return this.homeCachePrefix + userId + ":" + key
},
readHomeCache(key) {
try {
const raw = window.localStorage.getItem(this.getCacheKey(key))
if (!raw) {
return null
}
const cache = JSON.parse(raw)
if (!cache || !cache.expireAt || cache.expireAt < Date.now()) {
window.localStorage.removeItem(this.getCacheKey(key))
return null
}
return cache.value
} catch (e) {
return null
}
},
writeHomeCache(key, value, ttl) {
try {
window.localStorage.setItem(this.getCacheKey(key), JSON.stringify({
expireAt: Date.now() + ttl,
value: value
}))
} catch (e) {
// Ignore cache quota and private mode errors.
}
},
parseBannerList(value) {
if (!value || typeof value !== "string") {
return []
}
return value.split(",")
.map(item => item.trim())
.filter(item => item)
.map(src => ({ src }))
},
listHomeBanner() {
const cached = this.readHomeCache("h5Banner")
if (cached && cached.length) {
this.bannerList = cached
this.activeBannerIndex = 0
}
this.$axios.post("/open/common/getConfigKey", { key: "H5AppHomeImg" }).then((res) => {
if (res.code === 0) {
const configBannerList = this.parseBannerList(res.data)
const nextBannerList = configBannerList.length > 0 ? configBannerList : this.defaultBannerList
this.bannerList = nextBannerList
this.activeBannerIndex = 0
this.writeHomeCache("h5Banner", nextBannerList, this.homeCacheTtl.banner)
this.cacheBannerImages(nextBannerList)
}
}).catch(() => {
if (!cached) {
this.bannerList = this.defaultBannerList
}
})
},
shouldInlineBanner(src) {
return typeof src === "string" && src.indexOf("/platform/sys/file/download") !== -1
},
cacheBannerImages(list) {
const seq = ++this.bannerLoadSeq
Promise.all(list.map((banner) => {
return this.inlineBannerImage(banner.src).then((src) => Object.assign({}, banner, { src }))
})).then((cachedList) => {
if (seq === this.bannerLoadSeq) {
this.bannerList = cachedList
this.activeBannerIndex = 0
}
})
},
inlineBannerImage(src) {
if (!this.shouldInlineBanner(src)) {
return Promise.resolve(src)
}
if (this.bannerImageCache[src]) {
return Promise.resolve(this.bannerImageCache[src])
}
return fetch(src, {
credentials: "same-origin",
cache: "force-cache"
}).then((resp) => {
if (!resp.ok) {
throw new Error("load image failed")
}
return resp.blob()
}).then((blob) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.onerror = reject
reader.readAsDataURL(blob)
})
}).then((dataUrl) => {
this.bannerImageCache[src] = dataUrl
return dataUrl
}).catch(() => src)
},
getConfigValue(key) {
return this.$axios.post("/open/common/getConfigKey", { key }).then((res) => {
return res.code === 0 ? (res.data || "") : ""
}).catch(() => "")
},
listHomeBrand() {
const cached = this.readHomeCache("brand")
if (cached) {
this.appLogo = cached.appLogo || ""
this.appName = cached.appName || ""
}
Promise.all([
this.getConfigValue("AppLogo"),
this.getConfigValue("AppName")
]).then(([appLogo, appName]) => {
this.appLogo = appLogo
this.appName = appName
this.writeHomeCache("brand", { appLogo, appName }, this.homeCacheTtl.brand)
})
},
listRecommendApp() {
const cached = this.readHomeCache("recommend")
if (cached && cached.length) {
this.quickEntries = cached
}
this.$axios.post("/platform/home/listRecommendService", { platform: "H5" }).then((res) => {
if (res.code === 0) {
this.quickEntries = res.data
this.writeHomeCache("recommend", res.data || [], this.homeCacheTtl.recommend)
}
})
},
listActivity() {
const cached = this.readHomeCache("activity")
if (cached && cached.length) {
this.activityOptions = cached
}
this.$axios.post("/platform/home/listHomeActivity").then((resp) => {
if (resp.code === 0) {
this.activityOptions = resp.data
this.writeHomeCache("activity", resp.data || [], this.homeCacheTtl.activity)
}
})
},
listClassroomCourses() {
const cached = this.readHomeCache("classroom")
if (cached && cached.length) {
this.classroomCourses = cached
this.classroomCourseIndex = 0
this.startClassroomCourseTimer()
}
this.$axios.post("/platform/learning/course/display/pageData", {
pageNumber: 1,
pageSize: 20
}).then((res) => {
if (res.code === 0) {
const data = res.data || {}
this.classroomCourses = data.list || []
this.classroomCourseIndex = 0
this.writeHomeCache("classroom", this.classroomCourses, this.homeCacheTtl.classroom)
this.startClassroomCourseTimer()
}
}).catch(() => {
if (!cached) {
this.classroomCourses = []
this.classroomCourseIndex = 0
}
})
},
startClassroomCourseTimer() {
if (this.classroomCourseTimer) {
clearInterval(this.classroomCourseTimer)
this.classroomCourseTimer = null
}
if (this.classroomCourses.length <= 1) {
return
}
this.classroomCourseTimer = setInterval(() => {
this.classroomCourseIndex = (this.classroomCourseIndex + 1) % this.classroomCourses.length
}, 3000)
},
menuClick(item) {
this.$pjaxReplace(item.href)
},
//点击进入活动
featureClick(item) {
if (item.href) {
this.$pjaxReplace(item.href)
}
},
enterClassroom() {
this.$pjaxReplace("/platform/learning/course/h5")
},
bannerChange(index) {
this.activeBannerIndex = index
},
//鐐瑰嚮杩涘叆娲诲姩
enterAct(item) {
if(this.$moment().unix() < this.$moment(item.startDate).unix()) {
this.$toast("活动即将开始")
return
}
this.$pjaxReplace(item.h5Url)
}
},
created() {
this.listHomeBanner()
this.listHomeBrand()
this.listRecommendApp()
this.listActivity()
this.listClassroomCourses()
if (this.showLatestActivity) {
this.listActivity()
}
},
beforeDestroy() {
if (this.classroomCourseTimer) {
clearInterval(this.classroomCourseTimer)
this.classroomCourseTimer = null
}
},
style: /*language=CSS*/ `
.home__swipe .van-swipe-item img {
.home-hero {
position: relative;
min-height: 281px;
overflow: hidden;
padding: 26px 16px 0;
background-color: #fff;
box-sizing: border-box;
}
.home-hero::after {
content: "";
position: absolute;
left: 50%;
top: 187px;
z-index: 1;
width: 180%;
height: 96px;
transform: translateX(-50%);
background-color: #fff;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
}
.home-hero__bg {
position: absolute;
left: -24px;
top: -24px;
width: calc(100% + 48px);
height: 239px;
object-fit: cover;
filter: blur(20px);
transform: scale(1.12);
opacity: .88;
transition: opacity .25s ease;
}
.home-hero__mask {
position: absolute;
left: 0;
top: 0;
right: 0;
height: 239px;
background: rgba(0, 0, 0, .06);
}
.home-hero__logo {
position: relative;
z-index: 2;
display: flex;
align-items: center;
height: 36px;
margin-bottom: 12px;
min-width: 0;
}
.home-hero__logo-mark {
width: 34px;
height: 34px;
border: 1px solid rgba(255, 255, 255, .72);
border-radius: 50%;
box-sizing: border-box;
background: rgba(255, 255, 255, .2);
backdrop-filter: blur(2px);
flex-shrink: 0;
}
.home-hero__logo-img {
display: block;
max-width: 210px;
max-height: 36px;
object-fit: contain;
flex-shrink: 0;
}
.home-hero__logo-title {
min-width: 0;
margin-left: 10px;
color: #fff;
font-size: 17px;
font-weight: 600;
line-height: 22px;
text-shadow: 0 1px 8px rgba(0, 0, 0, .24);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.home-hero__logo-title.is-placeholder {
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
width: 154px;
margin-left: 10px;
}
.home-hero__logo-title i {
display: block;
height: 10px;
border-radius: 999px;
background: rgba(255, 255, 255, .72);
}
.home-hero__logo-title i + i {
width: 66%;
height: 7px;
opacity: .75;
}
.home__swipe {
position: relative;
z-index: 2;
overflow: visible;
border-radius: 12px;
}
.home__swipe-card {
height: 100%;
overflow: hidden;
border-radius: 12px;
background-color: rgba(255, 255, 255, .26);
box-shadow: 0 10px 28px rgba(0, 0, 0, .1);
}
.home__swipe-card img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: inherit;
}
.home__swipe /deep/ .van-swipe__track,
.home__swipe /deep/ .van-swipe-item {
border-radius: 12px;
overflow: hidden;
}
.home__swipe /deep/ .van-swipe__indicators {
bottom: -14px;
}
.home__swipe /deep/ .van-swipe__indicator {
width: 7px;
height: 7px;
background-color: rgba(225, 59, 76, .28);
opacity: 1;
}
.home__swipe /deep/ .van-swipe__indicator--active {
background-color: #e13b4c;
}
.quick-grid {
background-color: #fff;
margin: 10px;
margin: 10px 10px 10px;
border-radius: 10px;
padding: 10px;
position: relative;
z-index: 2;
}
.quick-grid .van-grid-item .van-grid-item__text {
.quick-scroll {
width: 100%;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
}
.quick-scroll::-webkit-scrollbar {
display: none;
}
.quick-scroll-grid {
display: grid;
grid-auto-flow: column;
grid-template-rows: repeat(2, 82px);
grid-auto-columns: 25%;
min-width: 100%;
}
.quick-entry-item {
min-width: 0;
height: 82px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4px 2px;
box-sizing: border-box;
}
.quick-entry-item:active {
background-color: #f2f7ff;
border-radius: 8px;
}
.quick-entry-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 7px;
color: #1989fa;
flex-shrink: 0;
}
.quick-entry-icon img {
width: 40px;
height: 40px;
object-fit: contain;
}
.quick-entry-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 85%;
width: 90%;
text-align: center;
color: #323233;
font-size: 13px;
line-height: 18px;
}
.feature-panel {
margin: 10px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-auto-rows: 82px;
gap: 10px;
}
.feature-card {
position: relative;
overflow: hidden;
border-radius: 10px;
padding: 14px 12px;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: flex-start;
min-width: 0;
box-shadow: 0 6px 18px rgba(42, 65, 92, .08);
}
.feature-card:active {
transform: scale(.985);
}
.feature-card--large {
grid-column: span 2;
grid-row: span 2;
}
.feature-card__text {
position: relative;
z-index: 1;
min-width: 0;
}
.feature-card__title {
font-size: 17px;
line-height: 22px;
font-weight: 700;
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.feature-card__desc {
color: rgba(55, 65, 81, .64);
font-size: 12px;
line-height: 17px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.feature-card__icon {
position: absolute;
right: 14px;
bottom: 12px;
font-size: 36px;
opacity: .78;
}
.feature-card--large .feature-card__icon {
right: 18px;
bottom: 18px;
font-size: 56px;
}
.feature-card--benefit,
.feature-card--model {
align-items: center;
justify-content: center;
padding: 10px 8px;
text-align: center;
}
.feature-card--benefit .feature-card__desc,
.feature-card--model .feature-card__desc {
display: none;
}
.feature-card--benefit .feature-card__title,
.feature-card--model .feature-card__title {
font-size: 15px;
line-height: 20px;
margin-bottom: 0;
overflow: visible;
text-overflow: clip;
}
.feature-card--benefit .feature-card__icon,
.feature-card--model .feature-card__icon {
position: static;
display: block;
margin: 8px auto 0;
font-size: 32px;
}
.feature-card--inclusive .feature-card__icon {
font-size: 44px;
}
.feature-card--activity {
background: linear-gradient(145deg, #fff2e5 0%, #ffe2c8 100%);
color: #ff7a25;
}
.feature-card--benefit {
background: linear-gradient(145deg, #fff1f4 0%, #ffd5dc 100%);
color: #f35f74;
}
.feature-card--model {
background: linear-gradient(145deg, #eef5ff 0%, #d9e8ff 100%);
color: #5b83d7;
}
.feature-card--inclusive {
grid-column: span 2;
background: linear-gradient(145deg, #f2eeff 0%, #ded4ff 100%);
color: #8063dc;
}
.classroom-panel {
height: 66px;
margin: 10px;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 10px;
background-color: #fff;
box-shadow: 0 8px 22px rgba(42, 65, 92, .07);
box-sizing: border-box;
}
.classroom-panel:active {
transform: scale(.99);
}
.classroom-panel__title {
position: relative;
flex-shrink: 0;
color: #202124;
font-size: 15px;
line-height: 21px;
}
.classroom-panel__title::after {
content: "";
position: absolute;
left: 0;
bottom: -5px;
width: 22px;
height: 2px;
border-radius: 999px;
background-color: #e13b4c;
}
.classroom-panel__course {
min-width: 0;
flex: 1;
margin: 0 12px;
color: #1989fa;
font-size: 13px;
line-height: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}
.classroom-panel__action {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-end;
color: #969799;
font-size: 13px;
line-height: 18px;
white-space: nowrap;
}
.classroom-panel__action .van-icon {
flex-shrink: 0;
margin-left: 4px;
font-size: 14px;
}
.policy-panel {
height: 66px;
margin: 10px;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 10px;
background-color: #fff;
box-shadow: 0 8px 22px rgba(42, 65, 92, .07);
box-sizing: border-box;
}
.policy-panel__title {
position: relative;
color: #202124;
font-size: 15px;
line-height: 21px;
}
.policy-panel__title::after {
content: "";
position: absolute;
left: 0;
bottom: -5px;
width: 22px;
height: 2px;
border-radius: 999px;
background-color: #e13b4c;
}
.policy-panel__action {
display: flex;
align-items: center;
color: #969799;
font-size: 13px;
line-height: 18px;
white-space: nowrap;
}
.policy-panel__action .van-icon {
margin-left: 4px;
font-size: 14px;
}
.act-list {
@@ -186,10 +939,15 @@ const home = {
padding: 0 0 5px 5px;
font-size: 15px;
}
.section-title--hidden {
display: none;
}
/deep/ .home__swipe img {
width: 100%;
height: 100%;
border-radius: inherit;
}
/deep/ .act-item-cover img {
@@ -22,10 +22,10 @@ layout("/layouts/platform_h5.html"){
<div id="page-tarbar" class="page-tarbar">
<van-tabbar v-model="homeTabbarActive" @change="homeTabbarChange">
<van-tabbar-item name="home" icon="wap-home-o">首页</van-tabbar-item>
<van-tabbar-item name="apps" icon="apps-o">应用</van-tabbar-item>
<!-- <van-tabbar-item name="work" icon="gem">工作台</van-tabbar-item>-->
<van-tabbar-item name="todo" icon="records-o">待办</van-tabbar-item>
<van-tabbar-item name="home" icon="wap-home-o">首页</van-tabbar-item>
<van-tabbar-item name="msg" icon="envelop-o">消息</van-tabbar-item>
<van-tabbar-item name="mine" icon="user-o">我的</van-tabbar-item>
</van-tabbar>
@@ -33,7 +33,7 @@ layout("/layouts/platform_h5.html"){
</div>
</div>
<script>
<script nonce="${cspNonce!}">
<!--#include("home.js"){}#-->
<!--#include("work.js"){}#-->
<!--#include("mine.js"){}#-->
@@ -60,17 +60,23 @@ layout("/layouts/platform_h5.html"){
homeTabbarChange(val) {
this.$store.commit("setActiveTarBar", val)
},
initHomeTabbarActive() {
const activeTarBar = this.$store && this.$store.state ? this.$store.state.activeTarBar : ""
const enabledTabs = ["apps", "todo", "home", "msg", "mine"]
if (enabledTabs.indexOf(activeTarBar) > -1) {
this.homeTabbarActive = activeTarBar
return
}
this.homeTabbarActive = "home"
this.$store.commit("setActiveTarBar", "home")
},
moduleClick(val) {
console.log(val)
this.homeTabbarActive = "work"
}
},
created() {
if (!this.$store.state.activeTarBar) {
this.$store.commit("setActiveTarBar", "home")
} else {
this.homeTabbarActive = this.$store.state.activeTarBar
}
this.initHomeTabbarActive()
},
style: /*language=CSS*/ `
@@ -177,7 +177,7 @@ const todo = {
onView(task) {
const {taskId, taskKey, instanceId, businessNo, formKey, h5FormKey} = task;
if (!h5FormKey) {
this.$toast("当前流程没有配置地址");
this.$toast("当前流程暂不支持手机端审核,请前往PC端页面审核!");
return;
}
this.$pjaxReplace(h5FormKey + "?taskId=" + taskId + "bizId=" + businessNo + "taskKey=" + taskKey+ "&tab=" + this.activeTab)