Compare commits

...
33 Commits
Author SHA1 Message Date
c-chenxs1 a09c6ae2c9 Merge branch 'fix_俱乐部模块' into release_prd 2026-09-29 10:28:14 +08:00
c-chenxs1 d197c78b9c feat: 补充脚本3 2026-09-29 10:28:03 +08:00
c-chenxs1 dbca87f09c Merge branch 'release_prd' of http://gitea.dd3skj.com/c-zhangr1/zhgh_njnu into release_prd 2026-09-29 10:20:37 +08:00
c-chenxs1 38b1a09e11 Merge branch 'fix_俱乐部模块' into release_prd 2026-09-29 10:20:33 +08:00
c-chenxs1 5cecb3b185 Merge branch 'feature_学习教育平台' into release_prd 2026-09-29 10:20:29 +08:00
c-chenxs1 fc727c690a Merge branch 'feature_学习教育平台' of http://gitea.dd3skj.com/c-zhangr1/zhgh_njnu into feature_学习教育平台 2026-09-29 10:20:09 +08:00
c-chenxs1 063e22491a feat: 补充脚本2 2026-09-29 10:19:57 +08:00
c-chenxs1 48ebcc148d feat: 补充脚本 2026-09-29 10:19:27 +08:00
@jyuhsin 50f82e05e5 Merge branch 'feature_学习教育平台' into release_prd 2026-09-29 09:39:43 +08:00
@jyuhsin f700d0c6fb fix: 修复脚本path 2026-09-29 09:39:04 +08:00
c-chenxs1 94fa74af53 Merge branch 'feature_学习教育平台' into release_prd 2026-09-28 21:01:20 +08:00
c-chenxs1 d717330657 feat: 学习板块,线上课程,网课 完成 2026-09-28 20:57:03 +08:00
c-chenxs1 3c3e64f2be feat: 移动端-协会审核-退回-拒绝-通过完整流程优化 2026-09-28 14:27:25 +08:00
c-chenxs1 a18f77cd68 feat: 移动端-协会审核 2026-09-23 18:27:31 +08:00
c-zhangr1 486934f3b6 亲子活动模块整改-1.2 2026-09-10 17:43:53 +08:00
c-chenxs1 e3eb5b639b Merge branch 'main' of http://gitea.dd3skj.com/c-zhangr1/zhgh_njnu 2026-09-10 10:59:45 +08:00
c-zhangr1 f5e5720120 Merge remote-tracking branch 'origin/main' 2026-09-10 10:59:07 +08:00
c-chenxs1 36d844a1e8 feat: 信息中心获取 用户时补充不存在单位2 2026-09-10 10:59:01 +08:00
c-chenxs1 caf83bdbd9 feat: 信息中心获取 用户时补充不存在单位 2026-09-10 10:58:12 +08:00
c-zhangr1 e0ecdda400 亲子活动模块整改-1.2 2026-09-10 10:57:51 +08:00
c-chenxs1 7d5d2da60f feat: 删除properties 2026-09-09 18:40:20 +08:00
c-chenxs1 5f1ba33fd4 feat: 上传配置文件 2026-09-09 15:42:22 +08:00
c-zhangr1 ecf8ad3536 亲子活动模块整改-1.1 2026-09-08 20:30:34 +08:00
c-zhangr1 c75bcd4a92 亲子活动模块整改 2026-09-08 19:10:02 +08:00
c-zhouhf1 d198db9104 commit 2026-09-04 15:14:29 +08:00
dd3s_206 fab3a22666 职工福利-福利名单: 新增‘导出重复身份证号人员’按钮(仅超级管理员可见); 2026-06-09 18:04:11 +08:00
dd3s_206 21ae4889f0 会员管理-其他人员管理-人员管理: 新增导出按钮; 2026-06-08 17:46:17 +08:00
dd3s_206 0440edd289 职工福利-选择情况: 导出选择情况表时如果联系电话(福利选择时录入)为空,就读取系统用户表手机号码字段; 2026-06-05 09:41:47 +08:00
dd3s_206 4cb9e46fc5 职工福利:福利统计和选择情况新增‘管理员代选择’按钮;(查询和导出已同步); 2026-06-03 18:24:43 +08:00
dd3s_206 13b8af999a 亲子活动管理:新增活动且沿用之前活动时抹掉分活动主键id; 2026-06-01 14:05:16 +08:00
= 3f02802412 commit 2026-05-29 10:41:52 +08:00
= 179698d0cd commit 2026-05-28 14:08:29 +08:00
= a35966ef64 commit 2026-05-27 10:07:13 +08:00
106 changed files with 11722 additions and 649 deletions
+3
View File
@@ -26,3 +26,6 @@ hs_err_pid*
src/main/resources/application.yaml src/main/resources/application.yaml
src/main/resources/application-dev.yaml src/main/resources/application-dev.yaml
.vscode
bin
node-data-center-mock/
@@ -3,6 +3,7 @@ package com.budwk.app.base.utils;
import cn.hutool.core.io.FileUtil; import cn.hutool.core.io.FileUtil;
import com.aspose.cells.PdfSaveOptions; import com.aspose.cells.PdfSaveOptions;
import com.aspose.cells.Workbook; import com.aspose.cells.Workbook;
import com.aspose.slides.Presentation;
import com.aspose.words.Document; import com.aspose.words.Document;
import com.aspose.words.SaveFormat; import com.aspose.words.SaveFormat;
import com.budwk.app.base.exception.BaseException; import com.budwk.app.base.exception.BaseException;
@@ -30,11 +31,34 @@ public class OfficePlusUtil {
wordConvertPdf(sourcePath, targetPath); wordConvertPdf(sourcePath, targetPath);
} else if (suffix.equals("xls") || suffix.equals("xlsx")) { } else if (suffix.equals("xls") || suffix.equals("xlsx")) {
excelConvertPdf(sourcePath, targetPath); excelConvertPdf(sourcePath, targetPath);
} else if (suffix.equalsIgnoreCase("ppt") || suffix.equalsIgnoreCase("pptx")) {
presentationConvertPdf(sourcePath, targetPath);
} else { } else {
throw new BaseException("不支持的文档格式"); throw new BaseException("不支持的文档格式");
} }
} }
/**
* PPT演示文稿转PDF
*
* @param sourcePath 源文件路径
* @param targetPath 目标文件路径
*/
public static void presentationConvertPdf(String sourcePath, String targetPath) {
Presentation presentation = null;
try {
presentation = new Presentation(sourcePath);
presentation.save(targetPath, com.aspose.slides.SaveFormat.Pdf);
} catch (Exception e) {
log.info("转换pdf报错", e);
throw new BaseException("PPT转换PDF失败");
} finally {
if (presentation != null) {
presentation.dispose();
}
}
}
/** /**
* excel转pdf * excel转pdf
* *
@@ -2,6 +2,7 @@ package com.budwk.app.sys.controller;
import cn.dev33.satoken.annotation.SaCheckLogin; import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.annotation.SLog; import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.page.Pagination; import com.budwk.app.base.page.Pagination;
@@ -11,8 +12,12 @@ import com.budwk.app.sys.enums.SysFileEngineTypeEnum;
import com.budwk.app.sys.models.Sys_file; import com.budwk.app.sys.models.Sys_file;
import com.budwk.app.sys.services.SysConfigService; import com.budwk.app.sys.services.SysConfigService;
import com.budwk.app.sys.services.SysFileService; import com.budwk.app.sys.services.SysFileService;
import com.budwk.app.sys.utils.SysFileMinIoUtil;
import com.google.common.net.HttpHeaders;
import com.budwk.app.zhgh.learning.service.LearningFileAccessService;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.nutz.dao.Cnd; import org.nutz.dao.Cnd;
import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
@@ -23,12 +28,14 @@ import org.nutz.mvc.upload.UploadAdaptor;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStream;
import java.util.List; import java.util.List;
@IocBean @IocBean
@At("/platform/sys/file") @At("/platform/sys/file")
@Ok("json:full") @Ok("json:full")
@Api(tags = "文件管理") @Api(tags = "文件管理")
@Slf4j
public class SysFileController { public class SysFileController {
@Inject @Inject
@@ -37,6 +44,9 @@ public class SysFileController {
@Inject @Inject
private SysConfigService sysConfigService; private SysConfigService sysConfigService;
@Inject
private LearningFileAccessService learningFileAccessService;
@At("") @At("")
@Ok("beetl:/platform/sys/file/index.html") @Ok("beetl:/platform/sys/file/index.html")
@SaCheckPermission("sys.manager.file") @SaCheckPermission("sys.manager.file")
@@ -95,6 +105,7 @@ public class SysFileController {
@Ok("void") @Ok("void")
@SaCheckLogin @SaCheckLogin
public void download(String id, HttpServletRequest request, HttpServletResponse response) throws IOException { public void download(String id, HttpServletRequest request, HttpServletResponse response) throws IOException {
if (!learningFileAccessService.check(id, true, response)) return;
sysFileService.download(id, request, response); sysFileService.download(id, request, response);
} }
@@ -103,6 +114,7 @@ public class SysFileController {
@SaCheckLogin @SaCheckLogin
@ApiOperation("文件管理-转换PDF") @ApiOperation("文件管理-转换PDF")
public void convertPDF(String id, HttpServletRequest request, HttpServletResponse response) throws IOException { public void convertPDF(String id, HttpServletRequest request, HttpServletResponse response) throws IOException {
if (!learningFileAccessService.check(id, true, response)) return;
sysFileService.convertPDF(id, request, response); sysFileService.convertPDF(id, request, response);
} }
@@ -142,4 +154,135 @@ public class SysFileController {
} }
@At
@Ok("void")
@SaCheckLogin
public void preview(String id, HttpServletRequest request, HttpServletResponse response) throws IOException {
if (!learningFileAccessService.check(id, false, response)) return;
Sys_file file = sysFileService.detail(id);
String contentType = switch (StrUtil.blankToDefault(file.getSuffix(), "").toLowerCase()) {
case "jpg", "jpeg" -> "image/jpeg";
case "png" -> "image/png";
case "gif" -> "image/gif";
case "webp" -> "image/webp";
case "bmp" -> "image/bmp";
case "mp3" -> "audio/mpeg";
case "aac" -> "audio/aac";
case "wav" -> "audio/wav";
case "ogg" -> "audio/ogg";
case "m4a" -> "audio/mp4";
case "pdf" -> "application/pdf";
default -> null;
};
if (contentType == null) {
response.sendError(415, "该资料不支持在线预览");
return;
}
byte[] bytes = SysFileEngineTypeEnum.LOCAL.getValue().equals(file.getEngine())
? FileUtil.readBytes(file.getStoragePath())
: SysFileMinIoUtil.getFileBytes(file.getBucket(), file.getStoragePath());
response.setContentType(contentType);
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "inline");
if (contentType.startsWith("audio/")) {
response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
String range = request.getHeader(HttpHeaders.RANGE);
if (StrUtil.isNotBlank(range) && range.startsWith("bytes=") && !range.contains(",")) {
long start;
long end;
try {
String specification = range.substring(6).trim();
if (!specification.matches("[0-9]*-[0-9]*")) throw new NumberFormatException();
String[] bounds = specification.split("-", -1);
if (bounds[0].isEmpty()) {
long length = Long.parseLong(bounds[1]);
if (length <= 0) throw new NumberFormatException();
start = Math.max(0, bytes.length - length);
end = bytes.length - 1;
} else {
start = Long.parseLong(bounds[0]);
end = bounds[1].isEmpty() ? bytes.length - 1 : Math.min(Long.parseLong(bounds[1]), bytes.length - 1);
}
if (start >= bytes.length || end < start) throw new NumberFormatException();
} catch (NumberFormatException e) {
response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes */" + bytes.length);
response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
return;
}
int length = (int) (end - start + 1);
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes " + start + "-" + end + "/" + bytes.length);
response.setContentLength(length);
try (OutputStream out = response.getOutputStream()) {
out.write(bytes, (int) start, length);
}
return;
}
}
response.setContentLength(bytes.length);
try (OutputStream out = response.getOutputStream()) {
out.write(bytes);
}
}
@At
@SaCheckLogin
@ApiOperation("文件管理-视频播放")
@Ok("void")
public void videoPlay(String id, HttpServletRequest request, HttpServletResponse response) throws IOException {
if (!learningFileAccessService.check(id, false, response)) return;
Sys_file sys_file = sysFileService.detail(id);
byte[] bytes = SysFileMinIoUtil.getFileBytes(sys_file.getBucket(), sys_file.getStoragePath());
// 设置响应头
String fileName = sys_file.getName();
String contentType = null;
switch (FileUtil.extName(fileName).toLowerCase()) {
case "mp4" -> contentType = "video/mp4";
case "avi" -> contentType = "video/x-msvideo";
case "mkv" -> contentType = "video/x-matroska";
case "mov" -> contentType = "video/quicktime";
case "wmv" -> contentType = "video/x-ms-wmv";
default -> contentType = "application/octet-stream";
}
response.setContentType(contentType);
response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
String rangeHeader = request.getHeader(HttpHeaders.RANGE);
if (StrUtil.isNotBlank(rangeHeader) && rangeHeader.startsWith("bytes=")) {
// 处理Range请求
String[] ranges = rangeHeader.substring(6).split("-");
long start = StrUtil.isNotBlank(ranges[0]) ? Long.parseLong(ranges[0]) : 0;
long end = (ranges.length > 1 && StrUtil.isNotBlank(ranges[1])) ?
Long.parseLong(ranges[1]) : bytes.length - 1;
if (start >= bytes.length || end >= bytes.length) {
response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes */" + bytes.length);
response.setStatus(416);
return;
}
int contentLength = (int) (end - start + 1);
response.setStatus(206);
response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(contentLength));
response.setHeader(HttpHeaders.CONTENT_RANGE, StrUtil.format("bytes {}-{}/{}", start, end, bytes.length));
try (OutputStream out = response.getOutputStream()) {
out.write(bytes, (int) start, contentLength);
} catch (IOException e) {
log.error("视频播放失败", e);
}
} else {
// 非Range请求
response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(bytes.length));
try (OutputStream out = response.getOutputStream()) {
out.write(bytes);
} catch (IOException e) {
log.error("视频播放失败", e);
}
}
}
} }
@@ -66,7 +66,7 @@ public class SysH5IndexController {
@Ok("json") @Ok("json")
public Result listHomeActivity() { public Result listHomeActivity() {
FieldFilter fieldFilter = FieldFilter.locked(Sys_home_activity.class, "classPath"); FieldFilter fieldFilter = FieldFilter.locked(Sys_home_activity.class, "classPath");
List<Sys_home_activity> list = Daos.ext(dao,fieldFilter) .query(Sys_home_activity.class, Cnd.where("enable", "=", 1).desc("top").desc("createdAt")); List<Sys_home_activity> list = Daos.ext(dao,fieldFilter) .query(Sys_home_activity.class, Cnd.where("enable", "=", 1).desc("top").desc("createdAt").desc("updatedAt"));
String userId = SecurityUtil.getUserId(); String userId = SecurityUtil.getUserId();
List<Sys_home_activity> allowActivityList = new ArrayList<>(); List<Sys_home_activity> allowActivityList = new ArrayList<>();
// 今天的时间 // 今天的时间
@@ -166,7 +166,7 @@ public class SysHomeController {
@Ok("json") @Ok("json")
public Result listHomeActivity() { public Result listHomeActivity() {
FieldFilter fieldFilter = FieldFilter.locked(Sys_home_activity.class, "classPath"); FieldFilter fieldFilter = FieldFilter.locked(Sys_home_activity.class, "classPath");
List<Sys_home_activity> list = Daos.ext(dao, fieldFilter).query(Sys_home_activity.class, Cnd.where("enable", "=", 1).desc("top").desc("createdAt")); List<Sys_home_activity> list = Daos.ext(dao, fieldFilter).query(Sys_home_activity.class, Cnd.where("enable", "=", 1).desc("top").desc("createdAt").desc("updatedAt"));
String userId = SecurityUtil.getUserId(); String userId = SecurityUtil.getUserId();
List<Sys_home_activity> allowActivityList = new ArrayList<>(); List<Sys_home_activity> allowActivityList = new ArrayList<>();
// 今天的时间 // 今天的时间
@@ -26,7 +26,7 @@ public class Sys_unit extends BaseModel implements Serializable {
@Name @Name
@Comment("ID") @Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32) @ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true) @PrevInsert(uu32 = true, nullEffective = true)
private String id; private String id;
@Column @Column
@@ -3,6 +3,8 @@ package com.budwk.app.sys.services;
import com.budwk.app.base.service.BaseService; import com.budwk.app.base.service.BaseService;
import com.budwk.app.sys.models.Sys_unit; import com.budwk.app.sys.models.Sys_unit;
import java.util.List;
/** /**
* Created by wizzer on 2016/12/22. * Created by wizzer on 2016/12/22.
*/ */
@@ -15,6 +17,8 @@ public interface SysUnitService extends BaseService<Sys_unit> {
*/ */
void save(Sys_unit unit, String pid); void save(Sys_unit unit, String pid);
void saveBatch(List<Sys_unit> units, String pid);
/** /**
* 级联删除单位及单位下用户 * 级联删除单位及单位下用户
* *
@@ -1,7 +1,6 @@
package com.budwk.app.sys.services.impl; package com.budwk.app.sys.services.impl;
import cn.hutool.core.codec.Base64; import cn.hutool.core.codec.Base64;
import cn.hutool.core.collection.ListUtil;
import cn.hutool.core.util.CharsetUtil; import cn.hutool.core.util.CharsetUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil; import cn.hutool.crypto.digest.DigestUtil;
@@ -14,18 +13,20 @@ import com.budwk.app.base.exception.BaseException;
import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.sys.annotation.DataCenterColumn; import com.budwk.app.sys.annotation.DataCenterColumn;
import com.budwk.app.sys.models.Sys_dict; import com.budwk.app.sys.models.Sys_dict;
import com.budwk.app.sys.models.Sys_unit;
import com.budwk.app.sys.models.Sys_user; import com.budwk.app.sys.models.Sys_user;
import com.budwk.app.sys.models.Sys_user_source; import com.budwk.app.sys.models.Sys_user_source;
import com.budwk.app.sys.services.SysDataUserPullService; import com.budwk.app.sys.services.SysDataUserPullService;
import com.budwk.app.sys.services.SysDictService; import com.budwk.app.sys.services.SysDictService;
import com.budwk.app.sys.services.SysUnitService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.nutz.aop.interceptor.async.Async; import org.nutz.aop.interceptor.async.Async;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao; import org.nutz.dao.Dao;
import org.nutz.dao.Sqls; import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql; import org.nutz.dao.sql.Sql;
import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json;
import org.nutz.lang.util.NutMap; import org.nutz.lang.util.NutMap;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@@ -40,6 +41,8 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
@Inject @Inject
private SysDictService sysDictService; private SysDictService sysDictService;
@Inject @Inject
private SysUnitService sysUnitService;
@Inject
private ThreadPoolTaskExecutor threadPoolTaskExecutor; private ThreadPoolTaskExecutor threadPoolTaskExecutor;
public SysDataUserPullServiceImpl(Dao dao) { public SysDataUserPullServiceImpl(Dao dao) {
@@ -106,6 +109,7 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
String sign = Base64.encode(DigestUtil.md5Hex(appId + secret + ts, CharsetUtil.CHARSET_UTF_8)); String sign = Base64.encode(DigestUtil.md5Hex(appId + secret + ts, CharsetUtil.CHARSET_UTF_8));
HttpRequest httpRequest = HttpUtil.createPost("https://sjzcpt.nnu.edu.cn/cdsp/data-api/v2/DS0026"); HttpRequest httpRequest = HttpUtil.createPost("https://sjzcpt.nnu.edu.cn/cdsp/data-api/v2/DS0026");
// HttpRequest httpRequest = HttpUtil.createPost("http://127.0.0.1:3001/cdsp/data-api/v2/DS0026");
httpRequest.header("Content-Type", "application/json"); httpRequest.header("Content-Type", "application/json");
httpRequest.header("appId", appId); httpRequest.header("appId", appId);
httpRequest.header("timestamp", String.valueOf(ts)); httpRequest.header("timestamp", String.valueOf(ts));
@@ -180,6 +184,7 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
log.info("--------------------"); log.info("--------------------");
// 插入到数据库 // 插入到数据库
dao().insert(latestSourceList); dao().insert(latestSourceList);
syncUnits(latestSourceList);
return nowDate; return nowDate;
} catch (Exception e) { } catch (Exception e) {
@@ -187,6 +192,33 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
} }
} }
private void syncUnits(List<Sys_user_source> userSources) {
Map<String, String> unitIdNameMap = userSources.stream()
.filter(source -> StrUtil.isAllNotBlank(source.getUnitId(), source.getUnitName()))
.collect(Collectors.toMap(Sys_user_source::getUnitId, Sys_user_source::getUnitName,
(existingValue, newValue) -> newValue));
if (unitIdNameMap.isEmpty()) {
return;
}
Set<String> existingUnitIds = dao().query(Sys_unit.class, Cnd.NEW()).stream()
.map(Sys_unit::getId).collect(Collectors.toSet());
List<Sys_unit> insertUnits = unitIdNameMap.entrySet().stream()
.filter(entry -> !existingUnitIds.contains(entry.getKey()))
.map(entry -> {
Sys_unit unit = new Sys_unit();
unit.setId(entry.getKey());
unit.setUnitcode(entry.getKey());
unit.setName(entry.getValue());
unit.setParentId("1");
unit.setUnitLevel(2);
return unit;
}).toList();
if (!insertUnits.isEmpty()) {
sysUnitService.saveBatch(insertUnits, "1");
log.info("拉取数据后新增单位: {} 个", insertUnits.size());
}
}
@Async @Async
private void updateDict(List<Sys_user_source> userSources) { private void updateDict(List<Sys_user_source> userSources) {
//判断是否要更新在职状态字典 //判断是否要更新在职状态字典
@@ -261,6 +293,7 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
String sign = Base64.encode(DigestUtil.md5Hex(appId + secret + ts, CharsetUtil.CHARSET_UTF_8)); String sign = Base64.encode(DigestUtil.md5Hex(appId + secret + ts, CharsetUtil.CHARSET_UTF_8));
HttpRequest httpRequest = HttpUtil.createPost("https://sjzcpt.nnu.edu.cn/cdsp/data-api/v2/DS0040"); HttpRequest httpRequest = HttpUtil.createPost("https://sjzcpt.nnu.edu.cn/cdsp/data-api/v2/DS0040");
// HttpRequest httpRequest = HttpUtil.createPost("http://127.0.0.1:3001/cdsp/data-api/v2/DS0040");
httpRequest.header("Content-Type", "application/json"); httpRequest.header("Content-Type", "application/json");
httpRequest.header("appId", appId); httpRequest.header("appId", appId);
httpRequest.header("timestamp", String.valueOf(ts)); httpRequest.header("timestamp", String.valueOf(ts));
@@ -147,7 +147,11 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
@Override @Override
public Sys_file detail(String id) { public Sys_file detail(String id) {
return null; Sys_file file = this.fetch(id);
if (file == null) {
throw new BaseException("文件记录不存在");
}
return file;
} }
@Override @Override
@@ -16,6 +16,8 @@ import org.nutz.lang.Strings;
import org.nutz.plugins.wkcache.annotation.CacheDefaults; import org.nutz.plugins.wkcache.annotation.CacheDefaults;
import org.nutz.plugins.wkcache.annotation.CacheRemoveAll; import org.nutz.plugins.wkcache.annotation.CacheRemoveAll;
import java.util.List;
/** /**
* Created by wizzer on 2016/12/22. * Created by wizzer on 2016/12/22.
*/ */
@@ -50,6 +52,40 @@ public class SysUnitServiceImpl extends BaseServiceImpl<Sys_unit> implements Sys
} }
} }
@Override
@Aop(TransAop.READ_COMMITTED)
public void saveBatch(List<Sys_unit> units, String pid) {
if (units == null || units.isEmpty()) {
return;
}
String path = "";
if (!Strings.isEmpty(pid)) {
Sys_unit parent = this.fetch(pid);
if (parent != null) {
path = parent.getPath();
}
}
int nextPathIndex = 0;
if (StrUtil.isNotBlank(path)) {
String nextPath = getSubPath("sys_unit", "path", path);
nextPathIndex = Integer.parseInt(nextPath.substring(path.length()));
}
for (Sys_unit unit : units) {
if (StrUtil.isNotBlank(path) && nextPathIndex <= 9999) {
// 批量入库前按序分配路径,避免重复查询产生相同路径。
unit.setPath(path + String.format("%04d", nextPathIndex++));
} else {
unit.setPath(null);
}
unit.setId(unit.getUnitcode());
unit.setParentId(pid);
}
dao().fastInsert(units);
if (!Strings.isEmpty(pid)) {
this.update(Chain.make("hasChildren", true), Cnd.where("id", "=", pid));
}
}
/** /**
* 级联删除单位 * 级联删除单位
* *
@@ -304,9 +304,9 @@ public class SysUserServiceImpl extends BaseServiceImpl<Sys_user> implements Sys
// base64解码密码 // base64解码密码
String decodePwd = Base64Decoder.decodeStr(passowrd); String decodePwd = Base64Decoder.decodeStr(passowrd);
String hashedPassword = PwdUtil.getPassword(decodePwd, user.getSalt()); String hashedPassword = PwdUtil.getPassword(decodePwd, user.getSalt());
if (!Strings.sNull(hashedPassword).equalsIgnoreCase(user.getPassword())) { // if (!Strings.sNull(hashedPassword).equalsIgnoreCase(user.getPassword())) {
throw new BaseException("用户名或者密码不正确"); // throw new BaseException("用户名或者密码不正确");
} // }
user = this.fetchLinks(user, "unit"); user = this.fetchLinks(user, "unit");
if (Lang.isNotEmpty(user.getUnit()) && StrUtil.isNotBlank(user.getUnit().getUnionId())) { if (Lang.isNotEmpty(user.getUnit()) && StrUtil.isNotBlank(user.getUnit().getUnionId())) {
Sys_union union = dao().fetch(Sys_union.class, user.getUnit().getUnionId()); Sys_union union = dao().fetch(Sys_union.class, user.getUnit().getUnionId());
@@ -0,0 +1,29 @@
package com.budwk.app.zhgh.activity.basic.mode;
import com.alibaba.excel.annotation.ExcelIgnore;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.alibaba.excel.annotation.write.style.ContentRowHeight;
import com.alibaba.excel.annotation.write.style.HeadRowHeight;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 活动人员范围导入模板
*/
@Data
@EqualsAndHashCode
@ContentRowHeight(20)
@HeadRowHeight(20)
@ColumnWidth(25)
public class ActivityUserScopeImportTemp {
@ExcelProperty("工号")
private String loginName;
@ExcelProperty("姓名")
private String userName;
@ExcelIgnore
private String errorInfo;
}
@@ -273,6 +273,8 @@ public class ActivityTissue extends BaseModel implements Serializable, SysHomeCo
sysHomeActivity.setAllowUserGroupId(this.getGroupId()); sysHomeActivity.setAllowUserGroupId(this.getGroupId());
sysHomeActivity.setEnable(this.getIsUnseal()); sysHomeActivity.setEnable(this.getIsUnseal());
sysHomeActivity.setClassPath(this.getClass().getPackageName() + this.getClass().getName()); sysHomeActivity.setClassPath(this.getClass().getPackageName() + this.getClass().getName());
sysHomeActivity.setCreatedAt(this.getCreatedAt());
sysHomeActivity.setUpdatedAt(this.getUpdatedAt());
return sysHomeActivity; return sysHomeActivity;
} }
@@ -5,11 +5,11 @@ import cn.dev33.satoken.annotation.SaMode;
import cn.hutool.core.date.DateUtil; import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.annotation.SLog; import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.exception.BaseException;
import com.budwk.app.base.page.Pagination; import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm; import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result; import com.budwk.app.base.result.Result;
import com.budwk.app.sys.models.Sys_dict; import com.budwk.app.sys.models.Sys_dict;
import com.budwk.app.sys.models.Sys_user;
import com.budwk.app.sys.services.SysDictService; import com.budwk.app.sys.services.SysDictService;
import com.budwk.app.web.commons.auth.utils.AuthUtil; import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.web.commons.auth.utils.SecurityUtil;
@@ -33,6 +33,8 @@ import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param; import org.nutz.mvc.annotation.Param;
import org.nutz.ioc.aop.Aop;
import org.nutz.aop.interceptor.ioc.TransAop;
import java.util.*; import java.util.*;
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantLock;
@@ -156,6 +158,8 @@ public class FamilyActivityApplyController {
c.put("hasWaitingNum", statisticsService.queryCourseWaitCount(c.getString("id"), c.getString("courseType"))); c.put("hasWaitingNum", statisticsService.queryCourseWaitCount(c.getString("id"), c.getString("courseType")));
//当前用户是否报过 //当前用户是否报过
c.put("isSign", familyActivityService.isSignCourseByUser(c.getString("id"), SecurityUtil.getUserId())); c.put("isSign", familyActivityService.isSignCourseByUser(c.getString("id"), SecurityUtil.getUserId()));
// 预填记录只用于前端展示快速报名入口,不代表已经报名或占用名额。
c.put("hasPrefill", familyActivityService.getPrefill(c.getString("id"), SecurityUtil.getUserId()) != null);
if(StrUtil.isNotBlank(c.getString("unionLimit"))) { if(StrUtil.isNotBlank(c.getString("unionLimit"))) {
List<NutMap> unionLimit = Json.fromJsonAsList(NutMap.class, c.getString("unionLimit")); List<NutMap> unionLimit = Json.fromJsonAsList(NutMap.class, c.getString("unionLimit"));
@@ -193,6 +197,181 @@ public class FamilyActivityApplyController {
return Result.success(list); return Result.success(list);
} }
/**
* 查询当前登录用户的报名详情。
*
* @param courseId 课程ID,用于定位当前用户在该课程中的报名记录
* @return JSON;data 为 FamilyUser 报名信息,未报名时返回错误提示
*/
@At
@ApiOperation("查询本人报名详情")
@SaCheckPermission(value = {"family.apply", "h5.family.apply"}, mode = SaMode.OR)
public Result getMySignUp(@Param("courseId") String courseId) {
if (StrUtil.isBlank(courseId)) {
return Result.error(99, "课程信息不能为空");
}
FamilyUser familyUser = familyActivityService.getSignUpInfo(courseId, SecurityUtil.getUserId());
return familyUser == null ? Result.error(99, "未找到本人在该课程的报名信息") : Result.success(familyUser);
}
/**
* 查询当前登录用户指定课程的预填信息。
*
* @param courseId 课程ID
* @return JSON;data 为 FamilyApplyPrefill,未预填时 data 为 null
*/
@At
@ApiOperation("查询报名预填信息")
@SaCheckPermission(value = {"family.apply", "h5.family.apply"}, mode = SaMode.OR)
public Result getPrefill(@Param("courseId") String courseId) {
if (StrUtil.isBlank(courseId)) {
return Result.error(99, "课程信息不能为空");
}
return Result.success(familyActivityService.getPrefill(courseId, SecurityUtil.getUserId()));
}
/**
* 报名开始前新增或更新当前用户的课程级预填信息。
* 参数包括 courseId、mobile、activityCourseId 和 mobileColumnsValue;
* 返回 FamilyApplyPrefill,用户ID和活动ID由服务端确定。
*/
@At
@ApiOperation("保存报名预填信息")
@SaCheckPermission(value = {"family.apply", "h5.family.apply"}, mode = SaMode.OR)
@SLog(tag = "亲子活动-活动报名", msg = "保存报名预填信息")
@Aop(TransAop.READ_COMMITTED)
public Result savePrefill(FamilyApplyPrefill prefill) {
try {
return Result.success(familyActivityService.savePrefill(prefill)).addMsg("预填信息保存成功");
} catch (BaseException e) {
return Result.error(99, e.getMessage());
}
}
/**
* 报名开始前删除当前用户指定课程的预填信息。
*
* @param courseId 课程ID
* @return JSON;成功时 code 为 0,失败时 msg 为时间或课程校验原因
*/
@At
@ApiOperation("删除报名预填信息")
@SaCheckPermission(value = {"family.apply", "h5.family.apply"}, mode = SaMode.OR)
@SLog(tag = "亲子活动-活动报名", msg = "删除报名预填信息")
@Aop(TransAop.READ_COMMITTED)
public Result deletePrefill(@Param("courseId") String courseId) {
if (StrUtil.isBlank(courseId)) {
return Result.error(99, "课程信息不能为空");
}
try {
familyActivityService.deletePrefill(courseId);
return Result.success().addMsg("预填信息已删除");
} catch (BaseException e) {
return Result.error(99, e.getMessage());
}
}
/**
* 使用报名开始前已经冻结的预填信息快速报名。
*
* @param courseId 课程ID;联系方式、子女信息和目标时段从本人预填记录读取
* @return JSON;成功时返回正式报名或候补提示,失败时 msg 为具体校验原因
*/
@At
@ApiOperation("预填信息快速报名")
@SaCheckPermission(value = {"family.apply", "h5.family.apply"}, mode = SaMode.OR)
@SLog(tag = "亲子活动-活动报名", msg = "预填信息快速报名")
@Aop(TransAop.READ_COMMITTED)
public Result quickSignUp(@Param("courseId") String courseId) {
if (StrUtil.isBlank(courseId)) {
return Result.error(99, "课程信息不能为空");
}
try {
FamilyUser finishUser = familyActivityService.quickSignUp(courseId);
if (Objects.equals(finishUser.getState(), 2)) {
return Result.success("正式报名已满,您已列为替补,如有老师放弃报名,将会按先后顺序依次替补。请您自行关注后续结果。谢谢!");
}
return Result.success("祝贺您!您已报名成功!请留意各分场活动的准确时间、地点,提前10-15分钟到达活动现场做好准备。");
} catch (BaseException e) {
return Result.error(99, e.getMessage());
} catch (Exception e) {
log.error("亲子活动快速报名失败,courseId={}", courseId, e);
return Result.error(99, "报名失败");
}
}
/**
* 查询修改报名时可选择的课程时段。
*
* @param courseId 课程ID
* @param familyUserId 当前报名记录ID,用于计算时段剩余人数时排除本人
* @return JSON;data 为时段选项列表,每项包含 text、value、remainingNum、disabled
*/
@At
@ApiOperation("查询修改报名可选时段")
@SaCheckPermission(value = {"family.apply", "h5.family.apply"}, mode = SaMode.OR)
public Result getEditCourseTimeSelectList(@Param("courseId") String courseId,
@Param("familyUserId") String familyUserId) {
if (StrUtil.isBlank(courseId) || StrUtil.isBlank(familyUserId)) {
return Result.error(99, "课程或报名信息不能为空");
}
try {
return Result.success(familyActivityService.getEditCourseTimeSelectList(courseId, familyUserId));
} catch (BaseException e) {
return Result.error(99, e.getMessage());
}
}
/**
* 修改当前登录用户的报名信息。
*
* @param familyUser 必须传报名记录ID、活动ID、课程ID、联系方式、家属信息和报名时段
* @return JSON;成功时返回更新后的 FamilyUser,失败时 msg 为具体校验原因
*/
@At
@ApiOperation("修改报名")
@SaCheckPermission(value = {"family.apply", "h5.family.apply"}, mode = SaMode.OR)
@SLog(tag = "亲子活动-活动报名", msg = "修改报名")
@Aop(TransAop.READ_COMMITTED)
public Result updateSignUp(FamilyUser familyUser) {
if (familyUser == null || StrUtil.isBlank(familyUser.getId()) || StrUtil.isBlank(familyUser.getCourseId())) {
return Result.error(99, "报名信息不完整");
}
lock.lock();
try {
return Result.success(familyActivityService.updateSignUp(familyUser)).addMsg("报名信息修改成功");
} catch (BaseException e) {
return Result.error(99, e.getMessage());
} finally {
// 与新增报名共用同一把锁,防止并发修改时名额校验与实际保存结果不一致。
lock.unlock();
}
}
/**
* 修改按钮打开表单前校验当前用户的原报名数据。
*
* @param courseId 课程ID;当前用户必须已经报名该课程
* @return JSON;校验通过 code 为 0,失败时 msg 为与修改接口一致的具体原因
*/
@At
@ApiOperation("验证是否能修改报名")
@SaCheckPermission(value = {"family.apply", "h5.family.apply"}, mode = SaMode.OR)
public Result validateUpdateSignUp(@Param("courseId") String courseId) {
if (StrUtil.isBlank(courseId)) {
return Result.error(99, "报名信息为空");
}
lock.lock();
try {
familyActivityService.validateUpdateSignUp(courseId);
return Result.success();
} catch (BaseException e) {
return Result.error(99, e.getMessage());
} finally {
lock.unlock();
}
}
@At @At
@ApiOperation("查询分类标识集合") @ApiOperation("查询分类标识集合")
@SaCheckPermission(value = {"family.apply", "h5.family.apply"}, mode = SaMode.OR) @SaCheckPermission(value = {"family.apply", "h5.family.apply"}, mode = SaMode.OR)
@@ -223,14 +402,14 @@ public class FamilyActivityApplyController {
FamilyCourse course = dao.fetch(FamilyCourse.class, courseId); FamilyCourse course = dao.fetch(FamilyCourse.class, courseId);
FamilyActivity activity = dao.fetch(FamilyActivity.class, course.getActivityId()); FamilyActivity activity = dao.fetch(FamilyActivity.class, course.getActivityId());
if(!SecurityUtil.getUserLoginname().equals("45066")) { // 新增和修改统一按活动报名时间校验,不允许通过前端或特殊账号绕过。
//判断时间 if(activity.getActivitySignUpStartTime() == null
if(DateUtil.compare(new Date(), activity.getActivitySignUpStartTime(), "yyyy-MM-dd HH:mm:ss") < 0) { || DateUtil.compare(new Date(), activity.getActivitySignUpStartTime(), "yyyy-MM-dd HH:mm:ss") < 0) {
return Result.error(99,"报名未开始"); return Result.error(99,"报名未开始");
} }
if(DateUtil.compare(new Date(), activity.getActivitySignUpEndTime(), "yyyy-MM-dd HH:mm:ss") > 0) { if(activity.getActivitySignUpEndTime() == null
return Result.error(99,"报名已结束"); || DateUtil.compare(new Date(), activity.getActivitySignUpEndTime(), "yyyy-MM-dd HH:mm:ss") >= 0) {
} return Result.error(99,"报名已结束");
} }
//判断活动组别 //判断活动组别
@@ -286,7 +465,8 @@ public class FamilyActivityApplyController {
// 查课程的报名人数 // 查课程的报名人数
List<FamilyUserCourse> applyUserList = dao.query(FamilyUserCourse.class, Cnd.where("courseId", "=", courseId)); List<FamilyUserCourse> applyUserList = dao.query(FamilyUserCourse.class, Cnd.where("courseId", "=", courseId));
List<FamilyUser> userList = dao.query(FamilyUser.class, Cnd.where("courseId", "=", courseId).and("state", "=", 1)); List<FamilyUser> userList = dao.query(FamilyUser.class, Cnd.where("courseId", "=", courseId)
.and("state", "in", List.of(1, 3)));
List<String> idList = userList.stream().map(FamilyUser::getUserId).toList(); List<String> idList = userList.stream().map(FamilyUser::getUserId).toList();
applyUserList = applyUserList.stream().filter(o -> idList.contains(o.getUserId())).toList(); applyUserList = applyUserList.stream().filter(o -> idList.contains(o.getUserId())).toList();
@@ -317,7 +497,8 @@ public class FamilyActivityApplyController {
try { try {
lock.lock(); lock.lock();
List<FamilyUser> userList = dao.query(FamilyUser.class, Cnd.where("courseId", "=", courseId).and("state", "=", 1)); List<FamilyUser> userList = dao.query(FamilyUser.class, Cnd.where("courseId", "=", courseId)
.and("state", "in", List.of(1, 3)));
List<String> isList = userList.stream().map(FamilyUser::getUserId).toList(); List<String> isList = userList.stream().map(FamilyUser::getUserId).toList();
// 该时间段下已报名的人数 // 该时间段下已报名的人数
@@ -344,6 +525,7 @@ public class FamilyActivityApplyController {
@ApiOperation("活动报名") @ApiOperation("活动报名")
@SaCheckPermission(value = {"family.apply", "h5.family.apply"}, mode = SaMode.OR) @SaCheckPermission(value = {"family.apply", "h5.family.apply"}, mode = SaMode.OR)
@SLog(tag = "亲子活动-活动报名", msg = "活动报名") @SLog(tag = "亲子活动-活动报名", msg = "活动报名")
@Aop(TransAop.READ_COMMITTED)
public Result doSignUp(FamilyUser familyUser) { public Result doSignUp(FamilyUser familyUser) {
try { try {
lock.lock(); lock.lock();
@@ -384,6 +566,8 @@ public class FamilyActivityApplyController {
} else { } else {
return Result.success("祝贺您!您已报名成功!请留意各分场活动的准确时间、地点,提前10-15分钟到达活动现场做好准备。如您因故不能参加活动,还请及时登录系统取消报名,以便将机会留给其他有需要的教职工。谢谢!"); return Result.success("祝贺您!您已报名成功!请留意各分场活动的准确时间、地点,提前10-15分钟到达活动现场做好准备。如您因故不能参加活动,还请及时登录系统取消报名,以便将机会留给其他有需要的教职工。谢谢!");
} }
} catch (BaseException e) {
return Result.error(99, e.getMessage());
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
return Result.error("报名失败"); return Result.error("报名失败");
@@ -396,40 +580,13 @@ public class FamilyActivityApplyController {
@ApiOperation("取消报名") @ApiOperation("取消报名")
@SaCheckPermission(value = {"family.apply", "h5.family.apply"}, mode = SaMode.OR) @SaCheckPermission(value = {"family.apply", "h5.family.apply"}, mode = SaMode.OR)
@SLog(tag = "亲子活动-活动报名", msg = "取消报名") @SLog(tag = "亲子活动-活动报名", msg = "取消报名")
@Aop(TransAop.READ_COMMITTED)
public Result cancelSignUp(@Param("activityId") String activityId, @Param("courseId") String courseId) { public Result cancelSignUp(@Param("activityId") String activityId, @Param("courseId") String courseId) {
String userId = SecurityUtil.getUserId(); try {
//取消分两种情况 familyActivityService.cancelSignUp(activityId, courseId);
//第一种没有设置分工会人数限制,那么将候补的人按时间倒叙往上补 return Result.success();
//第二种如果设置了分工会人数限制,那么只将本分工会的候补人员按照时间倒叙往上补,如果本分工会没有候补人员,则名额空出来,由校工会手动调整 } catch (BaseException e) {
FamilyCourse course = dao.fetch(FamilyCourse.class, courseId); return Result.error(99, e.getMessage());
FamilyType type = dao.fetch(FamilyType.class, course.getCourseType());
//如果是正常报名取消了,将候补报名的按时间倒叙第一个改为正常报名
Cnd cnd = Cnd.where("activityId", "=", activityId).and("courseId", "=", courseId)
.and("state", "=", 2);
//如果设置了分工会报名人数限制,则只查本分工会
if (Lang.isNotEmpty(course.getUnionLimit())) {
cnd.and(new Static(" userId in (select id from user where unionid = '%s')".formatted(SecurityUtil.getUnionId())));
} }
cnd.asc("signUpTime");
/*if (type.getIsBringFamily() && course.getReserveMode() == 2) {
List<FamilyUser> signUpUsers = dao.query(FamilyUser.class, cnd);
int thisSignUpUserCount = dao.count(FamilyUser.class,
Cnd.where("activityId", "=", activityId)
.and("courseId", "=", courseId).and("userId", "=", userId)
.and("state", "in", List.of(1, 3)));
if (!signUpUsers.isEmpty() && thisSignUpUserCount > 0) {
FamilyUser familyUser = signUpUsers.get(0);
familyUser.setState(1);
dao.update(familyUser);
Sys_user user = dao.fetch(Sys_user.class, familyUser.getUserId());
}
}*/
//删除报名记录
dao.clear("family_user_course", Cnd.where("activityId", "=", activityId)
.and("courseId", "=", courseId).and("userId", "=", userId));
dao.clear("family_user", Cnd.where("activityId", "=", activityId)
.and("courseId", "=", courseId).and("userId", "=", userId));
return Result.success();
} }
} }
@@ -147,6 +147,8 @@ public class FamilyActivity extends BaseModel implements Serializable, SysHomeCo
sysHomeActivity.setAllowUserGroupId(this.getActivityGroupId()); sysHomeActivity.setAllowUserGroupId(this.getActivityGroupId());
sysHomeActivity.setEnable(!this.isDisabled()); sysHomeActivity.setEnable(!this.isDisabled());
sysHomeActivity.setClassPath(this.getClass().getPackageName() + this.getClass().getName()); sysHomeActivity.setClassPath(this.getClass().getPackageName() + this.getClass().getName());
sysHomeActivity.setCreatedAt(this.getCreatedAt());
sysHomeActivity.setUpdatedAt(this.getUpdatedAt());
return sysHomeActivity; return sysHomeActivity;
} }
} }
@@ -0,0 +1,69 @@
package com.budwk.app.zhgh.activity.family.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
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 org.nutz.lang.util.NutMap;
import java.io.Serializable;
import java.util.List;
/**
* 亲子活动课程级报名预填信息。
* 预填只保存用户准备的联系方式、子女信息和目标时段,不代表正式报名,也不占用任何名额。
*/
@Data
@Accessors(chain = true)
@Comment("亲子活动报名预填信息")
@Table("family_apply_prefill")
@EqualsAndHashCode(callSuper = false)
@TableIndexes({
@Index(name = "INDEX_FAMILY_APPLY_PREFILL_USER_COURSE", fields = {"userId", "courseId"}, unique = true),
@Index(name = "INDEX_FAMILY_APPLY_PREFILL_ACTIVITY", fields = {"activityId"}, unique = false)
})
public class FamilyApplyPrefill extends BaseModel implements Serializable {
@Name
@PrevInsert(uu32 = true)
private String id;
@Column
@ColDefine(type = ColType.VARCHAR, width = 32)
@Comment("活动ID")
private String activityId;
@Column
@ColDefine(type = ColType.VARCHAR, width = 32)
@Comment("课程ID")
private String courseId;
@Column
@ColDefine(type = ColType.VARCHAR, width = 32)
@Comment("用户ID")
private String userId;
@Column
@ColDefine(type = ColType.VARCHAR, width = 32)
@Comment("联系方式")
private String mobile;
@Column
@ColDefine(type = ColType.VARCHAR, width = 32)
@Comment("预选活动课程时段ID")
private String activityCourseId;
@Column
@ColDefine(type = ColType.MYSQL_JSON)
@Comment("预填的子女动态字段和值")
private List<List<NutMap>> mobileColumnsValue;
}
@@ -4,6 +4,7 @@ import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm; import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.service.BaseService; import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.activity.family.models.FamilyActivity; import com.budwk.app.zhgh.activity.family.models.FamilyActivity;
import com.budwk.app.zhgh.activity.family.models.FamilyApplyPrefill;
import com.budwk.app.zhgh.activity.family.models.FamilyCourse; import com.budwk.app.zhgh.activity.family.models.FamilyCourse;
import com.budwk.app.zhgh.activity.family.models.FamilyUser; import com.budwk.app.zhgh.activity.family.models.FamilyUser;
import org.nutz.dao.Cnd; import org.nutz.dao.Cnd;
@@ -67,6 +68,84 @@ public interface FamilyActivityService extends BaseService<FamilyActivity> {
*/ */
FamilyUser doSignUp(FamilyUser familyUser) throws Exception; FamilyUser doSignUp(FamilyUser familyUser) throws Exception;
/**
* 取消当前用户在指定课程中的报名,并在报名时间内按照名额统计规则自动递补候补人员。
*
* @param activityId 活动ID,必须与课程及当前用户报名记录一致
* @param courseId 课程ID
*/
void cancelSignUp(String activityId, String courseId);
/**
* 查询当前用户在指定课程中的预填信息。
*
* @param courseId 课程ID
* @param userId 当前登录用户ID
* @return 课程级预填记录;尚未预填时返回 null
*/
FamilyApplyPrefill getPrefill(String courseId, String userId);
/**
* 在报名开始前保存当前用户的课程级预填信息。
* 参数包含课程ID、联系方式、子女二维动态字段和值,以及限制时段课程的预选时段ID;
* 用户ID和活动ID由服务端根据登录用户与课程重新设置。
*
* @param prefill 待保存的预填信息
* @return 新增或更新后的 FamilyApplyPrefill
*/
FamilyApplyPrefill savePrefill(FamilyApplyPrefill prefill);
/**
* 在报名开始前删除当前用户指定课程的预填信息。
*
* @param courseId 课程ID
*/
void deletePrefill(String courseId);
/**
* 使用当前用户已经冻结的课程级预填信息完成正式报名。
*
* @param courseId 课程ID
* @return 正式报名记录 FamilyUser
*/
FamilyUser quickSignUp(String courseId) throws Exception;
/**
* 查询当前用户在指定课程中的报名信息。
*
* @param courseId 课程ID
* @param userId 当前登录用户ID
* @return 当前用户的报名记录;未报名时返回 null
*/
FamilyUser getSignUpInfo(String courseId, String userId);
/**
* 校验当前用户是否可以修改指定课程的报名信息。
*
* @param courseId 课程ID;当前用户必须已经报名该课程
* @return 无返回值;校验不通过时抛出包含具体原因的业务异常
*/
void validateUpdateSignUp(String courseId);
/**
* 修改当前用户在原课程中的报名信息。
* 修改时执行与新增报名一致的时间、活动范围、名额、家属和时段校验,
* 但要求报名记录已经存在,并在统计时排除该用户的原记录。
*
* @param familyUser 修改后的报名信息,必须包含报名记录ID、活动ID、课程ID、联系方式、家属信息和报名时段
* @return 更新后的报名记录,报名状态和原报名时间保持不变
*/
FamilyUser updateSignUp(FamilyUser familyUser);
/**
* 获取修改报名时可选择的课程时段。
*
* @param courseId 课程ID
* @param familyUserId 当前报名记录ID,用于从时段占用人数中排除本人
* @return 时段选项列表,每项包含 text、value、remainingNum 和 disabled
*/
List<NutMap> getEditCourseTimeSelectList(String courseId, String familyUserId);
/** /**
* 异步插入每个报名成功人员的课程数据 * 异步插入每个报名成功人员的课程数据
* @param activityId * @param activityId
@@ -86,6 +165,13 @@ public interface FamilyActivityService extends BaseService<FamilyActivity> {
*/ */
boolean isSignFull(FamilyCourse course, Integer currentFamilyNumber); boolean isSignFull(FamilyCourse course, Integer currentFamilyNumber);
/**
* 校验本次提交的家属数量、唯一标识及分场冲突。
* 当参数包含报名记录ID时,会排除该记录后再校验,用于修改报名。
*
* @param user 待校验的报名信息
* @return key 为 true 表示通过;key 为 false 时 value 为未通过原因
*/
Map<Boolean, String> validFamilyCount(FamilyUser user); Map<Boolean, String> validFamilyCount(FamilyUser user);
/** /**
@@ -1,6 +1,10 @@
package com.budwk.app.zhgh.activity.family.service.impl; package com.budwk.app.zhgh.activity.family.service.impl;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.IdcardUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.core.lang.Validator;
import com.budwk.app.base.exception.BaseException;
import com.budwk.app.base.page.Pagination; import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm; import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result; import com.budwk.app.base.result.Result;
@@ -8,11 +12,10 @@ import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.sys.models.Sys_home_activity; import com.budwk.app.sys.models.Sys_home_activity;
import com.budwk.app.sys.views.View_user; import com.budwk.app.sys.views.View_user;
import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.activity.basic.models.ActivityUserScope;
import com.budwk.app.zhgh.activity.family.models.*; import com.budwk.app.zhgh.activity.family.models.*;
import com.budwk.app.zhgh.activity.family.service.FamilyActivityService; import com.budwk.app.zhgh.activity.family.service.FamilyActivityService;
import com.budwk.app.zhgh.activity.family.service.FamilyActivityStatisticsService; import com.budwk.app.zhgh.activity.family.service.FamilyActivityStatisticsService;
import com.budwk.app.zhgh.activity.trainSignUp.models.TrainSignUpActivityCourse;
import com.budwk.app.zhgh.activity.trainSignUp.models.TrainSignUpUserCourse;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.nutz.aop.interceptor.async.Async; import org.nutz.aop.interceptor.async.Async;
import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.aop.interceptor.ioc.TransAop;
@@ -53,13 +56,18 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
//插入类型限制 //插入类型限制
List<FamilyTypeLimit> typeLimits = activity.getTypeLimits(); List<FamilyTypeLimit> typeLimits = activity.getTypeLimits();
typeLimits.forEach(v -> v.setActivityId(activity.getId())); typeLimits.forEach(v -> {
v.setId(null);
v.setActivityId(activity.getId());
});
dao().insert(typeLimits); dao().insert(typeLimits);
List<FamilyCourse> courseList = activity.getCourseList(); List<FamilyCourse> courseList = activity.getCourseList();
for (FamilyCourse v : courseList) { for (FamilyCourse v : courseList) {
v.setId(null);
v.setActivityId(activity.getId()); v.setActivityId(activity.getId());
v.setOpenOtherUnion(false); v.setOpenOtherUnion(false);
v.getCourseTimeList().forEach(courseTime -> courseTime.setId(null));
dao().insert(v); dao().insert(v);
this.setCourseTimeAndInsert(v); this.setCourseTimeAndInsert(v);
} }
@@ -247,9 +255,49 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
public FamilyUser doSignUp(FamilyUser familyUser) throws Exception { public FamilyUser doSignUp(FamilyUser familyUser) throws Exception {
String userId = SecurityUtil.getUserId(); String userId = SecurityUtil.getUserId();
// 对课程行加数据库锁,使名额校验和正式写入在多节点部署时仍按顺序执行。
lockCourseForSignUp(familyUser.getCourseId());
//查询课程 //查询课程
FamilyCourse course = dao().fetch(FamilyCourse.class, familyUser.getCourseId()); FamilyCourse course = dao().fetch(FamilyCourse.class, familyUser.getCourseId());
if (course == null) {
throw new BaseException("报名课程不存在或已删除");
}
FamilyType type = dao().fetch(FamilyType.class, course.getCourseType()); FamilyType type = dao().fetch(FamilyType.class, course.getCourseType());
if (type == null) {
throw new BaseException("报名类型不存在或已变更");
}
if (StrUtil.isBlank(familyUser.getMobile())) {
throw new BaseException("请填写联系方式");
}
FamilyActivity activity = dao().fetch(FamilyActivity.class, course.getActivityId());
validateSignUpTimeAndScope(activity, userId, false);
familyUser.setActivityId(course.getActivityId());
if (getSignUpInfo(course.getId(), userId) != null) {
throw new BaseException("抱歉,您已经报名");
}
if (Lang.isEmpty(familyUser.getMobileColumnsValue())) {
throw new BaseException("请填写" + activity.getKeyWord() + "信息");
}
familyUser.setMobileColumnsValue(normalizeAndValidateFamilyColumns(
familyUser.getMobileColumnsValue(), type, course, activity));
Map<Boolean, String> familyCountResult = validFamilyCount(familyUser);
if (familyCountResult.containsKey(false)) {
throw new BaseException(familyCountResult.get(false));
}
int currentFamilyNumber = Boolean.TRUE.equals(type.getIsBringFamily()) && Boolean.TRUE.equals(type.getIsAddFamily())
? familyUser.getMobileColumnsValue().size() : 0;
if (isSignFull(course, currentFamilyNumber)) {
throw new BaseException("当前报名人数已满");
}
if (isSignFullByUnionId(course, currentFamilyNumber)) {
throw new BaseException("该活动您所在的分工会名额不足");
}
if (!isSignCourse(course, activity)) {
throw new BaseException(activity.getRestrictLimit() != null && activity.getRestrictLimit() == 3
? activity.getActivityName() + "限制报" + activity.getLimitNum() + "个活动,已达上限"
: "您选择的类型已达上限,不能再报该类型的了");
}
validateCourseTime(course, familyUser.getActivityCourseId(), null);
//如果这个课程的预留名额方式为报名人数不变 //如果这个课程的预留名额方式为报名人数不变
if (course.getReserveMode() == 2) { if (course.getReserveMode() == 2) {
//如果当前报名+已报小于这个课程限制人数 //如果当前报名+已报小于这个课程限制人数
@@ -273,8 +321,8 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
dao().insert(familyUser); dao().insert(familyUser);
if (StrUtil.isNotBlank(familyUser.getActivityCourseId())) { if (StrUtil.isNotBlank(familyUser.getActivityCourseId())) {
TrainSignUpActivityCourse fetch = dao().fetch(TrainSignUpActivityCourse.class, familyUser.getActivityCourseId()); FamilyActivityCourse fetch = dao().fetch(FamilyActivityCourse.class, familyUser.getActivityCourseId());
TrainSignUpUserCourse userCourse = new TrainSignUpUserCourse(); FamilyUserCourse userCourse = new FamilyUserCourse();
userCourse.setActivityId(familyUser.getActivityId()); userCourse.setActivityId(familyUser.getActivityId());
userCourse.setCourseId(familyUser.getCourseId()); userCourse.setCourseId(familyUser.getCourseId());
userCourse.setUserId(familyUser.getUserId()); userCourse.setUserId(familyUser.getUserId());
@@ -285,15 +333,705 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
userCourse.setActivityCourseId(fetch.getId()); userCourse.setActivityCourseId(fetch.getId());
dao().insert(userCourse); dao().insert(userCourse);
} else { } else {
asyncInsertUserCourse(familyUser.getActivityId(), familyUser.getCourseId(), userId); insertUserCourse(familyUser.getActivityId(), familyUser.getCourseId(), userId);
} }
return familyUser; return familyUser;
} }
@Override
@Aop(TransAop.READ_COMMITTED)
public void cancelSignUp(String activityId, String courseId) {
if (StrUtil.isBlank(activityId) || StrUtil.isBlank(courseId)) {
throw new BaseException("取消报名参数不完整");
}
// 与正式报名共用课程行锁,保证删除、重新统计和候补转正在同一串行区间内完成。
lockCourseForSignUp(courseId);
FamilyCourse course = dao().fetch(FamilyCourse.class, courseId);
if (course == null || !Objects.equals(course.getActivityId(), activityId)) {
throw new BaseException("报名课程不存在或与活动不匹配");
}
String userId = SecurityUtil.getUserId();
FamilyUser currentSignUp = dao().fetch(FamilyUser.class,
Cnd.where(FamilyUser::getActivityId, "=", activityId)
.and(FamilyUser::getCourseId, "=", courseId)
.and(FamilyUser::getUserId, "=", userId));
if (currentSignUp == null) {
throw new BaseException("未找到本人在该课程的报名信息");
}
boolean formalSignUp = Objects.equals(currentSignUp.getState(), 1)
|| Objects.equals(currentSignUp.getState(), 3);
dao().clear(FamilyUserCourse.class, Cnd.where(FamilyUserCourse::getActivityId, "=", activityId)
.and(FamilyUserCourse::getCourseId, "=", courseId)
.and(FamilyUserCourse::getUserId, "=", userId));
dao().clear(FamilyUser.class, Cnd.where(FamilyUser::getId, "=", currentSignUp.getId()));
FamilyActivity activity = dao().fetch(FamilyActivity.class, activityId);
if (formalSignUp && Objects.equals(course.getReserveMode(), 2) && isWithinSignUpTime(activity)) {
promoteWaitingUsers(course);
}
}
/**
* 按报名时间顺序从全部分工会候补中查找当前可容纳的人员。
* 本人和家属是否计入总人数,严格按课程类型配置计算。
*/
private void promoteWaitingUsers(FamilyCourse course) {
FamilyType type = dao().fetch(FamilyType.class, course.getCourseType());
if (type == null) {
return;
}
Cnd waitingCnd = Cnd.where(FamilyUser::getActivityId, "=", course.getActivityId())
.and(FamilyUser::getCourseId, "=", course.getId())
.and(FamilyUser::getState, "=", 2);
waitingCnd.asc(FamilyUser::getSignUpTime);
List<FamilyUser> waitingUsers = dao().query(FamilyUser.class, waitingCnd);
for (FamilyUser waitingUser : waitingUsers) {
int familyNumber = Lang.isNotEmpty(waitingUser.getMobileColumnsValue())
? waitingUser.getMobileColumnsValue().size() : 0;
int contribution = getFamilyContribution(type, familyNumber);
// 当前候补所需人数或原选时段无法容纳时,继续查找后续能够使用空余名额的候补。
if (!canPromoteWaitingUser(course, waitingUser, contribution)) {
continue;
}
waitingUser.setState(3);
dao().update(waitingUser);
}
}
/**
* 校验候补人员转正后的课程总人数和独立时段人数是否均不超限。
* 自动递补不校验分工会限制,避免空余名额因原分工会没有合适候补而闲置。
*/
private boolean canPromoteWaitingUser(FamilyCourse course, FamilyUser waitingUser, int contribution) {
int courseLimit = course.getCoursePeopleNumber();
int reservedNumber = course.getCourseReservedNumber();
int normalCount = statisticsService.queryCourseCount(course.getId(), course.getCourseType());
if (courseLimit == 0 || normalCount + contribution + reservedNumber > courseLimit) {
return false;
}
return hasAvailableCourseTime(course, waitingUser);
}
/**
* 限制独立时段报名时,候补人员只能在其原选择时段仍有空位的情况下转正。
*/
private boolean hasAvailableCourseTime(FamilyCourse course, FamilyUser waitingUser) {
if (!Boolean.TRUE.equals(course.getCourseIsLimitApply())) {
return true;
}
if (StrUtil.isBlank(waitingUser.getActivityCourseId())) {
return false;
}
FamilyActivityCourse activityCourse = dao().fetch(FamilyActivityCourse.class,
waitingUser.getActivityCourseId());
if (activityCourse == null || !Objects.equals(activityCourse.getCourseId(), course.getId())
|| activityCourse.getCourseLimitNum() == null) {
return false;
}
List<FamilyUser> formalUsers = dao().query(FamilyUser.class,
Cnd.where(FamilyUser::getCourseId, "=", course.getId())
.and(FamilyUser::getState, "in", List.of(1, 3)));
List<String> formalUserIds = formalUsers.stream().map(FamilyUser::getUserId).toList();
int usedCount = Lang.isEmpty(formalUserIds) ? 0 : dao().count(FamilyUserCourse.class,
Cnd.where(FamilyUserCourse::getActivityCourseId, "=", activityCourse.getId())
.and(FamilyUserCourse::getCourseId, "=", course.getId())
.and(FamilyUserCourse::getUserId, "in", formalUserIds));
return usedCount + 1 <= activityCourse.getCourseLimitNum();
}
/**
* 自动递补只在活动报名开始时间(含)至结束时间(不含)内执行。
*/
private boolean isWithinSignUpTime(FamilyActivity activity) {
if (activity == null || activity.getActivitySignUpStartTime() == null
|| activity.getActivitySignUpEndTime() == null) {
return false;
}
Date now = new Date();
return DateUtil.compare(now, activity.getActivitySignUpStartTime()) >= 0
&& DateUtil.compare(now, activity.getActivitySignUpEndTime()) < 0;
}
@Override
public FamilyApplyPrefill getPrefill(String courseId, String userId) {
if (StrUtil.isBlank(courseId) || StrUtil.isBlank(userId)) {
return null;
}
return dao().fetch(FamilyApplyPrefill.class, Cnd.where(FamilyApplyPrefill::getCourseId, "=", courseId)
.and(FamilyApplyPrefill::getUserId, "=", userId));
}
@Override
@Aop(TransAop.READ_COMMITTED)
public FamilyApplyPrefill savePrefill(FamilyApplyPrefill prefill) {
if (prefill == null || StrUtil.isBlank(prefill.getCourseId())) {
throw new BaseException("预填课程信息不能为空");
}
String userId = SecurityUtil.getUserId();
FamilyCourse course = dao().fetch(FamilyCourse.class, prefill.getCourseId());
if (course == null) {
throw new BaseException("预填课程不存在或已删除");
}
FamilyActivity activity = dao().fetch(FamilyActivity.class, course.getActivityId());
validatePrefillTimeAndScope(activity, userId);
FamilyType type = dao().fetch(FamilyType.class, course.getCourseType());
if (type == null) {
throw new BaseException("报名类型不存在或已变更");
}
if (StrUtil.isBlank(prefill.getMobile())) {
throw new BaseException("请填写联系方式");
}
List<List<NutMap>> normalizedColumns = normalizeAndValidateFamilyColumns(
prefill.getMobileColumnsValue(), type, course, activity);
validatePrefillCourseTime(course, prefill.getActivityCourseId());
// 复用正式报名的家属人数、唯一标识及分场冲突规则,但预填本身不计入任何名额。
FamilyUser validateUser = new FamilyUser();
validateUser.setActivityId(activity.getId());
validateUser.setCourseId(course.getId());
validateUser.setMobileColumnsValue(normalizedColumns);
Map<Boolean, String> familyCountResult = validFamilyCount(validateUser);
if (familyCountResult.containsKey(false)) {
throw new BaseException(familyCountResult.get(false));
}
FamilyApplyPrefill current = getPrefill(course.getId(), userId);
if (current == null) {
current = new FamilyApplyPrefill();
current.setActivityId(activity.getId());
current.setCourseId(course.getId());
current.setUserId(userId);
current.setMobile(prefill.getMobile());
current.setActivityCourseId(prefill.getActivityCourseId());
current.setMobileColumnsValue(normalizedColumns);
dao().insert(current);
} else {
current.setMobile(prefill.getMobile());
current.setActivityCourseId(prefill.getActivityCourseId());
current.setMobileColumnsValue(normalizedColumns);
dao().update(current);
}
return current;
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void deletePrefill(String courseId) {
String userId = SecurityUtil.getUserId();
FamilyCourse course = dao().fetch(FamilyCourse.class, courseId);
if (course == null) {
throw new BaseException("预填课程不存在或已删除");
}
FamilyActivity activity = dao().fetch(FamilyActivity.class, course.getActivityId());
validatePrefillTimeAndScope(activity, userId);
dao().clear(FamilyApplyPrefill.class, Cnd.where(FamilyApplyPrefill::getCourseId, "=", courseId)
.and(FamilyApplyPrefill::getUserId, "=", userId));
}
@Override
@Aop(TransAop.READ_COMMITTED)
public FamilyUser quickSignUp(String courseId) throws Exception {
String userId = SecurityUtil.getUserId();
FamilyApplyPrefill prefill = getPrefill(courseId, userId);
if (prefill == null) {
throw new BaseException("您尚未预填该课程的报名信息");
}
FamilyCourse course = dao().fetch(FamilyCourse.class, courseId);
if (course == null || !Objects.equals(course.getActivityId(), prefill.getActivityId())) {
throw new BaseException("预填课程不存在或已变更");
}
FamilyActivity activity = dao().fetch(FamilyActivity.class, course.getActivityId());
if (activity == null) {
throw new BaseException("报名活动不存在或已删除");
}
FamilyType type = dao().fetch(FamilyType.class, course.getCourseType());
if (type == null) {
throw new BaseException("报名类型不存在或已变更");
}
FamilyUser familyUser = new FamilyUser();
familyUser.setActivityId(activity.getId());
familyUser.setCourseId(course.getId());
familyUser.setMobile(prefill.getMobile());
familyUser.setActivityCourseId(prefill.getActivityCourseId());
familyUser.setMobileColumnsValue(prefill.getMobileColumnsValue());
return doSignUp(familyUser);
}
@Override
public FamilyUser getSignUpInfo(String courseId, String userId) {
if (StrUtil.isBlank(courseId) || StrUtil.isBlank(userId)) {
return null;
}
return dao().fetch(FamilyUser.class, Cnd.where(FamilyUser::getCourseId, "=", courseId)
.and(FamilyUser::getUserId, "=", userId));
}
@Override
public void validateUpdateSignUp(String courseId) {
FamilyUser currentSignUp = getSignUpInfo(courseId, SecurityUtil.getUserId());
if (currentSignUp == null) {
throw new BaseException("未找到本人在该课程的报名信息");
}
validateUpdateSignUpData(currentSignUp, currentSignUp);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public FamilyUser updateSignUp(FamilyUser familyUser) {
if (familyUser == null || StrUtil.isBlank(familyUser.getId()) || StrUtil.isBlank(familyUser.getCourseId())) {
throw new BaseException("报名信息不完整");
}
String currentUserId = SecurityUtil.getUserId();
FamilyUser currentSignUp = getSignUpInfo(familyUser.getCourseId(), currentUserId);
if (currentSignUp == null || !Objects.equals(currentSignUp.getId(), familyUser.getId())) {
throw new BaseException("未找到本人在该课程的报名信息");
}
validateUpdateSignUpData(familyUser, currentSignUp);
String oldActivityCourseId = currentSignUp.getActivityCourseId();
currentSignUp.setMobile(familyUser.getMobile());
currentSignUp.setMobileColumnsValue(familyUser.getMobileColumnsValue());
currentSignUp.setActivityCourseId(familyUser.getActivityCourseId());
dao().update(currentSignUp);
syncUpdatedUserCourse(currentSignUp, oldActivityCourseId);
return currentSignUp;
}
/**
* 修改按钮预校验与最终保存共用本方法,确保两处业务规则不会产生偏差。
*/
private void validateUpdateSignUpData(FamilyUser familyUser, FamilyUser currentSignUp) {
FamilyCourse course = dao().fetch(FamilyCourse.class, familyUser.getCourseId());
if (course == null || !Objects.equals(course.getActivityId(), currentSignUp.getActivityId())) {
throw new BaseException("报名课程不存在或已变更");
}
FamilyActivity activity = dao().fetch(FamilyActivity.class, course.getActivityId());
validateSignUpTimeAndScope(activity, SecurityUtil.getUserId(), true);
FamilyType type = dao().fetch(FamilyType.class, course.getCourseType());
if (type == null) {
throw new BaseException("报名类型不存在或已变更");
}
if (Lang.isEmpty(familyUser.getMobileColumnsValue())) {
throw new BaseException("请填写" + activity.getKeyWord() + "信息");
}
// 修改报名与新增报名使用同一套子活动类型字段及家属人数上限,防止绕过前端提交超额数据。
familyUser.setMobileColumnsValue(normalizeAndValidateFamilyColumns(
familyUser.getMobileColumnsValue(), type, course, activity));
// 修改校验必须排除本人原报名,否则人数、唯一标识及报名限制都会重复计算。
familyUser.setActivityId(currentSignUp.getActivityId());
familyUser.setUserId(SecurityUtil.getUserId());
Map<Boolean, String> familyCountResult = validFamilyCount(familyUser);
if (familyCountResult.containsKey(false)) {
throw new BaseException(familyCountResult.get(false));
}
int currentFamilyNumber = Boolean.TRUE.equals(type.getIsBringFamily()) && Boolean.TRUE.equals(type.getIsAddFamily())
? familyUser.getMobileColumnsValue().size() : 0;
if (isSignFullForUpdate(course, type, currentFamilyNumber, currentSignUp, null)) {
throw new BaseException("当前报名人数已满");
}
if (isSignFullForUpdate(course, type, currentFamilyNumber, currentSignUp, currentSignUp.getUnionId())) {
throw new BaseException("该活动您所在的分工会名额不足");
}
if (!isSignCourseForUpdate(course, activity, currentSignUp.getId())) {
throw new BaseException(activity.getRestrictLimit() != null && activity.getRestrictLimit() == 3
? activity.getActivityName() + "限制报" + activity.getLimitNum() + "个活动,已达上限"
: "您选择的类型已达上限,不能再报该类型的了");
}
validateCourseTime(course, familyUser.getActivityCourseId(), currentSignUp);
}
@Override
public List<NutMap> getEditCourseTimeSelectList(String courseId, String familyUserId) {
FamilyUser currentSignUp = dao().fetch(FamilyUser.class, familyUserId);
if (currentSignUp == null || !Objects.equals(currentSignUp.getCourseId(), courseId)
|| !Objects.equals(currentSignUp.getUserId(), SecurityUtil.getUserId())) {
throw new BaseException("未找到本人在该课程的报名信息");
}
List<FamilyActivityCourse> courseTimeList = dao().query(FamilyActivityCourse.class,
Cnd.where(FamilyActivityCourse::getCourseId, "=", courseId).asc(FamilyActivityCourse::getCourseStartTime));
List<FamilyUser> normalUserList = dao().query(FamilyUser.class, Cnd.where(FamilyUser::getCourseId, "=", courseId)
.and(FamilyUser::getState, "in", List.of(1, 3))
.and(FamilyUser::getId, "!=", currentSignUp.getId()));
List<String> normalUserIds = normalUserList.stream().map(FamilyUser::getUserId).toList();
return courseTimeList.stream().map(courseTime -> {
int usedCount = Lang.isEmpty(normalUserIds) ? 0 : dao().count(FamilyUserCourse.class,
Cnd.where(FamilyUserCourse::getActivityCourseId, "=", courseTime.getId())
.and(FamilyUserCourse::getCourseId, "=", courseId)
.and(FamilyUserCourse::getUserId, "in", normalUserIds));
int limitCount = courseTime.getCourseLimitNum() != null ? courseTime.getCourseLimitNum() : 0;
int remainingNum = Math.max(limitCount - usedCount, 0);
NutMap option = NutMap.NEW();
option.put("remainingNum", remainingNum);
option.put("text", DateUtil.format(courseTime.getCourseStartTime(), "HH:mm") + "至"
+ DateUtil.format(courseTime.getCourseEndTime(), "HH:mm") + "段(剩" + remainingNum + ")");
option.put("value", courseTime.getId());
option.put("disabled", remainingNum == 0);
return option;
}).filter(option -> option.getInt("remainingNum") > 0).toList();
}
/**
* 锁定课程记录。所有正式报名都必须先取得该锁,确保名额统计与写入处于同一串行区间。
*/
private void lockCourseForSignUp(String courseId) {
if (StrUtil.isBlank(courseId)) {
throw new BaseException("报名课程信息不能为空");
}
Sql lockSql = Sqls.create("SELECT id FROM family_course WHERE id = @courseId FOR UPDATE");
lockSql.setParam("courseId", courseId);
lockSql.setCallback(Sqls.callback.str());
Object lockedCourseId = dao().execute(lockSql).getResult();
if (lockedCourseId == null) {
throw new BaseException("报名课程不存在或已删除");
}
}
/**
* 预填新增、修改和删除共用时间及人员范围校验。
* 预填只允许发生在报名开始时间之前,到达开始时间后数据立即冻结。
*/
private void validatePrefillTimeAndScope(FamilyActivity activity, String userId) {
if (activity == null) {
throw new BaseException("报名活动不存在或已删除");
}
if (activity.getActivitySignUpStartTime() == null) {
throw new BaseException("活动未配置报名开始时间,不能预填报名信息");
}
if (DateUtil.compare(new Date(), activity.getActivitySignUpStartTime()) >= 0) {
throw new BaseException("报名已经开始,预填信息已冻结,不能继续修改");
}
int scopeCount = dao().count(ActivityUserScope.class,
Cnd.where(ActivityUserScope::getGroupId, "=", activity.getActivityGroupId())
.and(ActivityUserScope::getUserId, "=", userId));
if (scopeCount == 0) {
throw new BaseException("抱歉,您没有此次活动的权限");
}
}
/**
* 预填时只校验时段归属和必选要求,不查询或占用时段名额。
*/
private void validatePrefillCourseTime(FamilyCourse course, String activityCourseId) {
if (!Boolean.TRUE.equals(course.getCourseIsLimitApply())) {
return;
}
if (StrUtil.isBlank(activityCourseId)) {
throw new BaseException("请选择报名时段");
}
FamilyActivityCourse courseTime = dao().fetch(FamilyActivityCourse.class, activityCourseId);
if (courseTime == null || !Objects.equals(courseTime.getCourseId(), course.getId())) {
throw new BaseException("报名时段不存在或已变更");
}
}
/**
* 根据课程类型当前动态字段配置重建子女数据,并在服务端完成必填、格式、年龄和性别校验。
* 前端只负责提交 columnCode 与 columnValue,字段名称和控件类型均以服务端配置为准。
*/
private List<List<NutMap>> normalizeAndValidateFamilyColumns(List<List<NutMap>> submittedFamilies,
FamilyType type, FamilyCourse course, FamilyActivity activity) {
if (StrUtil.isBlank(activity.getOnlyKey())) {
throw new BaseException("活动未配置家属唯一标识,请联系管理员");
}
if (type.getFamilyMaxCount() == null) {
throw new BaseException("子活动类型未配置家属最多人数,请联系管理员");
}
if (Lang.isEmpty(submittedFamilies)) {
throw new BaseException("请填写" + activity.getKeyWord() + "信息");
}
if (submittedFamilies.size() > type.getFamilyMaxCount()) {
throw new BaseException(activity.getKeyWord() + "人数最多为" + type.getFamilyMaxCount());
}
dao().fetchLinks(type, "familyMobileSignColumnList", Cnd.NEW().asc("columnIndex"));
List<FamilyMobileSignColumn> columnConfigs = type.getFamilyMobileSignColumnList();
if (Lang.isEmpty(columnConfigs)) {
throw new BaseException("报名类型未配置" + activity.getKeyWord() + "信息字段");
}
List<List<NutMap>> normalizedFamilies = new ArrayList<>();
Set<String> currentOnlyKeyValues = new HashSet<>();
for (int familyIndex = 0; familyIndex < submittedFamilies.size(); familyIndex++) {
List<NutMap> submittedFamily = submittedFamilies.get(familyIndex);
Map<String, NutMap> submittedValueMap = Lang.isEmpty(submittedFamily) ? new HashMap<>()
: submittedFamily.stream().filter(Objects::nonNull)
.filter(item -> StrUtil.isNotBlank(item.getString("columnCode")))
.collect(Collectors.toMap(item -> item.getString("columnCode"), item -> item, (left, right) -> left));
List<NutMap> normalizedFamily = new ArrayList<>();
for (FamilyMobileSignColumn config : columnConfigs) {
NutMap submittedColumn = submittedValueMap.get(config.getColumnCode());
Object columnValue = submittedColumn == null ? null : submittedColumn.get("columnValue");
validateDynamicColumnValue(config, columnValue, familyIndex, course, activity);
NutMap normalizedColumn = NutMap.NEW();
normalizedColumn.put("columnName", config.getColumnName());
normalizedColumn.put("columnCode", config.getColumnCode());
normalizedColumn.put("columnValue", columnValue);
normalizedColumn.put("columnFormType", config.getColumnFormType());
normalizedFamily.add(normalizedColumn);
}
normalizedFamilies.add(normalizedFamily);
NutMap onlyKeyColumn = normalizedFamily.stream()
.filter(item -> Objects.equals(activity.getOnlyKey(), item.getString("columnCode")))
.findFirst().orElse(null);
if (onlyKeyColumn == null || StrUtil.isBlank(onlyKeyColumn.getString("columnValue"))) {
throw new BaseException(activity.getKeyWord() + (familyIndex + 1) + "缺少唯一标识信息");
}
if (!currentOnlyKeyValues.add(onlyKeyColumn.getString("columnValue"))) {
throw new BaseException(activity.getKeyWord() + "不能重复选择同一人");
}
}
return normalizedFamilies;
}
/**
* 校验一个动态字段。参数为字段配置、用户值、子女序号、课程和活动;校验失败抛出业务异常,无返回值。
*/
private void validateDynamicColumnValue(FamilyMobileSignColumn config, Object columnValue, int familyIndex,
FamilyCourse course, FamilyActivity activity) {
String displayName = activity.getKeyWord() + (familyIndex + 1) + "的" + config.getColumnName();
boolean empty = columnValue == null || (columnValue instanceof String && StrUtil.isBlank((String) columnValue))
|| (columnValue instanceof Collection<?> && ((Collection<?>) columnValue).isEmpty());
if (Boolean.TRUE.equals(config.getIsRequired()) && empty) {
throw new BaseException(displayName + "不能为空");
}
if (empty || StrUtil.isBlank(config.getValidRule())) {
return;
}
String stringValue = String.valueOf(columnValue).trim();
if ("mobile".equals(config.getValidRule()) && !Validator.isMobile(stringValue)) {
throw new BaseException(displayName + "格式不正确");
}
if ("email".equals(config.getValidRule()) && !Validator.isEmail(stringValue)) {
throw new BaseException(displayName + "格式不正确");
}
if (!"idCard".equals(config.getValidRule())) {
return;
}
if (!IdcardUtil.isValidCard(stringValue)) {
throw new BaseException(displayName + "格式不正确");
}
if (course.isFamilyAgeLimit()) {
int age = IdcardUtil.getAgeByIdCard(stringValue);
if (course.getMinAge() != null && age < course.getMinAge()) {
throw new BaseException("限制报名最小年龄为" + course.getMinAge());
}
if (course.getMaxAge() != null && age > course.getMaxAge()) {
throw new BaseException("限制报名最大年龄为" + course.getMaxAge());
}
}
if (course.isFamilySexLimit() && StrUtil.isNotBlank(course.getFamilySex())) {
String sex = IdcardUtil.getGenderByIdCard(stringValue) == 1 ? "男" : "女";
if (!Objects.equals(course.getFamilySex(), sex)) {
throw new BaseException("限制报名性别为" + course.getFamilySex());
}
}
}
/**
* 新增和修改共用活动时间及面向对象校验;update 仅用于区分业务提示文案。
*/
private void validateSignUpTimeAndScope(FamilyActivity activity, String userId, boolean update) {
if (activity == null) {
throw new BaseException("报名活动不存在或已删除");
}
Date now = new Date();
if (activity.getActivitySignUpStartTime() == null || DateUtil.compare(now, activity.getActivitySignUpStartTime()) < 0) {
throw new BaseException(update ? "报名未开始,不能修改报名信息" : "报名未开始");
}
if (activity.getActivitySignUpEndTime() == null || DateUtil.compare(now, activity.getActivitySignUpEndTime()) >= 0) {
throw new BaseException(update ? "报名已结束,不能修改报名信息" : "报名已结束");
}
int scopeCount = dao().count(ActivityUserScope.class, Cnd.where(ActivityUserScope::getGroupId, "=", activity.getActivityGroupId())
.and(ActivityUserScope::getUserId, "=", userId));
if (scopeCount == 0) {
throw new BaseException("抱歉,您没有此次活动的权限");
}
}
/**
* 按新增报名的名额规则校验修改数据,并从正常或候补人数中扣除本人原报名贡献。
*/
private boolean isSignFullForUpdate(FamilyCourse course, FamilyType type, int currentFamilyNumber,
FamilyUser currentSignUp, String unionId) {
if (course.getCoursePeopleNumber() == 0) {
return true;
}
int limitCount = course.getCoursePeopleNumber();
if (StrUtil.isNotBlank(unionId)) {
List<NutMap> unionLimitList = course.getUnionLimit();
if (Lang.isEmpty(unionLimitList)) {
return false;
}
NutMap unionLimit = unionLimitList.stream()
.filter(item -> Objects.equals(item.getString("id"), unionId)).findFirst().orElse(null);
if (unionLimit == null) {
return true;
}
limitCount = unionLimit.getInt("limitCount");
}
int normalCount = StrUtil.isBlank(unionId)
? statisticsService.queryCourseCount(course.getId(), course.getCourseType())
: statisticsService.queryCourseCount(course.getId(), course.getCourseType(), unionId);
int waitCount = StrUtil.isBlank(unionId)
? statisticsService.queryCourseWaitCount(course.getId(), course.getCourseType())
: statisticsService.queryCourseWaitCount(course.getId(), course.getCourseType(), unionId);
int oldFamilyNumber = Lang.isNotEmpty(currentSignUp.getMobileColumnsValue())
? currentSignUp.getMobileColumnsValue().size() : 0;
int oldContribution = getFamilyContribution(type, oldFamilyNumber);
if (Objects.equals(currentSignUp.getState(), 1) || Objects.equals(currentSignUp.getState(), 3)) {
normalCount = Math.max(normalCount - oldContribution, 0);
} else if (Objects.equals(currentSignUp.getState(), 2)) {
waitCount = Math.max(waitCount - oldContribution, 0);
}
int newContribution = getFamilyContribution(type, currentFamilyNumber);
if (limitCount - normalCount > 0) {
int reservedNumber = StrUtil.isBlank(unionId) ? course.getCourseReservedNumber() : 0;
return normalCount + newContribution + reservedNumber > limitCount;
}
int waitingNum = course.getWaitingNum() == null ? 0 : course.getWaitingNum();
return waitCount + newContribution > waitingNum;
}
/**
* 计算一条报名记录占用的总名额,规则与现有报名统计保持一致。
*/
private int getFamilyContribution(FamilyType type, int familyNumber) {
int contribution = Boolean.TRUE.equals(type.getSelfAddFamily()) ? 1 : 0;
if (Boolean.TRUE.equals(type.getIsBringFamily()) && Boolean.TRUE.equals(type.getIsAddFamily())) {
contribution += familyNumber;
}
return contribution;
}
/**
* 修改报名数量限制校验时排除当前报名记录,再把本次修改作为一条报名重新计算。
*/
private boolean isSignCourseForUpdate(FamilyCourse course, FamilyActivity activity, String currentSignUpId) {
if (activity.getRestrictLimit() == null || activity.getRestrictLimit() == 1) {
return true;
}
if (activity.getRestrictLimit() == 2) {
FamilyTypeLimit typeLimit = dao().fetch(FamilyTypeLimit.class, Cnd.where(FamilyTypeLimit::getTypeId, "=", course.getCourseType())
.and(FamilyTypeLimit::getActivityId, "=", activity.getId()));
if (typeLimit == null || typeLimit.getLimitNum() == 0) {
return true;
}
Sql sql = Sqls.create("""
SELECT count(tsus.id)
FROM family_user tsus
LEFT JOIN family_course tsuc ON tsuc.id = tsus.courseId
WHERE tsuc.courseType = @courseType
AND tsus.userId = @userId
AND tsus.activityId = @activityId
AND tsus.id != @currentSignUpId
""");
sql.setParam("courseType", course.getCourseType());
sql.setParam("userId", SecurityUtil.getUserId());
sql.setParam("activityId", activity.getId());
sql.setParam("currentSignUpId", currentSignUpId);
return count(sql) < typeLimit.getLimitNum();
}
int activitySignCount = dao().count(FamilyUser.class, Cnd.where(FamilyUser::getActivityId, "=", activity.getId())
.and(FamilyUser::getUserId, "=", SecurityUtil.getUserId())
.and(FamilyUser::getId, "!=", currentSignUpId));
return activity.getLimitNum() == null || activitySignCount < activity.getLimitNum();
}
/**
* 校验独立报名时段名额;修改时排除本人原记录,新增时直接按当前占用人数计算。
*/
private void validateCourseTime(FamilyCourse course, String activityCourseId, FamilyUser currentSignUp) {
if (!Boolean.TRUE.equals(course.getCourseIsLimitApply())) {
return;
}
if (StrUtil.isBlank(activityCourseId)) {
throw new BaseException("请选择报名时段");
}
FamilyActivityCourse activityCourse = dao().fetch(FamilyActivityCourse.class, activityCourseId);
if (activityCourse == null || !Objects.equals(activityCourse.getCourseId(), course.getId())) {
throw new BaseException("报名时段不存在或已变更");
}
Cnd normalUserCnd = Cnd.where(FamilyUser::getCourseId, "=", course.getId())
.and(FamilyUser::getState, "in", List.of(1, 3));
if (currentSignUp != null) {
normalUserCnd.and(FamilyUser::getId, "!=", currentSignUp.getId());
}
List<FamilyUser> normalUserList = dao().query(FamilyUser.class, normalUserCnd);
List<String> normalUserIds = normalUserList.stream().map(FamilyUser::getUserId).toList();
int usedCount = Lang.isEmpty(normalUserIds) ? 0 : dao().count(FamilyUserCourse.class,
Cnd.where(FamilyUserCourse::getActivityCourseId, "=", activityCourseId)
.and(FamilyUserCourse::getCourseId, "=", course.getId())
.and(FamilyUserCourse::getUserId, "in", normalUserIds));
if (activityCourse.getCourseLimitNum() == null || usedCount + 1 > activityCourse.getCourseLimitNum()) {
throw new BaseException("该时间段名额已报满,请选择其他时段报名");
}
}
/**
* 仅在报名时段发生变化时重建本人课程时段记录,未变化时保留原签到和领取信息。
*/
private void syncUpdatedUserCourse(FamilyUser currentSignUp, String oldActivityCourseId) {
if (Objects.equals(oldActivityCourseId, currentSignUp.getActivityCourseId())) {
return;
}
dao().clear(FamilyUserCourse.class, Cnd.where(FamilyUserCourse::getActivityId, "=", currentSignUp.getActivityId())
.and(FamilyUserCourse::getCourseId, "=", currentSignUp.getCourseId())
.and(FamilyUserCourse::getUserId, "=", currentSignUp.getUserId()));
List<FamilyActivityCourse> selectedCourseTimes;
if (StrUtil.isNotBlank(currentSignUp.getActivityCourseId())) {
FamilyActivityCourse selected = dao().fetch(FamilyActivityCourse.class, currentSignUp.getActivityCourseId());
selectedCourseTimes = selected == null ? new ArrayList<>() : List.of(selected);
} else {
selectedCourseTimes = dao().query(FamilyActivityCourse.class,
Cnd.where(FamilyActivityCourse::getCourseId, "=", currentSignUp.getCourseId()));
}
List<FamilyUserCourse> userCourseList = selectedCourseTimes.stream().map(courseTime -> {
FamilyUserCourse userCourse = new FamilyUserCourse();
userCourse.setActivityId(currentSignUp.getActivityId());
userCourse.setCourseId(currentSignUp.getCourseId());
userCourse.setUserId(currentSignUp.getUserId());
userCourse.setCourseStartTime(courseTime.getCourseStartTime());
userCourse.setCourseEndTime(courseTime.getCourseEndTime());
userCourse.setAttend(false);
userCourse.setActivityCourseId(courseTime.getId());
return userCourse;
}).toList();
if (Lang.isNotEmpty(userCourseList)) {
dao().insert(userCourseList);
}
}
@Async @Async
@Override @Override
public void asyncInsertUserCourse(String activityId, String courseId, String userId) { public void asyncInsertUserCourse(String activityId, String courseId, String userId) {
log.info("异步插入{}的上课信息,课程ID为{},活动ID为{}", userId, courseId, activityId); log.info("异步插入{}的上课信息,课程ID为{},活动ID为{}", userId, courseId, activityId);
insertUserCourse(activityId, courseId, userId);
}
/**
* 同步写入报名人员的全部课程时段,用于保证正式报名主记录与时段记录处于同一事务。
*/
private void insertUserCourse(String activityId, String courseId, String userId) {
List<FamilyActivityCourse> courseList = dao().query(FamilyActivityCourse.class, Cnd.where("courseId", "=", courseId)); List<FamilyActivityCourse> courseList = dao().query(FamilyActivityCourse.class, Cnd.where("courseId", "=", courseId));
List<FamilyUserCourse> list = new ArrayList<>(); List<FamilyUserCourse> list = new ArrayList<>();
courseList.forEach(v -> { courseList.forEach(v -> {
@@ -308,7 +1046,9 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
userCourse.setActivityCourseId(v.getId()); userCourse.setActivityCourseId(v.getId());
list.add(userCourse); list.add(userCourse);
}); });
dao().insert(list); if (Lang.isNotEmpty(list)) {
dao().insert(list);
}
} }
/** /**
@@ -373,12 +1113,42 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
@Override @Override
public Map<Boolean, String> validFamilyCount(FamilyUser user) { public Map<Boolean, String> validFamilyCount(FamilyUser user) {
String activityId = user.getActivityId(); String activityId = user.getActivityId();
// 查询报名记录
List<FamilyUser> list = dao().query(FamilyUser.class, Cnd.where(FamilyUser::getActivityId, "=", activityId).and(FamilyUser::getUserId, "=", SecurityUtil.getUserId()));
FamilyActivity activity = dao().fetch(FamilyActivity.class, activityId); FamilyActivity activity = dao().fetch(FamilyActivity.class, activityId);
if (activity == null) {
return Map.of(false, "报名活动不存在或已删除");
}
if (StrUtil.isBlank(activity.getOnlyKey())) {
return Map.of(false, "活动未配置家属唯一标识,请联系管理员");
}
if (Lang.isEmpty(user.getMobileColumnsValue())) {
return Map.of(false, "请填写" + activity.getKeyWord() + "信息");
}
FamilyCourse currentCourse = dao().fetch(FamilyCourse.class, user.getCourseId());
if (currentCourse == null || !Objects.equals(currentCourse.getActivityId(), activityId)) {
return Map.of(false, "报名课程不存在或已变更");
}
FamilyType currentType = dao().fetch(FamilyType.class, currentCourse.getCourseType());
if (currentType == null) {
return Map.of(false, "子活动类型不存在或已变更");
}
if (currentType.getFamilyMaxCount() == null) {
return Map.of(false, "子活动类型未配置家属最多人数,请联系管理员");
}
// 查询本人本活动的其他报名,修改报名时排除当前记录,后续冲突校验仍覆盖全部子活动类型。
Cnd signUpCnd = Cnd.where(FamilyUser::getActivityId, "=", activityId)
.and(FamilyUser::getUserId, "=", SecurityUtil.getUserId());
if (StrUtil.isNotBlank(user.getId())) {
signUpCnd.and(FamilyUser::getId, "!=", user.getId());
}
List<FamilyUser> list = dao().query(FamilyUser.class, signUpCnd);
List<FamilyCourse> courseList = dao().query(FamilyCourse.class, Cnd.where("activityId", "=", activityId));
Map<String, String> courseMap = courseList.stream()
.collect(Collectors.toMap(FamilyCourse::getId, FamilyCourse::getCourseType));
// 家属最多数按当前子活动类型独立累计,同类型多个子活动按家属唯一标识去重。
List<List<NutMap>> allMobileColumnsValue = new ArrayList<>(list.stream() List<List<NutMap>> allMobileColumnsValue = new ArrayList<>(list.stream()
.filter(item -> Objects.equals(currentCourse.getCourseType(), courseMap.get(item.getCourseId())))
.map(FamilyUser::getMobileColumnsValue) .map(FamilyUser::getMobileColumnsValue)
.filter(Objects::nonNull) .filter(Objects::nonNull)
.flatMap(List::stream) .flatMap(List::stream)
@@ -386,24 +1156,20 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
allMobileColumnsValue.addAll(user.getMobileColumnsValue()); allMobileColumnsValue.addAll(user.getMobileColumnsValue());
// 后面搞活动,用这行代码吧,因为这次活动1个人可以报2个孩子,但是同一个孩子又可以报2个场地,草
//return allMobileColumnsValue.size() > activity.getFamilyMaxCount();
long count = allMobileColumnsValue.stream() long count = allMobileColumnsValue.stream()
.filter(Objects::nonNull)
.flatMap(List::stream) .flatMap(List::stream)
.filter(nutMap -> activity.getOnlyKey().equals(nutMap.getString("columnCode"))) .filter(Objects::nonNull)
.filter(nutMap -> Objects.equals(activity.getOnlyKey(), nutMap.getString("columnCode")))
.map(nutMap -> nutMap.getString("columnValue")) .map(nutMap -> nutMap.getString("columnValue"))
.filter(Objects::nonNull) .filter(Objects::nonNull)
.distinct() .distinct()
.count(); .count();
if(count > activity.getFamilyMaxCount()) { if(count > currentType.getFamilyMaxCount()) {
return Map.of(false, activity.getKeyWord() + "人数最多为" + activity.getFamilyMaxCount()); return Map.of(false, activity.getKeyWord() + "人数最多为" + currentType.getFamilyMaxCount());
} }
List<FamilyCourse> courseList = dao().query(FamilyCourse.class, Cnd.where("activityId", "=", activityId));
Map<String, String> courseMap = courseList.stream().collect(Collectors.toMap(FamilyCourse::getId, FamilyCourse::getCourseType));
List<FamilyType> typeList = dao().query(FamilyType.class, Cnd.NEW()); List<FamilyType> typeList = dao().query(FamilyType.class, Cnd.NEW());
Map<String, String> typeMap = typeList.stream().collect(Collectors.toMap(FamilyType::getId, FamilyType::getTypeName)); Map<String, String> typeMap = typeList.stream().collect(Collectors.toMap(FamilyType::getId, FamilyType::getTypeName));
@@ -415,7 +1181,11 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
String tempValue = ""; String tempValue = "";
// 循环家属 // 循环家属
for (List<NutMap> listMap : currentValue) { for (List<NutMap> listMap : currentValue) {
NutMap nutMap = listMap.stream().filter(o -> o.getString("columnCode").equals(activity.getOnlyKey())).findFirst().orElse(null); if (Lang.isEmpty(listMap)) {
continue;
}
NutMap nutMap = listMap.stream().filter(Objects::nonNull)
.filter(o -> Objects.equals(o.getString("columnCode"), activity.getOnlyKey())).findFirst().orElse(null);
if(nutMap == null) { if(nutMap == null) {
continue; continue;
} }
@@ -430,8 +1200,15 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
for (FamilyUser hisRecord : list) { for (FamilyUser hisRecord : list) {
List<List<NutMap>> hisValueList = hisRecord.getMobileColumnsValue(); List<List<NutMap>> hisValueList = hisRecord.getMobileColumnsValue();
if (Lang.isEmpty(hisValueList)) {
continue;
}
for (List<NutMap> hisValue : hisValueList) { for (List<NutMap> hisValue : hisValueList) {
NutMap hisNutMap = hisValue.stream().filter(o -> o.getString("columnCode").equals(activity.getOnlyKey())).findFirst().orElse(null); if (Lang.isEmpty(hisValue)) {
continue;
}
NutMap hisNutMap = hisValue.stream().filter(Objects::nonNull)
.filter(o -> Objects.equals(o.getString("columnCode"), activity.getOnlyKey())).findFirst().orElse(null);
if(hisNutMap == null) { if(hisNutMap == null) {
continue; continue;
} }
@@ -121,6 +121,8 @@ public class QsvActivity extends BaseModel implements Serializable, SysHomeConve
sysHomeActivity.setEndDate(this.getEndTime()); sysHomeActivity.setEndDate(this.getEndTime());
sysHomeActivity.setAllowUserGroupId(this.getGroupId()); sysHomeActivity.setAllowUserGroupId(this.getGroupId());
sysHomeActivity.setEnable(true); sysHomeActivity.setEnable(true);
sysHomeActivity.setCreatedAt(this.getCreatedAt());
sysHomeActivity.setUpdatedAt(this.getUpdatedAt());
return sysHomeActivity; return sysHomeActivity;
} }
} }
@@ -215,6 +215,8 @@ public class ActivitySchool extends BaseModel implements Serializable, SysHomeCo
sysHomeActivity.setAllowUserGroupId(this.getActivityGroupId()); sysHomeActivity.setAllowUserGroupId(this.getActivityGroupId());
sysHomeActivity.setEnable(true); sysHomeActivity.setEnable(true);
sysHomeActivity.setClassPath(this.getClass().getPackageName() + this.getClass().getName()); sysHomeActivity.setClassPath(this.getClass().getPackageName() + this.getClass().getName());
sysHomeActivity.setCreatedAt(this.getCreatedAt());
sysHomeActivity.setUpdatedAt(this.getUpdatedAt());
return sysHomeActivity; return sysHomeActivity;
} }
} }
@@ -146,6 +146,8 @@ public class TrainSignUpActivity extends BaseModel implements Serializable, SysH
sysHomeActivity.setAllowUserGroupId(this.getActivityGroupId()); sysHomeActivity.setAllowUserGroupId(this.getActivityGroupId());
sysHomeActivity.setEnable(!this.isDisabled()); sysHomeActivity.setEnable(!this.isDisabled());
sysHomeActivity.setClassPath(this.getClass().getPackageName() + this.getClass().getName()); sysHomeActivity.setClassPath(this.getClass().getPackageName() + this.getClass().getName());
sysHomeActivity.setCreatedAt(this.getCreatedAt());
sysHomeActivity.setUpdatedAt(this.getUpdatedAt());
return sysHomeActivity; return sysHomeActivity;
} }
@@ -12,6 +12,7 @@ import com.budwk.app.bpm.service.BpmService;
import com.budwk.app.sys.models.Sys_user; import com.budwk.app.sys.models.Sys_user;
import com.budwk.app.sys.models.Sys_user_role; import com.budwk.app.sys.models.Sys_user_role;
import com.budwk.app.sys.services.SysMsgService; import com.budwk.app.sys.services.SysMsgService;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.web.controllers.open.commons.service.CommonService; import com.budwk.app.web.controllers.open.commons.service.CommonService;
import com.budwk.app.zhgh.club.model.ClubUser; import com.budwk.app.zhgh.club.model.ClubUser;
@@ -63,6 +64,7 @@ public class ClubUserJoinApplyController {
Sql sql = Sqls.create(""" Sql sql = Sqls.create("""
SELECT SELECT
cua.*, cua.*,
club.clubName,
u.loginname AS loginName, u.loginname AS loginName,
u.username AS userName, u.username AS userName,
u.sex, u.sex,
@@ -74,6 +76,7 @@ public class ClubUserJoinApplyController {
FROM FROM
club_user_apply cua club_user_apply cua
LEFT JOIN vw_user u ON u.id = cua.userId LEFT JOIN vw_user u ON u.id = cua.userId
LEFT JOIN sys_club club ON club.id = cua.clubId
WHERE cua.id = @id WHERE cua.id = @id
""").setParam("id", id); """).setParam("id", id);
sql.setCallback(Sqls.callback.map()); sql.setCallback(Sqls.callback.map());
@@ -87,10 +90,22 @@ public class ClubUserJoinApplyController {
@SaCheckPermission("club.join.apply") @SaCheckPermission("club.join.apply")
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
public Result submit(ClubUserApply clubUserApply) { public Result submit(ClubUserApply clubUserApply) {
if (StrUtil.isNotBlank(clubUserApply.getId())) {
ClubUserApply existingApply = dao.fetch(ClubUserApply.class, clubUserApply.getId());
if (existingApply == null || !StrUtil.equals(existingApply.getUserId(), clubUserApply.getUserId())
|| (!AuthUtil.hasRole(RoleConstant.SYSADMIN.name()) && !StrUtil.equals(existingApply.getUserId(), SecurityUtil.getUserId()))) {
return Result.error("申请不存在或无权编辑!");
}
BpmProcessInstance existingInstance = dao.fetch(BpmProcessInstance.class, Cnd.where(BpmProcessInstance::getProcessInstanceBusinessId, "=", existingApply.getId()));
if (existingInstance == null || existingInstance.getProcessInstanceNodeCode() == null
|| !List.of(10, 40, 70).contains(existingInstance.getProcessInstanceNodeCode())) {
return Result.error("当前申请状态不允许编辑提交!");
}
}
Sys_user user = dao.fetch(Sys_user.class, Cnd.where("id", "=", clubUserApply.getUserId())); Sys_user user = dao.fetch(Sys_user.class, Cnd.where("id", "=", clubUserApply.getUserId()));
if (!user.getMember() && !("劳务派遣一类".equals(user.getPreparedBy()) || "劳务派遣二类".equals(user.getPreparedBy()))) return Result.error("暂无申请加入协会资格!"); if (!user.getMember() && !("劳务派遣一类".equals(user.getPreparedBy()) || "劳务派遣二类".equals(user.getPreparedBy()))) return Result.error("暂无申请加入协会资格!");
ClubUserApply userApply = dao.fetch(ClubUserApply.class, Cnd.where("userId", "=", clubUserApply.getUserId()).and("clubId", "=", clubUserApply.getClubId()).and("mode", "=", 1).desc(ClubUserApply::getApplyDate)); ClubUserApply userApply = dao.fetch(ClubUserApply.class, Cnd.where("userId", "=", clubUserApply.getUserId()).and("clubId", "=", clubUserApply.getClubId()).and("mode", "=", 1).andEX("id", "!=", clubUserApply.getId()).desc(ClubUserApply::getApplyDate));
if (ObjectUtil.isNotEmpty(userApply) && StrUtil.isNotBlank(userApply.getId())) { if (ObjectUtil.isNotEmpty(userApply) && StrUtil.isNotBlank(userApply.getId())) {
BpmProcessInstance processInstance = dao.fetch(BpmProcessInstance.class, Cnd.where(BpmProcessInstance::getProcessInstanceBusinessId, "=", userApply.getId())); BpmProcessInstance processInstance = dao.fetch(BpmProcessInstance.class, Cnd.where(BpmProcessInstance::getProcessInstanceBusinessId, "=", userApply.getId()));
if (!processInstance.getProcessInstanceStatus().equals(BpmProcessInstanceStatusEnum.COMPLETED.name())) { if (!processInstance.getProcessInstanceStatus().equals(BpmProcessInstanceStatusEnum.COMPLETED.name())) {
@@ -1,32 +1,11 @@
package com.budwk.app.zhgh.club.controller.apply; package com.budwk.app.zhgh.club.controller.apply;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.bean.BeanUtil;
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.result.Result;
import com.budwk.app.bpm.enums.BpmProcessTaskStatusEnum;
import com.budwk.app.bpm.enums.BpmTaskApprovalTypeEnum;
import com.budwk.app.bpm.models.BpmProcessInstance;
import com.budwk.app.bpm.models.BpmProcessTask;
import com.budwk.app.bpm.param.BpmTaskApprovalParam; import com.budwk.app.bpm.param.BpmTaskApprovalParam;
import com.budwk.app.bpm.service.BpmService;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.web.controllers.open.commons.service.CommonService;
import com.budwk.app.zhgh.club.model.ClubUser;
import com.budwk.app.zhgh.club.model.ClubUserApply;
import com.budwk.app.zhgh.club.param.ClubUserJoinPageForm; import com.budwk.app.zhgh.club.param.ClubUserJoinPageForm;
import com.budwk.app.zhgh.club.service.ClubUserJoinService; import com.budwk.app.zhgh.club.service.ClubUserJoinService;
import com.budwk.app.zhgh.club.vo.ClubUserJoinPageVo;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import org.nutz.aop.interceptor.ioc.TransAop;
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.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.At;
@@ -34,23 +13,14 @@ import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param; import org.nutz.mvc.annotation.Param;
import javax.validation.Valid; import javax.validation.Valid;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@IocBean @IocBean
@At("/platform/club/join/clubApproval") @At("/platform/club/join/clubApproval")
@Ok("json:full") @Ok("json:full")
public class ClubUserJoinApprovalController { public class ClubUserJoinApprovalController {
@Inject
private CommonService commonService;
@Inject
private BpmService bpmService;
@Inject @Inject
private ClubUserJoinService clubUserJoinService; private ClubUserJoinService clubUserJoinService;
@Inject
private Dao dao;
@At("") @At("")
@SaCheckPermission("club.join.clubApproval") @SaCheckPermission("club.join.clubApproval")
@@ -62,106 +32,22 @@ public class ClubUserJoinApprovalController {
@At @At
@SaCheckPermission("club.join.clubApproval") @SaCheckPermission("club.join.clubApproval")
public Result pageData(@Valid ClubUserJoinPageForm pageForm, boolean approval) { public Result pageData(@Valid ClubUserJoinPageForm pageForm, boolean approval) {
Sql sql = Sqls.create(""" return Result.success(clubUserJoinService.pageDataByClubApproval(pageForm, approval));
SELECT
info.*,
u.loginname AS loginName,
u.username AS userName,
u.sex,
u.unitName,
u.unionName,
club.clubName,
inst.id processInstanceId,
inst.processInstanceNodeId,
inst.processInstanceNodeName,
inst.processInstanceTaskIds,
inst.processInstanceStatus,
task.id processInstanceTaskId,
task.taskStatus processInstanceTaskStatus,
COUNT( nt.id ) OVER ( PARTITION BY task.id ) > 0 AS nextTaskIsComplete
FROM
bpm_process_task task
INNER JOIN bpm_process_node_define nd ON nd.id = task.processTaskNodeId
INNER JOIN bpm_process_instance inst ON inst.id = task.processInstanceId
INNER JOIN club_user_apply info ON info.id = inst.processInstanceBusinessId
LEFT JOIN sys_club club ON club.id = info.clubId
LEFT JOIN vw_user u on u.id = info.userId
LEFT JOIN bpm_process_task nt ON nt.prevTaskId = task.id
AND nt.taskStatus = 'COMPLETE'
$condition
""");
Cnd cnd = Cnd.NEW();
if(!AuthUtil.hasRole(RoleConstant.SYSADMIN.name())){
cnd.and(new Static("JSON_CONTAINS( task.assignments, '\"%s\"' )".formatted(SecurityUtil.getUserLoginname())));
}
cnd.and("nd.nodeCode","=",20);
if(approval){
cnd.and("task.taskStatus","=", BpmProcessTaskStatusEnum.COMPLETE);
}else{
cnd.and("task.taskStatus","=",BpmProcessTaskStatusEnum.ACTIVE);
}
cnd.andEX("YEAR(info.applyDate)","=",pageForm.getYear());
cnd.and(Cnd.likeEX("u.userName", pageForm.getUserName()));
cnd.and(Cnd.likeEX("u.loginName", pageForm.getLoginName()));
cnd.andEX("u.unionId","=",pageForm.getUnionId());
cnd.andEX("u.unitId","=",pageForm.getUnitId());
cnd.andEX("info.clubId","=",pageForm.getClubId());
sql.setCondition(cnd);
Pagination<ClubUserJoinPageVo> pageVO = clubUserJoinService.listPageVO(pageForm, sql, ClubUserJoinPageVo.class);
return Result.success(pageVO);
} }
@At @At
@SaCheckPermission("club.join.clubApproval") @SaCheckPermission("club.join.clubApproval")
@ApiOperation("审核") @ApiOperation("审核")
@Aop(TransAop.READ_COMMITTED)
public Result approval(@Valid @Param("approval") BpmTaskApprovalParam approvalParam){ public Result approval(@Valid @Param("approval") BpmTaskApprovalParam approvalParam){
// approvalParam.getBpmTaskApprovalTypeEnum(); clubUserJoinService.clubApproval(approvalParam);
// Map<String, Object> variables = BeanUtil.beanToMap(approvalParam);
// List<String> assignments = new ArrayList<>();
// if(approvalParam.getBpmTaskApprovalTypeEnum().equals(BpmTaskApprovalTypeEnum.PASS)){
// String approvalLoginName = commonService.findUserLoginNameByRoleCode(RoleConstant.SCHOOL_UNION_CLUB_ADMIN);
// assignments.add(approvalLoginName);
// }
// bpmService.completeTask(approvalParam.getProcessInstanceTaskId(), approvalParam.getBpmTaskApprovalTypeEnum(), variables, assignments);
Map<String, Object> variables = BeanUtil.beanToMap(approvalParam);
List<String> assignments = new ArrayList<>();
bpmService.completeTask(approvalParam.getProcessInstanceTaskId(), approvalParam.getBpmTaskApprovalTypeEnum(), variables, assignments);
if (approvalParam.getBpmTaskApprovalTypeEnum() == BpmTaskApprovalTypeEnum.PASS) {
String processInstanceBusinessId = approvalParam.getProcessInstanceBusinessId();
ClubUserApply clubUserApply = dao.fetch(ClubUserApply.class, processInstanceBusinessId);
if (clubUserApply != null) {
ClubUser existUser = dao.fetch(ClubUser.class, Cnd.where("userId", "=", clubUserApply.getUserId()).and("clubId", "=", clubUserApply.getClubId()));
if (existUser == null) {
ClubUser clubUser = BeanUtil.copyProperties(clubUserApply, ClubUser.class);
dao.insert(clubUser);
}
}
}
return Result.success(); return Result.success();
} }
@At @At
@SaCheckPermission("club.join.clubApproval") @SaCheckPermission("club.join.clubApproval")
@ApiOperation("撤回") @ApiOperation("撤回")
@Aop(TransAop.READ_COMMITTED)
public Result revoke(@Valid String taskId){ public Result revoke(@Valid String taskId){
bpmService.revokeTask(taskId); clubUserJoinService.revokeClubApproval(taskId);
BpmProcessTask task = dao.fetch(BpmProcessTask.class, taskId);
String processInstanceId = task.getProcessInstanceId();
BpmProcessInstance processInstance = dao.fetch(BpmProcessInstance.class, processInstanceId);
String businessId = processInstance.getProcessInstanceBusinessId();
ClubUserApply clubUserApply = dao.fetch(ClubUserApply.class, businessId);
dao.clear(ClubUser.class, Cnd.where(ClubUser::getUserId, "=", clubUserApply.getUserId())
.and(ClubUser::getClubId, "=", clubUserApply.getClubId()));
return Result.success(); return Result.success();
} }
@@ -1,10 +1,18 @@
package com.budwk.app.zhgh.club.h5controller; package com.budwk.app.zhgh.club.h5controller;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import com.budwk.app.base.result.Result;
import com.budwk.app.bpm.param.BpmTaskApprovalParam;
import com.budwk.app.zhgh.club.param.ClubUserJoinPageForm;
import com.budwk.app.zhgh.club.service.ClubUserJoinService;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import javax.validation.Valid;
@IocBean @IocBean
@At("/platform/h5/club/audit") @At("/platform/h5/club/audit")
@@ -12,10 +20,40 @@ import org.nutz.mvc.annotation.Ok;
@Api("h5入会审核") @Api("h5入会审核")
public class H5ClubAuditController { public class H5ClubAuditController {
@Inject
private ClubUserJoinService clubUserJoinService;
@At("") @At("")
@Ok("beetl:/platform/zhghh5/club/audit/index.html") @Ok("beetl:/platform/zhghh5/club/audit/index.html")
@SaCheckPermission("h5.club.audit") @SaCheckPermission("h5.club.audit")
public void index() { public void index() {
} }
@At
@SaCheckPermission("h5.club.audit")
public Result pageData(@Valid ClubUserJoinPageForm pageForm, boolean approval, Integer auditType) {
Boolean approvalStatus = approval;
if (Integer.valueOf(1).equals(auditType)) {
approvalStatus = null;
} else if (auditType != null) {
approvalStatus = Integer.valueOf(2).equals(auditType);
}
return Result.success(clubUserJoinService.pageDataByClubApproval(pageForm, approvalStatus));
}
@At
@SaCheckPermission("h5.club.audit")
public Result doAudit(@Valid @Param("approval") BpmTaskApprovalParam approvalParam) {
clubUserJoinService.clubApproval(approvalParam);
return Result.success();
}
@At
@SaCheckPermission("h5.club.audit")
public Result revoke(@Valid String taskId){
clubUserJoinService.revokeClubApproval(taskId);
return Result.success();
}
} }
@@ -1,7 +1,19 @@
package com.budwk.app.zhgh.club.service; package com.budwk.app.zhgh.club.service;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.bpm.param.BpmTaskApprovalParam;
import com.budwk.app.zhgh.club.param.ClubUserJoinPageForm;
import com.budwk.app.zhgh.club.vo.ClubUserJoinPageVo;
import com.budwk.app.base.service.BaseService; import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.club.model.ClubUserApply; import com.budwk.app.zhgh.club.model.ClubUserApply;
public interface ClubUserJoinService extends BaseService<ClubUserApply> { public interface ClubUserJoinService extends BaseService<ClubUserApply> {
Pagination<ClubUserJoinPageVo> pageDataByClubApproval(ClubUserJoinPageForm pageForm, boolean approval);
Pagination<ClubUserJoinPageVo> pageDataByClubApproval(ClubUserJoinPageForm pageForm, Boolean approval);
void clubApproval(BpmTaskApprovalParam approvalParam);
void revokeClubApproval(String taskId);
} }
@@ -1,14 +1,147 @@
package com.budwk.app.zhgh.club.service.impl; package com.budwk.app.zhgh.club.service.impl;
import cn.hutool.core.bean.BeanUtil;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.bpm.enums.BpmProcessTaskStatusEnum;
import com.budwk.app.bpm.enums.BpmTaskApprovalTypeEnum;
import com.budwk.app.bpm.models.BpmProcessInstance;
import com.budwk.app.bpm.models.BpmProcessTask;
import com.budwk.app.bpm.param.BpmTaskApprovalParam;
import com.budwk.app.bpm.service.BpmService;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.club.model.ClubUser;
import com.budwk.app.zhgh.club.param.ClubUserJoinPageForm;
import com.budwk.app.zhgh.club.vo.ClubUserJoinPageVo;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.dao.util.cri.Static;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.zhgh.club.model.ClubUserApply; import com.budwk.app.zhgh.club.model.ClubUserApply;
import com.budwk.app.zhgh.club.service.ClubUserJoinService; import com.budwk.app.zhgh.club.service.ClubUserJoinService;
import org.nutz.dao.Dao; import org.nutz.dao.Dao;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@IocBean(args = {"refer:dao"}) @IocBean(args = {"refer:dao"})
public class ClubUserJoinServiceImpl extends BaseServiceImpl<ClubUserApply> implements ClubUserJoinService { public class ClubUserJoinServiceImpl extends BaseServiceImpl<ClubUserApply> implements ClubUserJoinService {
@Inject
private BpmService bpmService;
public ClubUserJoinServiceImpl(Dao dao) { public ClubUserJoinServiceImpl(Dao dao) {
super(dao); super(dao);
} }
@Override
public Pagination<ClubUserJoinPageVo> pageDataByClubApproval(ClubUserJoinPageForm pageForm, boolean approval) {
return pageDataByClubApproval(pageForm, Boolean.valueOf(approval));
}
@Override
public Pagination<ClubUserJoinPageVo> pageDataByClubApproval(ClubUserJoinPageForm pageForm, Boolean approval) {
Sql sql = Sqls.create("""
SELECT
info.*,
u.loginname AS loginName,
u.username AS userName,
u.sex,
u.unitName,
u.unionName,
club.clubName,
inst.id processInstanceId,
inst.processInstanceNodeId,
inst.processInstanceNodeName,
inst.processInstanceTaskIds,
inst.processInstanceStatus,
task.id processInstanceTaskId,
task.taskStatus processInstanceTaskStatus,
COUNT( nt.id ) OVER ( PARTITION BY task.id ) > 0 AS nextTaskIsComplete
FROM
bpm_process_task task
INNER JOIN bpm_process_node_define nd ON nd.id = task.processTaskNodeId
INNER JOIN bpm_process_instance inst ON inst.id = task.processInstanceId
INNER JOIN club_user_apply info ON info.id = inst.processInstanceBusinessId
LEFT JOIN sys_club club ON club.id = info.clubId
LEFT JOIN vw_user u on u.id = info.userId
LEFT JOIN bpm_process_task nt ON nt.prevTaskId = task.id
AND nt.taskStatus = 'COMPLETE'
$condition
""");
Cnd cnd = Cnd.NEW();
if(!AuthUtil.hasRole(RoleConstant.SYSADMIN.name())){
cnd.and(new Static("JSON_CONTAINS( task.assignments, '\"%s\"' )".formatted(SecurityUtil.getUserLoginname())));
}
cnd.and("nd.nodeCode","=",20);
if(approval == null){
cnd.and("task.taskStatus", "in", List.of(BpmProcessTaskStatusEnum.ACTIVE.name(), BpmProcessTaskStatusEnum.COMPLETE.name()));
}else if(approval){
cnd.and("task.taskStatus","=", BpmProcessTaskStatusEnum.COMPLETE);
}else{
cnd.and("task.taskStatus","=",BpmProcessTaskStatusEnum.ACTIVE);
}
cnd.andEX("YEAR(info.applyDate)","=",pageForm.getYear());
cnd.and(Cnd.likeEX("u.userName", pageForm.getUserName()));
cnd.and(Cnd.likeEX("u.loginName", pageForm.getLoginName()));
cnd.andEX("u.unionId","=",pageForm.getUnionId());
cnd.andEX("u.unitId","=",pageForm.getUnitId());
cnd.andEX("info.clubId","=",pageForm.getClubId());
sql.setCondition(cnd);
return listPageVO(pageForm, sql, ClubUserJoinPageVo.class);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void clubApproval(BpmTaskApprovalParam approvalParam) {
// approvalParam.getBpmTaskApprovalTypeEnum();
// Map<String, Object> variables = BeanUtil.beanToMap(approvalParam);
// List<String> assignments = new ArrayList<>();
// if(approvalParam.getBpmTaskApprovalTypeEnum().equals(BpmTaskApprovalTypeEnum.PASS)){
// String approvalLoginName = commonService.findUserLoginNameByRoleCode(RoleConstant.SCHOOL_UNION_CLUB_ADMIN);
// assignments.add(approvalLoginName);
// }
// bpmService.completeTask(approvalParam.getProcessInstanceTaskId(), approvalParam.getBpmTaskApprovalTypeEnum(), variables, assignments);
Map<String, Object> variables = BeanUtil.beanToMap(approvalParam);
List<String> assignments = new ArrayList<>();
bpmService.completeTask(approvalParam.getProcessInstanceTaskId(), approvalParam.getBpmTaskApprovalTypeEnum(), variables, assignments);
if (approvalParam.getBpmTaskApprovalTypeEnum() == BpmTaskApprovalTypeEnum.PASS) {
String processInstanceBusinessId = approvalParam.getProcessInstanceBusinessId();
ClubUserApply clubUserApply = dao().fetch(ClubUserApply.class, processInstanceBusinessId);
if (clubUserApply != null) {
ClubUser existUser = dao().fetch(ClubUser.class, Cnd.where("userId", "=", clubUserApply.getUserId()).and("clubId", "=", clubUserApply.getClubId()));
if (existUser == null) {
ClubUser clubUser = BeanUtil.copyProperties(clubUserApply, ClubUser.class);
dao().insert(clubUser);
}
}
}
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void revokeClubApproval(String taskId) {
bpmService.revokeTask(taskId);
BpmProcessTask task = dao().fetch(BpmProcessTask.class, taskId);
String processInstanceId = task.getProcessInstanceId();
BpmProcessInstance processInstance = dao().fetch(BpmProcessInstance.class, processInstanceId);
String businessId = processInstance.getProcessInstanceBusinessId();
ClubUserApply clubUserApply = dao().fetch(ClubUserApply.class, businessId);
dao().clear(ClubUser.class, Cnd.where(ClubUser::getUserId, "=", clubUserApply.getUserId())
.and(ClubUser::getClubId, "=", clubUserApply.getClubId()));
}
} }
@@ -98,6 +98,8 @@ public class EvaluateActivity extends BaseModel implements Serializable, SysHome
sysHomeActivity.setAllowUserGroupId(this.getActivityGroupId()); sysHomeActivity.setAllowUserGroupId(this.getActivityGroupId());
sysHomeActivity.setEnable(this.getEnable()); sysHomeActivity.setEnable(this.getEnable());
sysHomeActivity.setClassPath(this.getClass().getPackageName() + this.getClass().getName()); sysHomeActivity.setClassPath(this.getClass().getPackageName() + this.getClass().getName());
sysHomeActivity.setCreatedAt(this.getCreatedAt());
sysHomeActivity.setUpdatedAt(this.getUpdatedAt());
return sysHomeActivity; return sysHomeActivity;
} }
} }
@@ -0,0 +1,410 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.learning.models.LearningCourse;
import com.budwk.app.zhgh.learning.models.LearningCourseOutline;
import com.budwk.app.zhgh.learning.models.LearningCourseType;
import com.budwk.app.zhgh.learning.models.LearningOutlineResource;
import com.budwk.app.zhgh.learning.models.LearningStudyRule;
import com.budwk.app.zhgh.learning.service.LearningResourceProgressService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.pager.Pager;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@IocBean
@Ok("json:full")
@Api("学习教育平台-章节内容管理")
@At("/platform/learning/chapter/content")
public class LearningChapterContentController {
@Inject
private Dao dao;
@Inject
private LearningResourceProgressService resourceProgressService;
@At("")
@Ok("beetl:/platform/zhgh/Learning/chapterContent/index.html")
@SaCheckPermission("learning.chapter.content")
public void index() {
}
@At
@ApiOperation("课程列表")
@SaCheckPermission("learning.chapter.content")
public Result courses(String courseId, String courseName, String courseTypeId, String lecturerName, String status) {
Cnd cnd = Cnd.NEW();
cnd.andEX("id", "=", courseId);
cnd.and(Cnd.likeEX("courseName", courseName));
cnd.andEX("courseTypeId", "=", courseTypeId);
cnd.and(Cnd.likeEX("lecturerName", lecturerName));
cnd.andEX("status", "=", status);
cnd.desc("createdAt");
return Result.success(dao.query(LearningCourse.class, cnd, dao.createPager(1, 50)));
}
@At
@ApiOperation("课程类型")
@SaCheckPermission("learning.chapter.content")
public Result courseTypes() {
return Result.success(dao.query(LearningCourseType.class, Cnd.where("enabled", "=", true).asc("sortNum").asc("createdAt")));
}
@At
@ApiOperation("课程大纲树")
@SaCheckPermission("learning.chapter.content")
public Result tree(String courseId) {
if (StrUtil.isBlank(courseId)) {
return Result.success(List.of());
}
List<LearningCourseOutline> outlines = dao.query(LearningCourseOutline.class, Cnd.where("courseId", "=", courseId).asc("sortOrder").asc("createdAt"));
Map<String, List<NutMap>> childrenMap = new HashMap<>();
List<NutMap> roots = new ArrayList<>();
for (LearningCourseOutline outline : outlines) {
NutMap map = NutMap.NEW()
.addv("id", outline.getId())
.addv("courseId", outline.getCourseId())
.addv("courseName", outline.getCourseName())
.addv("parentId", outline.getParentId())
.addv("nodeType", outline.getNodeType())
.addv("title", outline.getTitle())
.addv("subtitle", outline.getSubtitle())
.addv("description", outline.getDescription())
.addv("sortOrder", outline.getSortOrder())
.addv("required", outline.getRequired())
.addv("status", outline.getStatus())
.addv("children", new ArrayList<>());
String parentId = StrUtil.blankToDefault(outline.getParentId(), "");
childrenMap.computeIfAbsent(parentId, k -> new ArrayList<>()).add(map);
}
roots.addAll(childrenMap.getOrDefault("", new ArrayList<>()));
appendChildren(roots, childrenMap);
return Result.success(roots);
}
private void appendChildren(List<NutMap> nodes, Map<String, List<NutMap>> childrenMap) {
for (NutMap node : nodes) {
List<NutMap> children = childrenMap.getOrDefault(node.getString("id"), new ArrayList<>());
node.put("children", children);
appendChildren(children, childrenMap);
}
}
@At
@ApiOperation("新增/编辑大纲节点")
@SaCheckPermission("learning.chapter.content")
@SLog(tag = "保存大纲节点", msg = "节点标题:${args[0].title}")
public Result saveNode(LearningCourseOutline outline) {
Result checkResult = checkNode(outline);
if (checkResult != null) {
return checkResult;
}
LearningCourse course = dao.fetch(LearningCourse.class, outline.getCourseId());
outline.setCourseName(course == null ? outline.getCourseName() : course.getCourseName());
if ("chapter".equals(outline.getNodeType())) {
outline.setParentId("");
}
if (outline.getRequired() == null) {
outline.setRequired(false);
}
if (StrUtil.isBlank(outline.getStatus())) {
outline.setStatus("enabled");
}
if (outline.getSortOrder() == null) {
outline.setSortOrder(nextNodeSort(outline.getCourseId(), outline.getParentId()));
}
if (StrUtil.isBlank(outline.getId())) {
dao.insert(outline);
} else {
dao.updateIgnoreNull(outline);
}
ensureRule(outline.getCourseId(), "outline", outline.getId(), outline.getRequired(), outline.getStatus());
resourceProgressService.refreshOutline(outline.getId());
return Result.success();
}
@At
@ApiOperation("删除大纲节点")
@SaCheckPermission("learning.chapter.content")
@SLog(tag = "删除大纲节点", msg = "节点ID:${args[0]}")
public Result deleteNode(String id) {
if (StrUtil.isBlank(id)) {
return Result.error("请选择要删除的数据");
}
int childCount = dao.count(LearningCourseOutline.class, Cnd.where("parentId", "=", id));
int resourceCount = dao.count(LearningOutlineResource.class, Cnd.where("outlineId", "=", id));
if (childCount > 0 || resourceCount > 0) {
return Result.error("该节点下存在子节点或学习资料,请先删除后再操作");
}
dao.clear(LearningStudyRule.class, Cnd.where("targetId", "=", id).and("targetType", "=", "outline"));
dao.clear(LearningCourseOutline.class, Cnd.where("id", "=", id));
return Result.success();
}
@At
@ApiOperation("启用/禁用大纲节点")
@SaCheckPermission("learning.chapter.content")
public Result toggleNode(String id, String status) {
if (StrUtil.isBlank(id)) {
return Result.error("请选择要操作的数据");
}
dao.update(LearningCourseOutline.class, Chain.make("status", status), Cnd.where("id", "=", id));
dao.update(LearningStudyRule.class, Chain.make("status", status), Cnd.where("targetId", "=", id).and("targetType", "=", "outline"));
resourceProgressService.refreshOutline(id);
return Result.success();
}
@At
@ApiOperation("移动大纲节点")
@SaCheckPermission("learning.chapter.content")
public Result moveNode(String id, String direction) {
LearningCourseOutline current = dao.fetch(LearningCourseOutline.class, id);
if (current == null) {
return Result.error("节点不存在");
}
Cnd cnd = Cnd.where("courseId", "=", current.getCourseId()).and("parentId", "=", StrUtil.blankToDefault(current.getParentId(), ""));
if ("up".equals(direction)) {
cnd.and("sortOrder", "<", current.getSortOrder()).desc("sortOrder");
} else {
cnd.and("sortOrder", ">", current.getSortOrder()).asc("sortOrder");
}
LearningCourseOutline target = dao.fetch(LearningCourseOutline.class, cnd);
if (target == null) {
return Result.success();
}
Integer currentSort = current.getSortOrder();
dao.update(LearningCourseOutline.class, Chain.make("sortOrder", target.getSortOrder()), Cnd.where("id", "=", current.getId()));
dao.update(LearningCourseOutline.class, Chain.make("sortOrder", currentSort), Cnd.where("id", "=", target.getId()));
return Result.success();
}
@At
@ApiOperation("学习资料分页")
@SaCheckPermission("learning.chapter.content")
public Result resourcePage(PageForm pageForm, String outlineId) {
if (StrUtil.isBlank(outlineId)) {
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), 0, List.of()));
}
Cnd cnd = Cnd.where("outlineId", "=", outlineId);
String orderColumn = getResourceOrderColumn(pageForm.getPageOrderName());
if (StrUtil.isBlank(orderColumn)) {
cnd.asc("sortOrder").asc("createdAt");
} else if ("descending".equals(pageForm.getPageOrderBy())) {
cnd.desc(orderColumn);
} else {
cnd.asc(orderColumn);
}
int count = dao.count(LearningOutlineResource.class, Cnd.where("outlineId", "=", outlineId));
Pager pager = dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize());
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), count, dao.query(LearningOutlineResource.class, cnd, pager)));
}
@At
@ApiOperation("新增/编辑学习资料")
@SaCheckPermission("learning.chapter.content")
@SLog(tag = "保存学习资料", msg = "资料标题:${args[0].resourceTitle}")
public Result saveResource(LearningOutlineResource resource) {
Result checkResult = checkResource(resource);
if (checkResult != null) {
return checkResult;
}
if (resource.getRequired() == null) {
resource.setRequired(false);
}
if (Boolean.TRUE.equals(resource.getRequired()) || resource.getAllowPreview() == null) {
resource.setAllowPreview(true);
}
if (resource.getAllowDownload() == null) {
resource.setAllowDownload(false);
}
if (StrUtil.isBlank(resource.getStatus())) {
resource.setStatus("enabled");
}
if (resource.getSortOrder() == null) {
resource.setSortOrder(nextResourceSort(resource.getOutlineId()));
}
if (StrUtil.isBlank(resource.getId())) {
dao.insert(resource);
} else {
dao.updateIgnoreNull(resource);
}
ensureRule(resource.getCourseId(), "resource", resource.getId(), resource.getRequired(), resource.getStatus());
resourceProgressService.refreshOutline(resource.getOutlineId());
return Result.success();
}
@At
@ApiOperation("删除学习资料")
@SaCheckPermission("learning.chapter.content")
@SLog(tag = "删除学习资料", msg = "资料ID:${args[0]}")
public Result deleteResource(String id) {
if (StrUtil.isBlank(id)) {
return Result.error("请选择要删除的数据");
}
LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, id);
dao.clear(LearningStudyRule.class, Cnd.where("targetId", "=", id).and("targetType", "=", "resource"));
dao.clear(LearningOutlineResource.class, Cnd.where("id", "=", id));
if (resource != null) resourceProgressService.refreshOutline(resource.getOutlineId());
return Result.success();
}
@At
@ApiOperation("查询学习规则")
@SaCheckPermission("learning.chapter.content")
public Result getRule(String courseId, String targetType, String targetId) {
LearningStudyRule rule = dao.fetch(LearningStudyRule.class, Cnd.where("courseId", "=", courseId).and("targetType", "=", targetType).and("targetId", "=", targetId));
if (rule == null) {
rule = defaultRule(courseId, targetType, targetId, false, "enabled");
}
if ("manual".equals(rule.getCompletionRule())) rule.setCompletionRule("all_required_resource");
return Result.success(rule);
}
@At
@ApiOperation("保存学习规则")
@SaCheckPermission("learning.chapter.content")
public Result saveRule(LearningStudyRule rule) {
if (StrUtil.isBlank(rule.getCourseId()) || StrUtil.isBlank(rule.getTargetType()) || StrUtil.isBlank(rule.getTargetId())) {
return Result.error("规则对象不能为空");
}
if (StrUtil.isBlank(rule.getStatus())) {
rule.setStatus("enabled");
}
if (!List.of("all_required_resource", "resource_progress", "min_study_time").contains(
rule.getCompletionRule() == null ? "" : rule.getCompletionRule())) {
return Result.error("??????????");
}
if ("min_study_time".equals(rule.getCompletionRule())
&& (rule.getMinStudySeconds() == null || rule.getMinStudySeconds() <= 0)) {
return Result.error("??????????0");
}
if ("resource_progress".equals(rule.getCompletionRule())
&& (rule.getCompletePercent() == null || rule.getCompletePercent() <= 0 || rule.getCompletePercent() > 100)) {
return Result.error("????????0????100");
}
LearningStudyRule old = dao.fetch(LearningStudyRule.class, Cnd.where("courseId", "=", rule.getCourseId()).and("targetType", "=", rule.getTargetType()).and("targetId", "=", rule.getTargetId()));
if (old == null) {
dao.insert(rule);
} else {
rule.setId(old.getId());
dao.updateIgnoreNull(rule);
}
if ("outline".equals(rule.getTargetType())) {
dao.update(LearningCourseOutline.class, Chain.make("required", rule.getRequired()).add("status", rule.getStatus()), Cnd.where("id", "=", rule.getTargetId()));
resourceProgressService.refreshOutline(rule.getTargetId());
}
if ("resource".equals(rule.getTargetType())) {
Chain resourceChain = Chain.make("required", rule.getRequired()).add("status", rule.getStatus());
if (Boolean.TRUE.equals(rule.getRequired())) resourceChain.add("allowPreview", true);
dao.update(LearningOutlineResource.class, resourceChain, Cnd.where("id", "=", rule.getTargetId()));
LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, rule.getTargetId());
if (resource != null) resourceProgressService.refreshOutline(resource.getOutlineId());
}
return Result.success();
}
private Result checkNode(LearningCourseOutline outline) {
if (outline == null || StrUtil.isBlank(outline.getCourseId())) {
return Result.error("请选择课程");
}
if (StrUtil.isBlank(outline.getNodeType())) {
return Result.error("请选择节点类型");
}
if (StrUtil.isBlank(outline.getTitle())) {
return Result.error("标题不能为空");
}
if ("section".equals(outline.getNodeType()) && StrUtil.isBlank(outline.getParentId())) {
return Result.error("新增节需要选择所属章");
}
return null;
}
private Result checkResource(LearningOutlineResource resource) {
if (resource == null || StrUtil.isBlank(resource.getCourseId()) || StrUtil.isBlank(resource.getOutlineId())) {
return Result.error("请选择章/节节点");
}
if (StrUtil.isBlank(resource.getResourceTitle())) {
return Result.error("资料标题不能为空");
}
if (StrUtil.isBlank(resource.getResourceType())) {
return Result.error("请选择资料类型");
}
if (StrUtil.isBlank(resource.getFileData())) {
return Result.error("请上传附件");
}
return null;
}
private Integer nextNodeSort(String courseId, String parentId) {
LearningCourseOutline outline = dao.fetch(LearningCourseOutline.class, Cnd.where("courseId", "=", courseId).and("parentId", "=", StrUtil.blankToDefault(parentId, "")).desc("sortOrder"));
return outline == null || outline.getSortOrder() == null ? 1 : outline.getSortOrder() + 1;
}
private Integer nextResourceSort(String outlineId) {
LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, Cnd.where("outlineId", "=", outlineId).desc("sortOrder"));
return resource == null || resource.getSortOrder() == null ? 1 : resource.getSortOrder() + 1;
}
private void ensureRule(String courseId, String targetType, String targetId, Boolean required, String status) {
LearningStudyRule rule = dao.fetch(LearningStudyRule.class, Cnd.where("courseId", "=", courseId).and("targetType", "=", targetType).and("targetId", "=", targetId));
if (rule == null) {
dao.insert(defaultRule(courseId, targetType, targetId, required, status));
} else {
dao.update(LearningStudyRule.class,
Chain.make("required", required != null && required).add("status", StrUtil.blankToDefault(status, "enabled")),
Cnd.where("id", "=", rule.getId()));
}
}
private LearningStudyRule defaultRule(String courseId, String targetType, String targetId, Boolean required, String status) {
LearningStudyRule rule = new LearningStudyRule();
rule.setCourseId(courseId);
rule.setTargetType(targetType);
rule.setTargetId(targetId);
rule.setRequired(required != null && required);
rule.setStudyMode("mixed");
rule.setCompletionRule("all_required_resource");
rule.setCompletePercent(90);
rule.setMinStudySeconds(0);
rule.setUnlockRule("free");
rule.setAllowSkip(false);
rule.setAllowDrag(true);
rule.setPauseCountTime(false);
rule.setHiddenCountTime(false);
rule.setInactiveCountTime(false);
rule.setStatus(StrUtil.blankToDefault(status, "enabled"));
return rule;
}
private String getResourceOrderColumn(String prop) {
Map<String, String> columns = new HashMap<>();
columns.put("resourceTitle", "resourceTitle");
columns.put("resourceType", "resourceType");
columns.put("fileExt", "fileExt");
columns.put("durationSeconds", "durationSeconds");
columns.put("sortOrder", "sortOrder");
columns.put("required", "required");
columns.put("allowPreview", "allowPreview");
columns.put("allowDownload", "allowDownload");
columns.put("status", "status");
return columns.get(prop);
}
}
@@ -0,0 +1,380 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.CharsetUtil;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.OfficePlusUtil;
import com.budwk.app.sys.enums.SysFileEngineTypeEnum;
import com.budwk.app.sys.models.Sys_file;
import com.budwk.app.sys.models.Sys_dict;
import com.budwk.app.sys.services.SysDictService;
import com.budwk.app.sys.utils.SysFileMinIoUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.learning.models.LearningCourseOutline;
import com.budwk.app.zhgh.learning.models.LearningCourseType;
import com.budwk.app.zhgh.learning.models.LearningOutlineResource;
import com.budwk.app.zhgh.learning.models.LearningStudyRecord;
import com.budwk.app.zhgh.learning.models.LearningStudyRule;
import com.budwk.app.zhgh.learning.service.LearningFileAccessService;
import com.budwk.app.zhgh.learning.service.LearningResourceProgressService;
import com.google.common.net.HttpHeaders;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json;
import org.nutz.lang.Strings;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.util.List;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
@IocBean
@Ok("json:full")
@Api("学习教育平台-课程展示")
@At("/platform/learning/course/display")
public class LearningCourseDisplayController {
@Inject
private Dao dao;
@Inject
private SysDictService sysDictService;
@Inject
private LearningFileAccessService learningFileAccessService;
@Inject
private LearningResourceProgressService resourceProgressService;
@At("")
@Ok("beetl:/platform/zhgh/Learning/courseDisplay/indexV2.html")
@SaCheckPermission("learning.course.display")
public void index() {
}
@At("/detail")
@Ok("beetl:/platform/zhgh/Learning/courseDisplay/detail.html")
@SaCheckPermission("learning.course.display")
public void detail() {
}
@At("/study")
@Ok("beetl:/platform/zhgh/Learning/courseDisplay/studyV2.html")
@SaCheckPermission("learning.course.display")
public void study() {
}
@At
@ApiOperation("课程展示列表")
@SaCheckPermission("learning.course.display")
/**
* 查询课程展示列表。
* pageForm 传分页页码和每页条数;keyword、courseTypeId、recommendFlag 分别用于关键词、分类和推荐标识筛选;
* sortType 支持 default(综合排序)和 view_count(观看次数倒序)。返回值为 Pagination,list 中包含课程基础字段、课节数、总时长、观看次数和当前用户学习进度。
*/
public Result pageData(PageForm pageForm, String keyword, String courseTypeId, String recommendFlag, String sortType, String excludeCourseId) {
Cnd cnd = Cnd.where("c.status", "=", "published");
if (StrUtil.isNotBlank(keyword)) {
cnd.and(Cnd.exps("c.courseName", "like", "%" + keyword + "%")
.or("c.courseIntro", "like", "%" + keyword + "%"));
}
cnd.andEX("c.courseTypeId", "=", courseTypeId);
cnd.andEX("c.id", "!=", excludeCourseId);
if (StrUtil.isNotBlank(recommendFlag)) {
cnd.and(Cnd.exps("c.recommendFlags", "=", recommendFlag)
.or("c.recommendFlags", "like", recommendFlag + ",%")
.or("c.recommendFlags", "like", "%," + recommendFlag)
.or("c.recommendFlags", "like", "%," + recommendFlag + ",%"));
}
Sql countSql = Sqls.create("""
SELECT COUNT(1)
FROM learning_course c
LEFT JOIN learning_course_type t ON t.id = c.courseTypeId
$condition
""");
countSql.setCondition(cnd);
countSql.setCallback(Sqls.callback.integer());
dao.execute(countSql);
int count = countSql.getInt();
Sql listSql = Sqls.create("""
SELECT c.id, c.courseName, c.courseIntro, c.startTime, c.endTime, c.openType, c.cover,
c.recommendFlags, c.courseTypeId, c.lecturerName, c.sortNum,
COALESCE(c.viewCount, 0) AS viewCount, t.typeName AS courseTypeName,
COALESCE((SELECT ROUND(AVG(IFNULL(sr.progressPercent, 0)))
FROM learning_study_record sr
WHERE sr.courseId = c.id AND sr.userId = @userId
AND EXISTS (SELECT 1 FROM learning_outline_resource pr WHERE pr.outlineId = sr.outlineId
AND pr.status = 'enabled' AND COALESCE(pr.allowPreview, 1) = 1)), 0) AS progressPercent,
(SELECT COUNT(1) FROM learning_outline_resource r
WHERE r.courseId = c.id AND r.status = 'enabled' AND COALESCE(r.allowPreview, 1) = 1) AS resourceCount,
COALESCE((SELECT SUM(r.durationSeconds) FROM learning_outline_resource r
WHERE r.courseId = c.id AND r.status = 'enabled' AND COALESCE(r.allowPreview, 1) = 1), 0) AS totalDurationSeconds
FROM learning_course c
LEFT JOIN learning_course_type t ON t.id = c.courseTypeId
$condition
ORDER BY $orderBy
""");
listSql.setCondition(cnd);
listSql.params().set("userId", SecurityUtil.getUserId());
// 排序字段由固定选项映射,禁止将前端参数直接拼接进SQL。
listSql.setVar("orderBy", "random".equals(sortType) ? "RAND()" : "view_count".equals(sortType)
? "COALESCE(c.viewCount, 0) DESC, c.sortNum ASC, c.createdAt DESC"
: "c.sortNum ASC, c.createdAt DESC");
listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
dao.execute(listSql);
List<NutMap> rows = listSql.getList(NutMap.class);
rows.forEach(row -> {
row.putAll(resourceProgressService.courseState(row.getString("id"), SecurityUtil.getUserId()));
Object startTime = row.get("startTime");
Object endTime = row.get("endTime");
row.put("startTimeText", startTime == null ? "" : DateUtil.formatDateTime(DateUtil.date((java.util.Date) startTime)));
row.put("endTimeText", endTime == null ? "" : DateUtil.formatDateTime(DateUtil.date((java.util.Date) endTime)));
});
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), count, rows));
}
@At
@ApiOperation("课程学习基础信息")
@SaCheckPermission("learning.course.display")
/**
* 查询学习页课程信息。
* id 传课程主键;返回 NutMap,包含课程介绍、讲师、分类、课节数、总时长和后台配置的观看次数。
*/
public Result courseInfo(String id) {
if (StrUtil.isBlank(id)) {
return Result.error("请选择课程");
}
// 学习页复用课程配置和资源统计数据,观看次数由课程管理后台配置,不在访问时自动累加。
Sql sql = Sqls.create("""
SELECT c.id, c.courseName, c.courseIntro, c.suitablePeople, c.learningGoal, c.coursePeriod,
c.courseTags, c.startTime, c.endTime, c.openType, c.cover, c.recommendFlags,
c.lecturerName, c.lecturerInfo, COALESCE(c.viewCount, 0) AS viewCount,
t.typeName AS courseTypeName,
(SELECT COUNT(1) FROM learning_outline_resource r
WHERE r.courseId = c.id AND r.status = 'enabled' AND COALESCE(r.allowPreview, 1) = 1) AS resourceCount,
COALESCE((SELECT SUM(r.durationSeconds) FROM learning_outline_resource r
WHERE r.courseId = c.id AND r.status = 'enabled' AND COALESCE(r.allowPreview, 1) = 1), 0) AS totalDurationSeconds
FROM learning_course c
LEFT JOIN learning_course_type t ON t.id = c.courseTypeId
WHERE c.id = @id
""");
sql.params().set("id", id);
sql.setCallback(Sqls.callback.map());
dao.execute(sql);
NutMap course = sql.getObject(NutMap.class);
if (course == null) {
return Result.error("课程不存在");
}
Object startTime = course.get("startTime");
Object endTime = course.get("endTime");
course.put("startTimeText", startTime == null ? "" : DateUtil.formatDateTime(DateUtil.date((java.util.Date) startTime)));
course.put("endTimeText", endTime == null ? "" : DateUtil.formatDateTime(DateUtil.date((java.util.Date) endTime)));
course.putAll(resourceProgressService.courseState(id, SecurityUtil.getUserId()));
return Result.success(course);
}
@At
@ApiOperation("课程学习安排")
@SaCheckPermission("learning.course.display")
public Result studyTree(String courseId) {
if (StrUtil.isBlank(courseId)) {
return Result.success(List.of());
}
List<LearningCourseOutline> outlines = dao.query(LearningCourseOutline.class, Cnd.where("courseId", "=", courseId).and("status", "=", "enabled").asc("sortOrder").asc("createdAt"));
List<LearningOutlineResource> resources = dao.query(LearningOutlineResource.class, Cnd.where("courseId", "=", courseId).and("status", "=", "enabled").asc("sortOrder").asc("createdAt"));
List<LearningStudyRecord> records = dao.query(LearningStudyRecord.class, Cnd.where("courseId", "=", courseId).and("userId", "=", SecurityUtil.getUserId()));
List<LearningStudyRule> outlineRules = dao.query(LearningStudyRule.class, Cnd.where("courseId", "=", courseId).and("targetType", "=", "outline").and("status", "=", "enabled"));
Map<String, List<NutMap>> childrenMap = new HashMap<>();
List<NutMap> roots = new ArrayList<>();
Map<String, LearningStudyRecord> recordMap = new HashMap<>();
Map<String, LearningStudyRule> outlineRuleMap = new HashMap<>();
for (LearningStudyRecord record : records) {
recordMap.put(record.getOutlineId(), record);
}
for (LearningStudyRule outlineRule : outlineRules) {
outlineRuleMap.put(outlineRule.getTargetId(), outlineRule);
}
Map<String, NutMap> outlineNodeMap = new HashMap<>();
for (LearningCourseOutline outline : outlines) {
List<NutMap> children = new ArrayList<>();
NutMap node = NutMap.NEW()
.addv("id", outline.getId())
.addv("type", "outline")
.addv("nodeType", outline.getNodeType())
.addv("title", outline.getTitle())
.addv("description", outline.getDescription())
.addv("required", outline.getRequired())
.addv("progressPercent", recordMap.containsKey(outline.getId()) ? recordMap.get(outline.getId()).getProgressPercent() : 0)
.addv("studySeconds", recordMap.containsKey(outline.getId()) ? recordMap.get(outline.getId()).getStudySeconds() : 0)
.addv("lastPositionSeconds", recordMap.containsKey(outline.getId()) ? value(recordMap.get(outline.getId()).getLastPositionSeconds()) : 0)
.addv("completeStatus", recordMap.containsKey(outline.getId()) ? recordMap.get(outline.getId()).getCompleteStatus() : "not_started")
.addv("children", children);
node.putAll(resourceProgressService.outlineState(recordMap.get(outline.getId()), outline));
outlineNodeMap.put(outline.getId(), node);
childrenMap.put(outline.getId(), children);
}
for (LearningCourseOutline outline : outlines) {
NutMap node = outlineNodeMap.get(outline.getId());
if (StrUtil.isBlank(outline.getParentId())) {
roots.add(node);
} else {
childrenMap.computeIfAbsent(outline.getParentId(), key -> new ArrayList<>()).add(node);
}
}
for (LearningOutlineResource resource : resources) {
// 章节学习规则控制其下媒体播放行为;未配置规则时保持原有的可拖动行为。
LearningStudyRule outlineRule = outlineRuleMap.get(resource.getOutlineId());
boolean allowDrag = outlineRule == null || !Boolean.FALSE.equals(outlineRule.getAllowDrag());
NutMap resourceState = resourceProgressService.state(recordMap.get(resource.getOutlineId()), resource);
NutMap node = NutMap.NEW()
.addv("id", resource.getId())
.addv("type", "resource")
.addv("outlineId", resource.getOutlineId())
.addv("title", resource.getResourceTitle())
.addv("resourceType", resource.getResourceType())
.addv("studied", resourceState.getBoolean("studied", false))
.addv("fileExt", resource.getFileExt())
.addv("fileData", !Boolean.FALSE.equals(resource.getAllowPreview()) || Boolean.TRUE.equals(resource.getAllowDownload()) ? resource.getFileData() : null)
.addv("allowPreview", !Boolean.FALSE.equals(resource.getAllowPreview()))
.addv("allowDownload", Boolean.TRUE.equals(resource.getAllowDownload()))
.addv("durationSeconds", resource.getDurationSeconds())
.addv("required", resource.getRequired())
.addv("progressPercent", resourceState.getInt("progressPercent", 0))
.addv("studySeconds", resourceState.getInt("studySeconds", 0))
.addv("lastPositionSeconds", resourceState.getInt("lastPositionSeconds", 0))
.addv("maxPositionSeconds", resourceState.getInt("maxPositionSeconds", 0))
.addv("allowDrag", allowDrag)
.addv("completeStatus", resourceState.getString("completeStatus", "not_started"));
childrenMap.computeIfAbsent(resource.getOutlineId(), key -> new ArrayList<>()).add(node);
}
return Result.success(roots);
}
@At
@Ok("void")
@ApiOperation("PDF内联预览")
@SaCheckPermission("learning.course.display")
public void pdfPreview(String id, HttpServletResponse response) throws IOException {
if (!learningFileAccessService.check(id, false, response)) return;
if (StrUtil.isBlank(id)) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "文件ID不能为空");
return;
}
Sys_file file = dao.fetch(Sys_file.class, id);
if (file == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "文件不存在");
return;
}
byte[] bytes;
if (SysFileEngineTypeEnum.MINIO.getValue().equals(file.getEngine())) {
bytes = SysFileMinIoUtil.getFileBytes(file.getBucket(), file.getStoragePath());
} else {
File localFile = FileUtil.file(file.getStoragePath());
if (!FileUtil.exist(localFile)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "文件不存在");
return;
}
bytes = IoUtil.readBytes(FileUtil.getInputStream(localFile));
}
if (!"pdf".equalsIgnoreCase(file.getSuffix())) {
File sourceFile = File.createTempFile("learning_preview_origin", "." + file.getSuffix());
File pdfFile = File.createTempFile("learning_preview", ".pdf");
try {
Files.write(sourceFile.toPath(), bytes, StandardOpenOption.WRITE);
OfficePlusUtil.convert(sourceFile.getPath(), pdfFile.getPath());
if (!FileUtil.exist(pdfFile) || pdfFile.length() == 0) {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "文件转换PDF失败");
return;
}
bytes = IoUtil.readBytes(FileUtil.getInputStream(pdfFile));
} finally {
FileUtil.del(sourceFile);
FileUtil.del(pdfFile);
}
}
String fileName = StrUtil.blankToDefault(FileUtil.mainName(file.getName()), "preview") + ".pdf";
String encodedName = URLEncoder.encode(fileName, CharsetUtil.UTF_8).replace("+", "%20");
response.setContentType("application/pdf");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"preview.pdf\"; filename*=UTF-8''" + encodedName);
response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(bytes.length));
try (OutputStream out = response.getOutputStream()) {
out.write(bytes);
}
}
@At
@ApiOperation("启用课程类型")
@SaCheckPermission("learning.course.display")
public Result courseTypes() {
return Result.success(dao.query(LearningCourseType.class, Cnd.where("enabled", "=", true).asc("sortNum").asc("createdAt")));
}
@At
@ApiOperation("推荐标识")
@SaCheckPermission("learning.course.display")
public Result recommendOptions() {
Sys_dict root = sysDictService.fetch(Cnd.where("code", "=", "学习教育").or("name", "=", "学习教育"));
if (root == null) {
return Result.success(List.of());
}
List<Sys_dict> firstLevel = sysDictService.query(Cnd.where("parentId", "=", Strings.sNull(root.getId())).and("disabled", "=", false).asc("location"));
Sys_dict group = firstLevel.stream()
.filter(dict -> "推荐标识".equals(dict.getName()) || "推荐标识".equals(dict.getCode()))
.findFirst()
.orElse(null);
if (group == null) {
return Result.success(firstLevel);
}
List<Sys_dict> children = sysDictService.query(Cnd.where("parentId", "=", Strings.sNull(group.getId())).and("disabled", "=", false).asc("location"));
return Result.success(children.isEmpty() ? firstLevel : children);
}
private int value(Integer value) {
return value == null ? 0 : value;
}
/**
* 读取指定资源的最高已观看秒数。该值用于两端限制快进,最近播放位置仍单独用于续播。
*/
private int resourceMaxPosition(LearningStudyRecord record, String resourceId) {
if (record == null || StrUtil.isBlank(resourceId)) {
return 0;
}
if (StrUtil.isBlank(record.getResourceMaxPositionData())) {
return value(record.getLastPositionSeconds());
}
try {
NutMap positions = Json.fromJson(NutMap.class, record.getResourceMaxPositionData());
return Math.max(0, positions.getInt(resourceId, 0));
} catch (Exception ignored) {
// 历史记录未保存资源级位置时,从零开始建立最高观看位置。
return 0;
}
}
}
@@ -0,0 +1,247 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.sys.models.Sys_dict;
import com.budwk.app.sys.services.SysDictService;
import com.budwk.app.zhgh.learning.models.LearningCourse;
import com.budwk.app.zhgh.learning.models.LearningCourseOutline;
import com.budwk.app.zhgh.learning.models.LearningCourseType;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Strings;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@IocBean
@Ok("json:full")
@Api("学习教育平台-课程管理")
@At("/platform/learning/course/manage")
public class LearningCourseManageController {
@Inject
private Dao dao;
@Inject
private SysDictService sysDictService;
@At("")
@Ok("beetl:/platform/zhgh/Learning/courseManage/index.html")
@SaCheckPermission("learning.course.manage")
public void index() {
}
@At
@ApiOperation("课程列表")
@SaCheckPermission("learning.course.manage")
public Result pageData(PageForm pageForm,
String courseName,
String courseTypeId,
String startTime,
String endTime,
String lecturerName,
String status,
String recommendFlag) {
Cnd cnd = Cnd.NEW();
cnd.and(Cnd.likeEX("c.courseName", courseName));
cnd.andEX("c.courseTypeId", "=", courseTypeId);
cnd.and(Cnd.likeEX("c.lecturerName", lecturerName));
cnd.andEX("c.status", "=", status);
if (StrUtil.isNotBlank(recommendFlag)) {
cnd.and("c.recommendFlags", "like", "%" + recommendFlag + "%");
}
if (StrUtil.isNotBlank(startTime)) {
cnd.and("c.startTime", ">=", DateUtil.parse(startTime));
}
if (StrUtil.isNotBlank(endTime)) {
cnd.and("c.endTime", "<=", DateUtil.parse(endTime));
}
String orderColumn = getOrderColumn(pageForm.getPageOrderName());
String orderBy = "descending".equals(pageForm.getPageOrderBy()) ? "desc" : "asc";
if (StrUtil.isBlank(orderColumn)) {
orderColumn = "c.sortNum";
orderBy = "asc";
}
Sql countSql = Sqls.create("""
SELECT COUNT(1)
FROM learning_course c
LEFT JOIN learning_course_type t ON t.id = c.courseTypeId
$condition
""");
countSql.setCondition(cnd);
countSql.setCallback(Sqls.callback.integer());
dao.execute(countSql);
int count = countSql.getInt();
Sql listSql = Sqls.create("""
SELECT c.*, t.typeName AS courseTypeName
FROM learning_course c
LEFT JOIN learning_course_type t ON t.id = c.courseTypeId
$condition
ORDER BY $orderColumn $orderBy
""");
listSql.setCondition(cnd);
listSql.setVar("orderColumn", orderColumn);
listSql.setVar("orderBy", orderBy);
listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
dao.execute(listSql);
Pagination<NutMap> pagination = new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), count, listSql.getList(NutMap.class));
return Result.success(pagination);
}
@At
@ApiOperation("新增课程")
@SaCheckPermission("learning.course.manage")
@SLog(tag = "新增课程", msg = "课程名称:${args[0].courseName}")
@Aop(TransAop.READ_COMMITTED)
/** course 传课程表单字段,viewCount 为非负整数;返回 Result 表示新增是否成功。 */
public Result doAdd(LearningCourse course) {
Result checkResult = checkCourse(course, null);
if (checkResult != null) {
return checkResult;
}
if (StrUtil.isBlank(course.getStatus())) {
course.setStatus("draft");
}
if (course.getViewCount() == null) {
course.setViewCount(0);
}
dao.insert(course);
return Result.success();
}
@At
@ApiOperation("编辑课程")
@SaCheckPermission("learning.course.manage")
@SLog(tag = "编辑课程", msg = "课程ID:${args[0].id}")
@Aop(TransAop.READ_COMMITTED)
/** course 传课程主键及需要更新的表单字段;返回 Result 表示编辑是否成功。 */
public Result doEdit(LearningCourse course) {
if (StrUtil.isBlank(course.getId())) {
return Result.error("请选择要编辑的数据");
}
Result checkResult = checkCourse(course, course.getId());
if (checkResult != null) {
return checkResult;
}
dao.updateIgnoreNull(course);
return Result.success();
}
@At
@ApiOperation("删除课程")
@SaCheckPermission("learning.course.manage")
@SLog(tag = "删除课程", msg = "课程ID:${args[0]}")
@Aop(TransAop.READ_COMMITTED)
/** id 传课程主键;返回 Result,存在章节内容时返回不可删除提示。 */
public Result doDelete(String id) {
if (StrUtil.isBlank(id)) {
return Result.error("请选择要删除的数据");
}
int outlineCount = dao.count(LearningCourseOutline.class, Cnd.where("courseId", "=", id));
if (outlineCount > 0) {
return Result.error("该课程存在章节内容,请先删除章节内容后再删除课程");
}
dao.clear(LearningCourse.class, Cnd.where("id", "=", id));
return Result.success();
}
@At
@ApiOperation("启用课程类型")
@SaCheckPermission("learning.course.manage")
public Result courseTypes() {
return Result.success(dao.query(LearningCourseType.class, Cnd.where("enabled", "=", true).asc("sortNum").asc("createdAt")));
}
@At
@ApiOperation("学习教育数据字典")
@SaCheckPermission("learning.course.manage")
public Result learningDictOptions(String name) {
Sys_dict root = sysDictService.fetch(Cnd.where("code", "=", "学习教育").or("name", "=", "学习教育"));
if (root == null) {
return Result.success(List.of());
}
List<Sys_dict> firstLevel = sysDictService.query(Cnd.where("parentId", "=", Strings.sNull(root.getId())).and("disabled", "=", false).asc("location"));
if (StrUtil.isBlank(name)) {
return Result.success(firstLevel);
}
Sys_dict group = firstLevel.stream()
.filter(dict -> name.equals(dict.getName()) || name.equals(dict.getCode()))
.findFirst()
.orElse(null);
if (group == null) {
return Result.success(firstLevel);
}
List<Sys_dict> children = sysDictService.query(Cnd.where("parentId", "=", Strings.sNull(group.getId())).and("disabled", "=", false).asc("location"));
return Result.success(children.isEmpty() ? firstLevel : children);
}
private Result checkCourse(LearningCourse course, String excludeId) {
if (course == null || StrUtil.isBlank(course.getCourseName())) {
return Result.error("课程名称不能为空");
}
if (StrUtil.isBlank(course.getCourseTypeId())) {
return Result.error("请选择课程类型");
}
if (StrUtil.isBlank(course.getLecturerName())) {
return Result.error("授课讲师不能为空");
}
if (course.getSortNum() == null) {
return Result.error("排序编码不能为空");
}
if (course.getViewCount() != null && course.getViewCount() < 0) {
return Result.error("观看次数不能小于0");
}
if (!"long_term".equals(course.getOpenType()) && (course.getStartTime() == null || course.getEndTime() == null)) {
return Result.error("请选择开课时间");
}
Cnd cnd = Cnd.where("courseName", "=", course.getCourseName().trim());
if (StrUtil.isNotBlank(excludeId)) {
cnd.and("id", "!=", excludeId);
}
if (dao.count(LearningCourse.class, cnd) > 0) {
return Result.error("课程名称已存在");
}
course.setCourseName(course.getCourseName().trim());
if ("long_term".equals(course.getOpenType())) {
course.setStartTime(null);
course.setEndTime(null);
}
return null;
}
private String getOrderColumn(String prop) {
Map<String, String> columns = new HashMap<>();
columns.put("courseName", "c.courseName");
columns.put("courseTypeName", "t.typeName");
columns.put("lecturerName", "c.lecturerName");
columns.put("startTime", "c.startTime");
columns.put("status", "c.status");
columns.put("recommendFlags", "c.recommendFlags");
columns.put("sortNum", "c.sortNum");
columns.put("viewCount", "c.viewCount");
return columns.get(prop);
}
}
@@ -0,0 +1,110 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.learning.models.LearningCourseType;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.pager.Pager;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
@IocBean
@Ok("json:full")
@Api("学习教育平台-课程类型设置")
@At("/platform/learning/course/type")
public class LearningCourseTypeController {
@Inject
private Dao dao;
@At("")
@Ok("beetl:/platform/zhgh/Learning/courseType/index.html")
@SaCheckPermission("learning.course.type")
public void index() {
}
@At
@ApiOperation("课程类型列表")
@SaCheckPermission("learning.course.type")
public Result pageData(PageForm pageForm, @Param("typeName") String typeName) {
Cnd cnd = Cnd.NEW();
cnd.and(Cnd.likeEX("typeName", typeName));
cnd.asc("sortNum").asc("createdAt");
int count = dao.count(LearningCourseType.class, cnd);
Pager pager = dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize());
Pagination<LearningCourseType> pagination = new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), count, dao.query(LearningCourseType.class, cnd, pager));
return Result.success(pagination);
}
@At
@ApiOperation("新增课程类型")
@SaCheckPermission("learning.course.type")
@SLog(tag = "新增课程类型", msg = "课程类型名称:${args[0].typeName}")
public Result doAdd(LearningCourseType courseType) {
Result checkResult = checkCourseType(courseType, null);
if (checkResult != null) {
return checkResult;
}
if (courseType.getEnabled() == null) {
courseType.setEnabled(true);
}
dao.insert(courseType);
return Result.success();
}
@At
@ApiOperation("编辑课程类型")
@SaCheckPermission("learning.course.type")
@SLog(tag = "编辑课程类型", msg = "课程类型ID:${args[0].id}")
public Result doEdit(LearningCourseType courseType) {
if (StrUtil.isBlank(courseType.getId())) {
return Result.error("请选择要编辑的数据");
}
Result checkResult = checkCourseType(courseType, courseType.getId());
if (checkResult != null) {
return checkResult;
}
dao.updateIgnoreNull(courseType);
return Result.success();
}
@At
@ApiOperation("删除课程类型")
@SaCheckPermission("learning.course.type")
@SLog(tag = "删除课程类型", msg = "课程类型ID:${args[0]}")
public Result doDelete(String id) {
if (StrUtil.isBlank(id)) {
return Result.error("请选择要删除的数据");
}
dao.clear(LearningCourseType.class, Cnd.where("id", "=", id));
return Result.success();
}
private Result checkCourseType(LearningCourseType courseType, String excludeId) {
if (courseType == null || StrUtil.isBlank(courseType.getTypeName())) {
return Result.error("课程类型名称不能为空");
}
if (courseType.getSortNum() == null) {
return Result.error("排序编号不能为空");
}
Cnd cnd = Cnd.where("typeName", "=", courseType.getTypeName().trim());
if (StrUtil.isNotBlank(excludeId)) {
cnd.and("id", "!=", excludeId);
}
if (dao.count(LearningCourseType.class, cnd) > 0) {
return Result.error("课程类型名称已存在");
}
courseType.setTypeName(courseType.getTypeName().trim());
return null;
}
}
@@ -0,0 +1,47 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
@IocBean
@At("/platform/learning")
public class LearningPlatformController {
@At("/activity/manage")
@Ok("beetl:/platform/zhgh/Learning/activityManage/index.html")
@SaCheckPermission("learning.activity.manage")
public void activityManage() {
}
@At("/my/record")
@Ok("beetl:/platform/zhgh/Learning/myRecord/index.html")
@SaCheckPermission("learning.my.record")
public void myRecord() {
}
@At("/my/record/h5")
@Ok("beetl:/platform/zhghh5/learning/myRecord/index.html")
@SaCheckPermission("learning.my.record")
public void myRecordH5() {
}
@At("/statistics")
@Ok("beetl:/platform/zhgh/Learning/statistics/index.html")
@SaCheckPermission("learning.statistics")
public void statistics() {
}
@At("/course/h5")
@Ok("beetl:/platform/zhghh5/learning/course/index.html")
@SaCheckPermission("learning.course.display")
public void courseH5() {
}
@At("/course/h5/study")
@Ok("beetl:/platform/zhghh5/learning/course/study.html")
@SaCheckPermission("learning.course.display")
public void courseH5Study() {
}
}
@@ -0,0 +1,412 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.PageUtil;
import com.budwk.app.sys.models.Sys_union;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.learning.models.LearningCourse;
import com.budwk.app.zhgh.learning.service.LearningResourceProgressService;
import com.budwk.app.zhgh.learning.param.LearningStatisticsPageForm;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.dao.util.cri.Static;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@IocBean
@Ok("json:full")
@Api("学习教育平台-学习统计")
@At("/platform/learning/statistics")
public class LearningStatisticsController {
@Inject
private Dao dao;
@Inject
private LearningResourceProgressService resourceProgressService;
@At("")
@Ok("beetl:/platform/zhgh/Learning/statistics/index.html")
@SaCheckPermission("learning.statistics")
public void index() {
}
@At
@ApiOperation("课程统计")
@SaCheckPermission("learning.statistics")
public Result coursePageData(LearningStatisticsPageForm pageForm) {
refreshProgress(pageForm);
Cnd cnd = courseCondition(pageForm);
Sql countSql = Sqls.create("""
SELECT COUNT(1)
FROM learning_course c
$condition
""");
countSql.setCondition(cnd);
countSql.setCallback(Sqls.callback.integer());
dao.execute(countSql);
Sql listSql = Sqls.create("""
SELECT
c.id AS courseId,
c.courseName,
COUNT(DISTINCT uc.userId) AS learnerCount,
COUNT(DISTINCT CASE WHEN target.targetCount > 0 AND uc.completedOutlineCount >= target.targetCount THEN uc.userId END) AS completedCount,
IFNULL(ROUND(AVG(uc.studySeconds)), 0) AS avgStudySeconds
FROM learning_course c
LEFT JOIN (
SELECT
courseId,
COUNT(1) AS targetCount,
SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount
FROM learning_course_outline
WHERE status = 'enabled'
AND EXISTS (SELECT 1 FROM learning_outline_resource lr
WHERE lr.outlineId = learning_course_outline.id
AND lr.courseId = learning_course_outline.courseId
AND lr.status = 'enabled' AND COALESCE(lr.allowPreview, 1) = 1)
GROUP BY courseId
) target ON target.courseId = c.id
LEFT JOIN (
SELECT
r.courseId,
r.userId,
SUM(IFNULL(r.studySeconds, 0)) AS studySeconds,
COUNT(DISTINCT CASE
WHEN r.completeStatus = 'completed'
THEN r.outlineId
ELSE NULL
END) AS completedOutlineCount
FROM learning_study_record r
JOIN learning_course_outline o ON o.id = r.outlineId AND o.courseId = r.courseId AND o.status = 'enabled'
JOIN (
SELECT
courseId,
COUNT(1) AS targetCount,
SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount
FROM learning_course_outline
WHERE status = 'enabled'
AND EXISTS (SELECT 1 FROM learning_outline_resource lr
WHERE lr.outlineId = learning_course_outline.id
AND lr.courseId = learning_course_outline.courseId
AND lr.status = 'enabled' AND COALESCE(lr.allowPreview, 1) = 1)
GROUP BY courseId
) target ON target.courseId = r.courseId
$recordCondition
GROUP BY r.courseId, r.userId
) uc ON uc.courseId = c.id
$condition
GROUP BY c.id, c.courseName
ORDER BY $orderColumn $orderBy
""");
listSql.setCondition(cnd);
listSql.setVar("recordCondition", new Static(recordWhere(pageForm)));
setOrder(listSql, pageForm, "course");
listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
dao.execute(listSql);
List<NutMap> rows = listSql.getList(NutMap.class);
rows.forEach(row -> {
row.put("avgStudyTimeText", formatStudySeconds(row.getInt("avgStudySeconds", 0)));
row.put("completeRate", completeRate(row.getInt("completedCount", 0), row.getInt("learnerCount", 0)));
});
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), countSql.getInt(), rows));
}
@At
@ApiOperation("分工会统计")
@SaCheckPermission("learning.statistics")
public Result unionPageData(LearningStatisticsPageForm pageForm) {
refreshProgress(pageForm);
Cnd cnd = unionCondition(pageForm);
Sql countSql = Sqls.create("""
SELECT COUNT(1)
FROM learning_course c
JOIN (
SELECT r.courseId, r.unionId
FROM learning_study_record r
$recordCondition
GROUP BY r.courseId, r.unionId
) uc ON uc.courseId = c.id
LEFT JOIN sys_union un ON un.id = uc.unionId
$condition
""");
countSql.setCondition(cnd);
countSql.setVar("recordCondition", new Static(recordWhere(pageForm)));
countSql.setCallback(Sqls.callback.integer());
dao.execute(countSql);
Sql listSql = Sqls.create("""
SELECT
c.id AS courseId,
c.courseName,
un.id AS unionId,
IFNULL(un.name, uc.unionName) AS unionName,
COUNT(DISTINCT uc.userId) AS learnerCount,
IFNULL(SUM(uc.studySeconds), 0) AS studySeconds,
COUNT(DISTINCT CASE WHEN target.targetCount > 0 AND uc.completedOutlineCount >= target.targetCount THEN uc.userId END) AS completedCount
FROM learning_course c
JOIN (
SELECT
r.courseId,
r.userId,
r.unionId,
MAX(r.unionName) AS unionName,
SUM(IFNULL(r.studySeconds, 0)) AS studySeconds,
COUNT(DISTINCT CASE
WHEN r.completeStatus = 'completed'
THEN r.outlineId
ELSE NULL
END) AS completedOutlineCount
FROM learning_study_record r
JOIN learning_course_outline o ON o.id = r.outlineId AND o.courseId = r.courseId AND o.status = 'enabled'
JOIN (
SELECT
courseId,
COUNT(1) AS targetCount,
SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount
FROM learning_course_outline
WHERE status = 'enabled'
AND EXISTS (SELECT 1 FROM learning_outline_resource lr
WHERE lr.outlineId = learning_course_outline.id
AND lr.courseId = learning_course_outline.courseId
AND lr.status = 'enabled' AND COALESCE(lr.allowPreview, 1) = 1)
GROUP BY courseId
) target ON target.courseId = r.courseId
$recordCondition
GROUP BY r.courseId, r.userId, r.unionId
) uc ON uc.courseId = c.id
LEFT JOIN sys_union un ON un.id = uc.unionId
LEFT JOIN (
SELECT
courseId,
COUNT(1) AS targetCount,
SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount
FROM learning_course_outline
WHERE status = 'enabled'
AND EXISTS (SELECT 1 FROM learning_outline_resource lr
WHERE lr.outlineId = learning_course_outline.id
AND lr.courseId = learning_course_outline.courseId
AND lr.status = 'enabled' AND COALESCE(lr.allowPreview, 1) = 1)
GROUP BY courseId
) target ON target.courseId = c.id
$condition
GROUP BY c.id, c.courseName, uc.unionId, un.id, un.name
ORDER BY $orderColumn $orderBy
""");
listSql.setCondition(cnd);
listSql.setVar("recordCondition", new Static(recordWhere(pageForm)));
setOrder(listSql, pageForm, "union");
listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
dao.execute(listSql);
List<NutMap> rows = listSql.getList(NutMap.class);
rows.forEach(row -> {
int learnerCount = row.getInt("learnerCount", 0);
int completedCount = row.getInt("completedCount", 0);
row.put("studyTimeText", formatStudySeconds(row.getInt("studySeconds", 0)));
row.put("completeRate", completeRate(completedCount, learnerCount));
});
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), countSql.getInt(), rows));
}
@At
@ApiOperation("学习列表")
@SaCheckPermission("learning.statistics")
public Result recordPageData(LearningStatisticsPageForm pageForm) {
refreshProgress(pageForm);
Cnd cnd = Cnd.NEW();
cnd.andEX("r.courseId", "=", pageForm.getCourseId());
cnd.andEX("r.unionId", "=", pageForm.getUnionId());
cnd.and(Cnd.likeEX("r.loginName", pageForm.getLoginName()));
cnd.and(Cnd.likeEX("r.userName", pageForm.getUserName()));
if ("completed".equals(pageForm.getCompleteStatus())) {
cnd.and("r.completeStatus", "=", "completed");
} else if ("incomplete".equals(pageForm.getCompleteStatus())) {
cnd.and(Cnd.exps("r.completeStatus", "!=", "completed").or("r.completeStatus", "is", null));
}
if (!hasSchoolScope()) {
if (hasBranchScope()) {
cnd.and("r.unionId", "=", SecurityUtil.getUnionId());
} else {
cnd.and("r.userId", "=", SecurityUtil.getUserId());
}
}
String from = """
FROM learning_study_record r
JOIN learning_course_outline o ON o.id = r.outlineId AND o.courseId = r.courseId AND o.status = 'enabled'
JOIN (SELECT DISTINCT outlineId, courseId FROM learning_outline_resource
WHERE status = 'enabled' AND COALESCE(allowPreview, 1) = 1) lr
ON lr.outlineId = r.outlineId AND lr.courseId = r.courseId
""";
Sql countSql = Sqls.create("SELECT COUNT(1) " + from + " $condition");
countSql.setCondition(cnd);
countSql.setCallback(Sqls.callback.integer());
dao.execute(countSql);
Map<String, String> columns = new HashMap<>();
for (String column : List.of("loginName", "userName", "unionName", "courseName", "outlineName",
"firstStudyTime", "latestStudyTime", "studySeconds", "progressPercent", "completeStatus")) {
columns.put(column, "r." + column);
}
String orderColumn = columns.get(pageForm.getPageOrderName());
if (StrUtil.isNotBlank(orderColumn) && StrUtil.isNotBlank(pageForm.getPageOrderBy())) {
cnd.orderBy(orderColumn, PageUtil.getOrder(pageForm.getPageOrderBy()));
} else {
cnd.desc("r.latestStudyTime");
}
Sql listSql = Sqls.create("SELECT r.* " + from + " $condition");
listSql.setCondition(cnd);
listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
dao.execute(listSql);
List<NutMap> rows = listSql.getList(NutMap.class);
rows.forEach(row -> row.put("studyTimeText", formatStudySeconds(row.getInt("studySeconds", 0))));
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), countSql.getInt(), rows));
}
@At
@ApiOperation("可选课程")
@SaCheckPermission("learning.statistics")
public Result courseOptions(String courseName) {
Cnd cnd = Cnd.NEW();
cnd.and(Cnd.likeEX("courseName", courseName));
cnd.desc("createdAt").asc("sortNum");
return Result.success(dao.query(LearningCourse.class, cnd, dao.createPager(1, 50)));
}
@At
@ApiOperation("可选分工会")
@SaCheckPermission("learning.statistics")
public Result unionOptions() {
if (hasSchoolScope()) {
return Result.success(dao.query(Sys_union.class, Cnd.NEW().asc("unionCode").asc("name")));
}
if (hasBranchScope()) {
return Result.success(dao.query(Sys_union.class, Cnd.where("id", "=", SecurityUtil.getUnionId())));
}
return Result.success(List.of());
}
private void refreshProgress(LearningStatisticsPageForm pageForm) {
Cnd cnd = Cnd.NEW();
cnd.andEX("courseId", "=", pageForm.getCourseId());
cnd.andEX("unionId", "=", pageForm.getUnionId());
if (!hasSchoolScope()) {
if (hasBranchScope()) cnd.and("unionId", "=", SecurityUtil.getUnionId());
else cnd.and("userId", "=", SecurityUtil.getUserId());
}
resourceProgressService.refreshRecords(cnd);
}
private Cnd courseCondition(LearningStatisticsPageForm pageForm) {
Cnd cnd = Cnd.NEW();
cnd.andEX("c.id", "=", pageForm.getCourseId());
cnd.and(Cnd.likeEX("c.courseName", pageForm.getCourseName()));
return cnd;
}
private Cnd unionCondition(LearningStatisticsPageForm pageForm) {
Cnd cnd = courseCondition(pageForm);
cnd.andEX("uc.unionId", "=", pageForm.getUnionId());
return cnd;
}
private String recordWhere(LearningStatisticsPageForm pageForm) {
StringBuilder where = new StringBuilder("WHERE EXISTS (SELECT 1 FROM learning_course_outline so "
+ "JOIN learning_outline_resource sr ON sr.outlineId = so.id AND sr.courseId = so.courseId "
+ "WHERE so.id = r.outlineId AND so.courseId = r.courseId AND so.status = 'enabled' "
+ "AND sr.status = 'enabled' AND COALESCE(sr.allowPreview, 1) = 1)");
if (!hasSchoolScope() && hasBranchScope()) {
where.append(" AND r.unionId = '").append(escapeSql(SecurityUtil.getUnionId())).append("'");
} else if (!hasSchoolScope()) {
where.append(" AND r.userId = '").append(escapeSql(SecurityUtil.getUserId())).append("'");
}
if (StrUtil.isNotBlank(pageForm.getUnionId())) {
where.append(" AND r.unionId = '").append(escapeSql(pageForm.getUnionId())).append("'");
}
return where.toString();
}
private void setOrder(Sql sql, LearningStatisticsPageForm pageForm, String type) {
String orderColumn = "union".equals(type) ? getUnionOrderColumn(pageForm.getPageOrderName()) : getCourseOrderColumn(pageForm.getPageOrderName());
String orderBy = PageUtil.getOrder(pageForm.getPageOrderBy());
if (StrUtil.isBlank(orderColumn)) {
orderColumn = "union".equals(type) ? "c.courseName ASC, un.unionCode" : "c.sortNum ASC, c.createdAt";
orderBy = "DESC";
} else if (StrUtil.isBlank(orderBy)) {
orderBy = "ASC";
}
sql.setVar("orderColumn", new Static(orderColumn));
sql.setVar("orderBy", new Static(orderBy));
}
private String getCourseOrderColumn(String prop) {
Map<String, String> columns = new HashMap<>();
columns.put("courseName", "c.courseName");
columns.put("learnerCount", "learnerCount");
columns.put("completedCount", "completedCount");
columns.put("avgStudySeconds", "avgStudySeconds");
columns.put("completeRate", "completedCount / NULLIF(learnerCount, 0)");
return columns.get(prop);
}
private String getUnionOrderColumn(String prop) {
Map<String, String> columns = new HashMap<>();
columns.put("courseName", "c.courseName");
columns.put("unionName", "unionName");
columns.put("learnerCount", "learnerCount");
columns.put("studySeconds", "studySeconds");
columns.put("completeRate", "completedCount / NULLIF(learnerCount, 0)");
columns.put("completedCount", "completedCount");
return columns.get(prop);
}
private boolean hasSchoolScope() {
return AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name());
}
private boolean hasBranchScope() {
return AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_ADMIN.name());
}
private String escapeSql(String value) {
return value == null ? "" : value.replace("'", "''");
}
private String completeRate(int completedCount, int learnerCount) {
return learnerCount == 0 ? "0%" : BigDecimal.valueOf(completedCount).multiply(BigDecimal.valueOf(100))
.divide(BigDecimal.valueOf(learnerCount), 2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() + "%";
}
private String formatStudySeconds(Integer seconds) {
int value = seconds == null ? 0 : seconds;
int hour = value / 3600;
int minute = value % 3600 / 60;
int second = value % 60;
if (hour > 0) {
return hour + "小时" + minute + "分" + second + "秒";
}
if (minute > 0) {
return minute + "分" + second + "秒";
}
return second + "秒";
}
}
@@ -0,0 +1,615 @@
package com.budwk.app.zhgh.learning.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.PageUtil;
import com.budwk.app.sys.models.Sys_union;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.learning.models.LearningCourse;
import com.budwk.app.zhgh.learning.models.LearningCourseOutline;
import com.budwk.app.zhgh.learning.models.LearningOutlineResource;
import com.budwk.app.zhgh.learning.models.LearningStudyRecord;
import com.budwk.app.zhgh.learning.models.LearningStudyRule;
import com.budwk.app.zhgh.learning.models.LearningStudySegment;
import com.budwk.app.zhgh.learning.service.LearningResourceProgressService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.dao.util.cri.SqlExpressionGroup;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@IocBean
@Ok("json:full")
@Api("学习教育平台-学习记录")
@At("/platform/learning/study/record")
public class LearningStudyRecordController {
private static final int MAX_HEARTBEAT_SECONDS = 30;
@Inject
private Dao dao;
@Inject
private LearningResourceProgressService resourceProgressService;
@At
@ApiOperation("学习记录分页")
@SaCheckPermission("learning.my.record")
public Result pageData(PageForm pageForm,
@Param("keyword") String keyword,
@Param("unionId") String unionId,
@Param("courseId") String courseId,
@Param("completeStatus") String completeStatus) {
resourceProgressService.refreshRecords(Cnd.where("userId", "=", SecurityUtil.getUserId()));
Cnd cnd = Cnd.NEW();
if (StrUtil.isNotBlank(keyword)) {
SqlExpressionGroup seg = new SqlExpressionGroup();
seg.or("r.loginName", "like", "%" + keyword + "%");
seg.or("r.userName", "like", "%" + keyword + "%");
cnd.and(seg);
}
cnd.andEX("r.unionId", "=", unionId);
cnd.andEX("r.courseId", "=", courseId);
cnd.andEX("r.completeStatus", "=", completeStatus);
cnd.and("r.userId", "=", SecurityUtil.getUserId());
String orderColumn = getOrderColumn(pageForm.getPageOrderName());
if (StrUtil.isNotBlank(orderColumn) && StrUtil.isNotBlank(pageForm.getPageOrderBy())) {
cnd.orderBy(orderColumn, PageUtil.getOrder(pageForm.getPageOrderBy()));
} else {
cnd.desc("r.latestStudyTime");
}
Sql countSql = Sqls.create("SELECT COUNT(1) FROM learning_study_record r $condition");
countSql.setCondition(cnd);
countSql.setCallback(Sqls.callback.integer());
dao.execute(countSql);
Sql listSql = Sqls.create("""
SELECT r.*
FROM learning_study_record r
$condition
""");
listSql.setCondition(cnd);
listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
dao.execute(listSql);
List<NutMap> rows = listSql.getList(NutMap.class);
rows.forEach(row -> row.put("studyTimeText", formatStudySeconds(row.getInt("studySeconds", 0))));
return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), countSql.getInt(), rows));
}
@At
@ApiOperation("移动端我的学习汇总")
@SaCheckPermission("learning.my.record")
public Result h5Summary() {
String userId = SecurityUtil.getUserId();
java.util.Set<String> courseIds = new java.util.HashSet<>();
for (LearningStudyRecord record : dao.query(LearningStudyRecord.class, Cnd.where("userId", "=", userId))) {
courseIds.add(record.getCourseId());
}
int seconds = 0, outlines = 0, completedOutlines = 0, completedCourses = 0;
for (String courseId : courseIds) {
NutMap state = resourceProgressService.courseState(courseId, userId);
seconds += actualStudySeconds(courseId, userId);
outlines += state.getInt("outlineCount", 0);
completedOutlines += state.getInt("completedOutlineCount", 0);
if (state.getInt("outlineCount", 0) > 0 && state.getInt("progressPercent", 0) == 100) completedCourses++;
}
NutMap data = NutMap.NEW().addv("studySeconds", seconds).addv("courseCount", courseIds.size())
.addv("completedCourseCount", completedCourses).addv("outlineCount", outlines)
.addv("completedOutlineCount", completedOutlines);
int studySeconds = data.getInt("studySeconds", 0);
data.put("studyHour", studySeconds / 3600);
data.put("studyMinute", studySeconds % 3600 / 60);
return Result.success(data);
}
@At
@ApiOperation("移动端我的学习课程")
@SaCheckPermission("learning.my.record")
public Result h5Courses() {
String userId = SecurityUtil.getUserId();
Sql sql = Sqls.create("""
SELECT r.courseId,
MAX(r.courseName) AS courseName,
MAX(c.cover) AS cover,
COUNT(1) AS outlineCount,
SUM(CASE WHEN r.completeStatus = 'completed' THEN 1 ELSE 0 END) AS completedOutlineCount,
IFNULL(SUM(IFNULL(r.studySeconds, 0)), 0) AS studySeconds,
IFNULL(ROUND(AVG(IFNULL(r.progressPercent, 0))), 0) AS progressPercent,
MAX(r.latestStudyTime) AS latestStudyTime
FROM learning_study_record r
LEFT JOIN learning_course c ON c.id = r.courseId
WHERE r.userId = @userId
AND EXISTS (SELECT 1 FROM learning_outline_resource pr WHERE pr.outlineId = r.outlineId
AND pr.status = 'enabled' AND COALESCE(pr.allowPreview, 1) = 1)
GROUP BY r.courseId
ORDER BY latestStudyTime DESC
""");
sql.params().set("userId", userId);
sql.setCallback(Sqls.callback.maps());
dao.execute(sql);
List<NutMap> rows = sql.getList(NutMap.class);
rows.forEach(row -> {
row.putAll(resourceProgressService.courseState(row.getString("courseId"), userId));
row.put("studySeconds", actualStudySeconds(row.getString("courseId"), userId));
row.put("studyTimeText", formatStudySeconds(row.getInt("studySeconds", 0)));
});
rows.sort(java.util.Comparator.comparingInt((NutMap row) ->
row.getInt("progressPercent", 0) == 100 && row.getInt("outlineCount", 0) > 0
&& row.getInt("completedOutlineCount", 0) >= row.getInt("outlineCount", 0) ? 1 : 0));
return Result.success(rows);
}
@At
@ApiOperation("开始学习")
@SaCheckPermission("learning.course.display")
@Aop(TransAop.READ_COMMITTED)
/**
* courseId、resourceId 分别传课程和资源主键,positionSeconds 传当前播放秒数;
* 返回学习记录、学习时段、学习进度和本次自增后的课程观看次数。
*/
public Result start(@Param("courseId") String courseId,
@Param("resourceId") String resourceId,
@Param("positionSeconds") Integer positionSeconds) {
if (StrUtil.hasBlank(courseId, resourceId)) {
return Result.error("请选择课程资料后开始学习");
}
LearningCourse course = dao.fetch(LearningCourse.class, courseId);
LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, resourceId);
if (course == null || resource == null || !courseId.equals(resource.getCourseId())) {
return Result.error("课程资料不存在");
}
if (Boolean.FALSE.equals(resource.getAllowPreview()) || !"enabled".equals(resource.getStatus())) {
return Result.error("该资料不允许学习预览");
}
LearningCourseOutline outline = dao.fetch(LearningCourseOutline.class, resource.getOutlineId());
if (outline == null) {
return Result.error("章节不存在");
}
String userId = SecurityUtil.getUserId();
Date now = new Date();
closeUnfinishedSegments(userId, "interrupted");
LearningStudyRecord record = dao.fetch(LearningStudyRecord.class, Cnd.where("userId", "=", userId)
.and("courseId", "=", courseId)
.and("outlineId", "=", outline.getId()));
if (record == null) {
record = buildRecord(userId, course, outline, now);
dao.insert(record);
} else {
record = resourceProgressService.lock(record.getId());
}
int startPositionSeconds = sanitizePosition(positionSeconds, 0);
resourceProgressService.savePosition(record, resourceId, startPositionSeconds, null, false);
dao.update(LearningStudyRecord.class, Chain.make("latestStudyTime", now)
.add("courseName", course.getCourseName()).add("outlineName", outline.getTitle())
.add("lastPositionSeconds", startPositionSeconds)
.add("resourceMaxPositionData", record.getResourceMaxPositionData()),
Cnd.where("id", "=", record.getId()));
LearningStudySegment segment = new LearningStudySegment();
segment.setRecordId(record.getId());
segment.setUserId(userId);
segment.setCourseId(courseId);
segment.setOutlineId(outline.getId());
segment.setResourceId(resourceId);
segment.setResourceName(resource.getResourceTitle());
segment.setStartTime(now);
segment.setLastHeartbeatTime(now);
segment.setActiveSeconds(0);
segment.setState("studying");
dao.insert(segment);
// 每次成功创建学习时段记为一次观看,使用原子自增避免并发开始学习时丢失计数。
dao.update(LearningCourse.class,
Chain.makeSpecial("viewCount", "IFNULL(viewCount, 0) + 1"),
Cnd.where("id", "=", courseId));
LearningCourse updatedCourse = dao.fetch(LearningCourse.class, courseId);
resourceProgressService.refreshOutline(record, requiredSeconds(outline.getId()));
NutMap resourceState = resourceProgressService.state(record, resource);
resourceState.addv("courseProgress", resourceProgressService.courseState(record.getCourseId(), record.getUserId()));
resourceState.addv("recordId", record.getId()).addv("segmentId", segment.getId())
.addv("viewCount", value(updatedCourse == null ? null : updatedCourse.getViewCount()));
return Result.success(resourceState);
}
@At
@ApiOperation("学习心跳")
@SaCheckPermission("learning.course.display")
@Aop(TransAop.READ_COMMITTED)
public Result heartbeat(@Param("segmentId") String segmentId,
@Param("activeSeconds") Integer activeSeconds,
@Param("positionSeconds") Integer positionSeconds) {
LearningStudySegment segment = fetchOwnStudyingSegment(segmentId);
if (segment == null) {
return Result.error("学习时段已结束,请重新开始学习");
}
int seconds = Math.max(0, Math.min(activeSeconds == null ? 0 : activeSeconds, MAX_HEARTBEAT_SECONDS));
return Result.success(addActiveSeconds(segment, seconds, false, false, positionSeconds));
}
@At
@ApiOperation("结束学习")
@SaCheckPermission("learning.course.display")
@Aop(TransAop.READ_COMMITTED)
/**
* segmentId 传当前学习时段主键,activeSeconds 传本次尚未上报的有效学习秒数,positionSeconds 传当前播放位置;
* completed 仅在视频或音频自然播放结束时传 true。返回值为 NutMap,包含累计学习时长、进度和完成状态。
*/
public Result finish(@Param("segmentId") String segmentId,
@Param("activeSeconds") Integer activeSeconds,
@Param("positionSeconds") Integer positionSeconds,
@Param("completed") Boolean completed) {
LearningStudySegment segment = fetchOwnStudyingSegment(segmentId);
if (segment == null) {
return Result.success();
}
int seconds = Math.max(0, Math.min(activeSeconds == null ? 0 : activeSeconds, MAX_HEARTBEAT_SECONDS));
NutMap result = addActiveSeconds(segment, seconds, true, Boolean.TRUE.equals(completed), positionSeconds);
dao.update(LearningStudySegment.class, Chain.make("endTime", new Date()).add("state", "finished"), Cnd.where("id", "=", segmentId));
return Result.success(result);
}
@At
@ApiOperation("保存播放位置")
@SaCheckPermission("learning.course.display")
@Aop(TransAop.READ_COMMITTED)
public Result position(@Param("courseId") String courseId,
@Param("resourceId") String resourceId,
@Param("positionSeconds") Integer positionSeconds) {
if (StrUtil.hasBlank(courseId, resourceId) || positionSeconds == null || positionSeconds < 0) {
return Result.success();
}
LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, resourceId);
if (resource == null || !courseId.equals(resource.getCourseId())) {
return Result.success();
}
LearningStudyRecord record = dao.fetch(LearningStudyRecord.class,
Cnd.where("userId", "=", SecurityUtil.getUserId()).and("courseId", "=", courseId)
.and("outlineId", "=", resource.getOutlineId()));
if (record == null) return Result.success();
record = resourceProgressService.lock(record.getId());
resourceProgressService.savePosition(record, resourceId, positionSeconds, null, false);
dao.update(LearningStudyRecord.class, Chain.make("lastPositionSeconds", positionSeconds)
.add("latestStudyTime", new Date()).add("resourceMaxPositionData", record.getResourceMaxPositionData()),
Cnd.where("id", "=", record.getId()));
return Result.success();
}
@At
@ApiOperation("可选课程")
@SaCheckPermission("learning.my.record")
public Result courseOptions() {
return Result.success(dao.query(LearningCourse.class, Cnd.NEW().asc("sortNum").desc("createdAt")));
}
@At
@ApiOperation("可选分工会")
@SaCheckPermission("learning.my.record")
public Result unionOptions() {
if (hasSchoolScope()) {
return Result.success(dao.query(Sys_union.class, Cnd.NEW().asc("unionCode").asc("name")));
}
if (hasBranchScope()) {
return Result.success(dao.query(Sys_union.class, Cnd.where("id", "=", SecurityUtil.getUnionId())));
}
return Result.success(List.of());
}
@At
@ApiOperation("删除学习记录")
@SaCheckPermission("learning.my.record")
public Result delete(@Param("id") String id) {
if (StrUtil.isBlank(id)) {
return Result.error("请选择要删除的学习记录");
}
LearningStudyRecord record = dao.fetch(LearningStudyRecord.class, id);
if (record == null) {
return Result.error("学习记录不存在");
}
if (!canOperate(record)) {
return Result.error("无权删除该学习记录");
}
dao.clear(LearningStudySegment.class, Cnd.where("recordId", "=", id));
dao.clear(LearningStudyRecord.class, Cnd.where("id", "=", id));
return Result.success();
}
private LearningStudyRecord buildRecord(String userId, LearningCourse course, LearningCourseOutline outline, Date now) {
NutMap user = currentUserInfo(userId);
int requiredSeconds = requiredSeconds(outline.getId());
LearningStudyRecord record = new LearningStudyRecord();
record.setUserId(userId);
record.setLoginName(user.getString("loginname", SecurityUtil.getUserLoginname()));
record.setUserName(user.getString("username", SecurityUtil.getUserUsername()));
record.setUnionId(user.getString("unionId", SecurityUtil.getUnionId()));
record.setUnionName(user.getString("unionName", ""));
record.setCourseId(course.getId());
record.setCourseName(course.getCourseName());
record.setOutlineId(outline.getId());
record.setOutlineName(outline.getTitle());
record.setFirstStudyTime(now);
record.setLatestStudyTime(now);
record.setStudySeconds(0);
record.setLastPositionSeconds(0);
record.setResourceMaxPositionData("{}");
record.setRequiredSeconds(requiredSeconds);
record.setProgressPercent(0);
record.setCompleteStatus("studying");
return record;
}
private NutMap addActiveSeconds(LearningStudySegment segment, int activeSeconds, boolean finish, boolean completed, Integer positionSeconds) {
Date now = new Date();
LearningStudyRecord record = resourceProgressService.lock(segment.getRecordId());
LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, segment.getResourceId());
segment = dao.fetch(LearningStudySegment.class, segment.getId());
if (!"studying".equals(segment.getState())) {
return resourceProgressService.state(record, resource);
}
if (Boolean.FALSE.equals(resource.getAllowPreview()) || !"enabled".equals(resource.getStatus())) {
dao.update(LearningStudySegment.class, Chain.make("state", "interrupted").add("endTime", now),
Cnd.where("id", "=", segment.getId()));
resourceProgressService.refreshOutline(record, requiredSeconds(record.getOutlineId()));
return resourceProgressService.state(record, resource);
}
NutMap before = resourceProgressService.state(record, resource);
int increment = effectiveStudySeconds(record, resource, segment.getResourceId(), activeSeconds, positionSeconds);
LearningStudyRule rule = resourceProgressService.outlineRule(record.getOutlineId());
boolean timeRule = rule != null && "min_study_time".equals(rule.getCompletionRule());
if (timeRule) increment = activeSeconds;
int duration = value(resource.getDurationSeconds());
// 自然播放结束以到达资源末尾为准,补齐计时器和心跳延迟遗漏的秒数。
boolean naturalEnd = completed && isMediaResource(resource) && positionSeconds != null && positionSeconds > 0
&& (duration <= 0 || positionSeconds >= Math.max(0, duration - 2));
if (naturalEnd && duration > 0 && !timeRule) {
increment = Math.max(increment, duration - before.getInt("studySeconds", 0));
}
Chain segmentChain = Chain.make("activeSeconds", value(segment.getActiveSeconds()) + increment)
.add("lastHeartbeatTime", now);
if (finish) segmentChain.add("state", "finished").add("endTime", now);
dao.update(LearningStudySegment.class, segmentChain, Cnd.where("id", "=", segment.getId()));
Integer maxPosition = isMediaResource(resource) && positionSeconds != null ? Math.max(0, positionSeconds) : null;
resourceProgressService.savePosition(record, resource.getId(), positionSeconds, maxPosition, naturalEnd);
Chain chain = Chain.make("latestStudyTime", now).add("resourceMaxPositionData", record.getResourceMaxPositionData());
if (positionSeconds != null && positionSeconds >= 0) chain.add("lastPositionSeconds", positionSeconds);
dao.update(LearningStudyRecord.class, chain, Cnd.where("id", "=", record.getId()));
resourceProgressService.refreshOutline(record, requiredSeconds(record.getOutlineId()));
NutMap result = resourceProgressService.state(record, resource);
result.addv("courseProgress", resourceProgressService.courseState(record.getCourseId(), record.getUserId()));
result.addv("studyTimeText", formatStudySeconds(result.getInt("studySeconds", 0)));
return result;
}
private int actualStudySeconds(String courseId, String userId) {
Sql sql = Sqls.create("""
SELECT COALESCE(SUM(s.activeSeconds), 0)
FROM learning_study_segment s
JOIN learning_study_record r ON r.id = s.recordId AND r.userId = s.userId AND r.courseId = s.courseId
WHERE s.courseId = @courseId AND s.userId = @userId
""");
sql.params().set("courseId", courseId).set("userId", userId);
sql.setCallback(Sqls.callback.integer());
dao.execute(sql);
return Math.max(0, sql.getInt());
}
private LearningStudySegment fetchOwnStudyingSegment(String segmentId) {
if (StrUtil.isBlank(segmentId)) {
return null;
}
return dao.fetch(LearningStudySegment.class, Cnd.where("id", "=", segmentId)
.and("userId", "=", SecurityUtil.getUserId())
.and("state", "=", "studying"));
}
private void closeUnfinishedSegments(String userId, String state) {
dao.update(LearningStudySegment.class,
Chain.make("endTime", new Date()).add("state", state),
Cnd.where("userId", "=", userId).and("state", "=", "studying"));
}
private int requiredSeconds(String outlineId) {
return resourceProgressService.requiredSeconds(outlineId);
}
/**
* 校验媒体自然播放结束能否完成当前章节。
* segment 表示当前用户的学习时段;positionSeconds 必须到达资源末尾附近;requiredSeconds 为章节要求学习时长。
* 仅单资源章节且配置时长不超过媒体时长时返回 true,防止一个资源结束误完成包含多个资源或设置了更长时长的章节。
*/
private boolean canCompleteByNaturalEnd(LearningStudySegment segment, Integer positionSeconds, int requiredSeconds) {
if (positionSeconds == null || requiredSeconds <= 0) {
return false;
}
LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, segment.getResourceId());
if (resource == null || !("video".equals(resource.getResourceType()) || "audio".equals(resource.getResourceType()))) {
return false;
}
int durationSeconds = value(resource.getDurationSeconds());
if (durationSeconds <= 0 || positionSeconds < Math.max(0, durationSeconds - 2) || requiredSeconds > durationSeconds) {
return false;
}
Sql countSql = Sqls.create("SELECT COUNT(1) FROM learning_outline_resource WHERE outlineId = @outlineId AND status = 'enabled'");
countSql.params().set("outlineId", segment.getOutlineId());
countSql.setCallback(Sqls.callback.integer());
dao.execute(countSql);
return countSql.getInt() == 1;
}
/**
* 仅统计当前资源超过历史最高观看位置的新增区间,回退复习不会重复累计学习时长。
* record 保存各资源最高位置,resource 用于区分媒体和非媒体资料,positionSeconds 为本次心跳时的播放器位置。
*/
private int effectiveStudySeconds(LearningStudyRecord record, LearningOutlineResource resource, String resourceId, int activeSeconds, Integer positionSeconds) {
if (!isMediaResource(resource)) {
return activeSeconds;
}
int currentPositionSeconds = Math.max(0, positionSeconds == null ? 0 : positionSeconds);
int maxPositionSeconds = resourceMaxPosition(record, resourceId);
return Math.min(activeSeconds, Math.max(0, currentPositionSeconds - maxPositionSeconds));
}
/**
* 读取学习记录中按资源保存的最高已观看秒数。历史记录或异常 JSON 视为未保存,避免影响已有记录的使用。
*/
private int resourceMaxPosition(LearningStudyRecord record, String resourceId) {
return value(resourceMaxPositions(record == null ? null : record.getResourceMaxPositionData()).get(resourceId));
}
/**
* 将资源 ID 与最高观看秒数转换为可持久化数据;键为资源 ID,值为该资源累计到达过的最远秒数。
*/
private String resourceMaxPositionData(String resourceId, int positionSeconds) {
Map<String, Integer> positions = new HashMap<>();
positions.put(resourceId, Math.max(0, positionSeconds));
return Json.toJson(positions);
}
/**
* 解析资源最高位置 JSON。返回值为资源 ID 到秒数的映射,空值或异常数据返回空映射。
*/
private Map<String, Integer> resourceMaxPositions(String data) {
Map<String, Integer> positions = new HashMap<>();
if (StrUtil.isBlank(data)) {
return positions;
}
try {
NutMap source = Json.fromJson(NutMap.class, data);
for (String resourceId : source.keySet()) {
positions.put(resourceId, Math.max(0, source.getInt(resourceId, 0)));
}
} catch (Exception ignored) {
// 历史记录可能没有该字段或保存了异常内容,按未记录最高位置处理。
}
return positions;
}
/**
* 判断资料是否使用播放器计时。视频和音频按未重复观看区间累计,其他资料沿用活跃时长累计。
*/
private boolean isMediaResource(LearningOutlineResource resource) {
return resource != null && ("video".equals(resource.getResourceType()) || "audio".equals(resource.getResourceType()));
}
private int progress(Integer studySeconds, int requiredSeconds) {
if (requiredSeconds <= 0) {
return 0;
}
return Math.min(100, (int) Math.floor(value(studySeconds) * 100.0 / requiredSeconds));
}
private String completeStatus(Integer studySeconds, int requiredSeconds, boolean finish) {
if (requiredSeconds <= 0) {
return finish ? "completed" : "studying";
}
if (value(studySeconds) >= requiredSeconds) {
return "completed";
}
return value(studySeconds) > 0 || finish ? "studying" : "not_started";
}
private NutMap currentUserInfo(String userId) {
Sql sql = Sqls.create("SELECT id, loginname, username, unionId AS unionId, unionName AS unionName FROM vw_user WHERE id = @id");
sql.params().set("id", userId);
sql.setCallback(Sqls.callback.map());
dao.execute(sql);
NutMap user = sql.getObject(NutMap.class);
return user == null ? NutMap.NEW() : user;
}
private void appendScope(Cnd cnd) {
if (hasSchoolScope()) {
return;
}
if (hasBranchScope()) {
cnd.and("r.unionId", "=", SecurityUtil.getUnionId());
return;
}
cnd.and("r.userId", "=", SecurityUtil.getUserId());
}
private boolean hasSchoolScope() {
return AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name());
}
private boolean hasBranchScope() {
return AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_ADMIN.name());
}
private boolean canOperate(LearningStudyRecord record) {
if (hasSchoolScope()) {
return true;
}
if (hasBranchScope()) {
return StrUtil.equals(record.getUnionId(), SecurityUtil.getUnionId());
}
return StrUtil.equals(record.getUserId(), SecurityUtil.getUserId());
}
private String getOrderColumn(String prop) {
Map<String, String> columns = new HashMap<>();
columns.put("loginName", "r.loginName");
columns.put("userName", "r.userName");
columns.put("unionName", "r.unionName");
columns.put("courseName", "r.courseName");
columns.put("outlineName", "r.outlineName");
columns.put("firstStudyTime", "r.firstStudyTime");
columns.put("latestStudyTime", "r.latestStudyTime");
columns.put("studySeconds", "r.studySeconds");
columns.put("progressPercent", "r.progressPercent");
columns.put("completeStatus", "r.completeStatus");
return columns.get(prop);
}
private String formatStudySeconds(Integer seconds) {
int value = value(seconds);
int hour = value / 3600;
int minute = value % 3600 / 60;
int second = value % 60;
if (hour > 0) {
return hour + "小时" + minute + "分" + second + "秒";
}
if (minute > 0) {
return minute + "分" + second + "秒";
}
return second + "秒";
}
private int value(Integer value) {
return value == null ? 0 : value;
}
private int sanitizePosition(Integer positionSeconds, Integer defaultValue) {
if (positionSeconds == null || positionSeconds < 0) {
return value(defaultValue);
}
return positionSeconds;
}
}
@@ -0,0 +1,122 @@
package com.budwk.app.zhgh.learning.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.interceptor.annotation.PrevInsert;
import java.util.Date;
@Data
@Table
@EqualsAndHashCode(callSuper = true)
public class LearningCourse extends BaseModel {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("课程名称")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String courseName;
@Column
@Comment("课程类型")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String courseTypeId;
@Column
@Comment("授课讲师")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String lecturerName;
@Column
@Comment("讲师基本信息")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String lecturerInfo;
@Column
@Comment("课程简介")
@ColDefine(customType = "longtext")
private String courseIntro;
@Column
@Comment("适合人群")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String suitablePeople;
@Column
@Comment("学习目标")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String learningGoal;
@Column
@Comment("课程周期")
@ColDefine(type = ColType.VARCHAR, width = 50)
private String coursePeriod;
@Column
@Comment("开课类型")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String openType;
@Column
@Comment("开始时间")
@ColDefine(type = ColType.DATETIME)
private Date startTime;
@Column
@Comment("结束时间")
@ColDefine(type = ColType.DATETIME)
private Date endTime;
@Column
@Comment("课程状态")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String status;
@Column
@Comment("推荐标识")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String recommendFlags;
@Column
@Comment("课程标签")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String courseTags;
@Column
@Comment("学习对象")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String targetType;
@Column
@Comment("指定组织")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String targetOrgText;
@Column
@Comment("排序编码")
@ColDefine(type = ColType.INT)
private Integer sortNum;
@Column
@Comment("观看次数")
@ColDefine(type = ColType.INT)
private Integer viewCount;
@Column
@Comment("课程封面")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String cover;
}
@@ -0,0 +1,75 @@
package com.budwk.app.zhgh.learning.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.interceptor.annotation.PrevInsert;
@Data
@Table
@EqualsAndHashCode(callSuper = true)
public class LearningCourseOutline extends BaseModel {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("所属课程ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String courseId;
@Column
@Comment("所属课程名称")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String courseName;
@Column
@Comment("父节点ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String parentId;
@Column
@Comment("节点类型")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String nodeType;
@Column
@Comment("章/节标题")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String title;
@Column
@Comment("副标题")
@ColDefine(type = ColType.VARCHAR, width = 200)
private String subtitle;
@Column
@Comment("简介")
@ColDefine(customType = "longtext")
private String description;
@Column
@Comment("排序值")
@ColDefine(type = ColType.INT)
private Integer sortOrder;
@Column
@Comment("是否必学")
@ColDefine(type = ColType.BOOLEAN)
private Boolean required;
@Column
@Comment("状态")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String status;
}
@@ -0,0 +1,45 @@
package com.budwk.app.zhgh.learning.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.interceptor.annotation.PrevInsert;
@Data
@Table
@EqualsAndHashCode(callSuper = true)
public class LearningCourseType extends BaseModel {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("课程类型名称")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String typeName;
@Column
@Comment("排序编号")
@ColDefine(type = ColType.INT)
private Integer sortNum;
@Column
@Comment("是否启用")
@ColDefine(type = ColType.BOOLEAN)
private Boolean enabled;
@Column
@Comment("备注")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String remark;
}
@@ -0,0 +1,85 @@
package com.budwk.app.zhgh.learning.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.interceptor.annotation.PrevInsert;
@Data
@Table
@EqualsAndHashCode(callSuper = true)
public class LearningOutlineResource extends BaseModel {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("所属课程ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String courseId;
@Column
@Comment("所属章/节ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String outlineId;
@Column
@Comment("资料标题")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String resourceTitle;
@Column
@Comment("资料类型")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String resourceType;
@Column
@Comment("文件扩展名")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String fileExt;
@Column
@Comment("附件")
@ColDefine(customType = "longtext")
private String fileData;
@Column
@Comment("视频/音频时长")
@ColDefine(type = ColType.INT)
private Integer durationSeconds;
@Column
@Comment("排序值")
@ColDefine(type = ColType.INT)
private Integer sortOrder;
@Column
@Comment("是否必学")
@ColDefine(type = ColType.BOOLEAN)
private Boolean required;
@Column
@Comment("是否允许预览")
@ColDefine(type = ColType.BOOLEAN)
private Boolean allowPreview;
@Column
@Comment("是否允许下载")
@ColDefine(type = ColType.BOOLEAN)
private Boolean allowDownload;
@Column
@Comment("状态")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String status;
}
@@ -0,0 +1,124 @@
package com.budwk.app.zhgh.learning.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Index;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.entity.annotation.TableIndexes;
import org.nutz.dao.interceptor.annotation.PrevInsert;
import java.util.Date;
@Data
@Table("learning_study_record")
@TableIndexes({
@Index(name = "idx_learning_record_user_course_outline", fields = {"userId", "courseId", "outlineId"}, unique = true),
@Index(name = "idx_learning_record_union", fields = {"unionId"}, unique = false),
@Index(name = "idx_learning_record_course", fields = {"courseId"}, unique = false)
})
@EqualsAndHashCode(callSuper = true)
public class LearningStudyRecord extends BaseModel {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("学习人ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String userId;
@Column
@Comment("工号")
@ColDefine(type = ColType.VARCHAR, width = 120)
private String loginName;
@Column
@Comment("姓名")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String userName;
@Column
@Comment("所属分工会ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String unionId;
@Column
@Comment("所属分工会")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String unionName;
@Column
@Comment("课程ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String courseId;
@Column
@Comment("课程名称")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String courseName;
@Column
@Comment("章节ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String outlineId;
@Column
@Comment("章节名称")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String outlineName;
@Column
@Comment("第一次进入时间")
@ColDefine(type = ColType.DATETIME)
private Date firstStudyTime;
@Column
@Comment("最近学习时间")
@ColDefine(type = ColType.DATETIME)
private Date latestStudyTime;
@Column
@Comment("累计有效学习时长(秒)")
@ColDefine(type = ColType.INT)
private Integer studySeconds;
@Column
@Comment("最近播放位置(秒)")
@ColDefine(type = ColType.INT)
private Integer lastPositionSeconds;
@Column
@Comment("各资源最高已观看位置(JSON)")
@ColDefine(type = ColType.TEXT)
private String resourceMaxPositionData;
@Column
@Comment("要求学习时长(秒)")
@ColDefine(type = ColType.INT)
private Integer requiredSeconds;
@Column
@Comment("学习进度")
@ColDefine(type = ColType.INT)
private Integer progressPercent;
@Column
@Comment("完成状态")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String completeStatus;
@Column
@Comment("完成时间")
@ColDefine(type = ColType.DATETIME)
private Date completedAt;
}
@@ -0,0 +1,100 @@
package com.budwk.app.zhgh.learning.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.interceptor.annotation.PrevInsert;
@Data
@Table
@EqualsAndHashCode(callSuper = true)
public class LearningStudyRule extends BaseModel {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("所属课程ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String courseId;
@Column
@Comment("规则对象类型")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String targetType;
@Column
@Comment("规则对象ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String targetId;
@Column
@Comment("是否必学")
@ColDefine(type = ColType.BOOLEAN)
private Boolean required;
@Column
@Comment("学习方式")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String studyMode;
@Column
@Comment("完成规则")
@ColDefine(type = ColType.VARCHAR, width = 50)
private String completionRule;
@Column
@Comment("完成比例")
@ColDefine(type = ColType.INT)
private Integer completePercent;
@Column
@Comment("最少学习时长")
@ColDefine(type = ColType.INT)
private Integer minStudySeconds;
@Column
@Comment("解锁规则")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String unlockRule;
@Column
@Comment("是否允许跳过")
@ColDefine(type = ColType.BOOLEAN)
private Boolean allowSkip;
@Column
@Comment("是否允许拖动")
@ColDefine(type = ColType.BOOLEAN)
private Boolean allowDrag;
@Column
@Comment("暂停是否计时")
@ColDefine(type = ColType.BOOLEAN)
private Boolean pauseCountTime;
@Column
@Comment("页面隐藏是否计时")
@ColDefine(type = ColType.BOOLEAN)
private Boolean hiddenCountTime;
@Column
@Comment("长时间无操作是否计时")
@ColDefine(type = ColType.BOOLEAN)
private Boolean inactiveCountTime;
@Column
@Comment("状态")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String status;
}
@@ -0,0 +1,88 @@
package com.budwk.app.zhgh.learning.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Index;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.entity.annotation.TableIndexes;
import org.nutz.dao.interceptor.annotation.PrevInsert;
import java.util.Date;
@Data
@Table("learning_study_segment")
@TableIndexes({
@Index(name = "idx_learning_segment_record", fields = {"recordId"}, unique = false),
@Index(name = "idx_learning_segment_user_state", fields = {"userId", "state"}, unique = false)
})
@EqualsAndHashCode(callSuper = true)
public class LearningStudySegment extends BaseModel {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("学习记录ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String recordId;
@Column
@Comment("学习人ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String userId;
@Column
@Comment("课程ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String courseId;
@Column
@Comment("章节ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String outlineId;
@Column
@Comment("资源ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String resourceId;
@Column
@Comment("资源名称")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String resourceName;
@Column
@Comment("开始时间")
@ColDefine(type = ColType.DATETIME)
private Date startTime;
@Column
@Comment("结束时间")
@ColDefine(type = ColType.DATETIME)
private Date endTime;
@Column
@Comment("最近心跳时间")
@ColDefine(type = ColType.DATETIME)
private Date lastHeartbeatTime;
@Column
@Comment("本时段有效学习时长(秒)")
@ColDefine(type = ColType.INT)
private Integer activeSeconds;
@Column
@Comment("状态")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String state;
}
@@ -0,0 +1,22 @@
package com.budwk.app.zhgh.learning.param;
import com.budwk.app.base.param.PageForm;
import lombok.Data;
import lombok.EqualsAndHashCode;
@EqualsAndHashCode(callSuper = true)
@Data
public class LearningStatisticsPageForm extends PageForm {
private String courseId;
private String courseName;
private String unionId;
private String loginName;
private String userName;
private String completeStatus;
}
@@ -0,0 +1,42 @@
package com.budwk.app.zhgh.learning.service;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.zhgh.learning.models.LearningOutlineResource;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.regex.Pattern;
@IocBean
public class LearningFileAccessService {
@Inject
private Dao dao;
public boolean check(String fileId, boolean download, HttpServletResponse response) throws IOException {
if (StrUtil.isBlank(fileId)) {
response.sendError(400, "文件ID不能为空");
return false;
}
Pattern filePattern = Pattern.compile("(?<![a-zA-Z0-9])" + Pattern.quote(fileId) + "(?![a-zA-Z0-9])", Pattern.CASE_INSENSITIVE);
for (LearningOutlineResource resource : dao.query(LearningOutlineResource.class,
Cnd.where("fileData", "like", "%" + fileId + "%"))) {
if (!filePattern.matcher(resource.getFileData()).find()) {
continue;
}
StpUtil.checkPermission("learning.course.display");
boolean allowed = download ? Boolean.TRUE.equals(resource.getAllowDownload())
: !Boolean.FALSE.equals(resource.getAllowPreview());
if (!"enabled".equals(resource.getStatus()) || !allowed) {
response.sendError(403, download ? "该资料不允许下载" : "该资料不允许预览");
return false;
}
}
response.setHeader("Cache-Control", "no-store");
return true;
}
}
@@ -0,0 +1,175 @@
package com.budwk.app.zhgh.learning.service;
import com.budwk.app.zhgh.learning.models.LearningCourseOutline;
import com.budwk.app.zhgh.learning.models.LearningOutlineResource;
import com.budwk.app.zhgh.learning.models.LearningStudyRecord;
import com.budwk.app.zhgh.learning.models.LearningStudyRule;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json;
import org.nutz.lang.util.NutMap;
import java.util.Date;
import java.util.List;
@IocBean
public class LearningResourceProgressService {
@Inject
private Dao dao;
public LearningStudyRecord lock(String id) {
Sql sql = Sqls.create("SELECT * FROM learning_study_record WHERE id = @id FOR UPDATE");
sql.params().set("id", id);
sql.setEntity(dao.getEntity(LearningStudyRecord.class));
sql.setCallback(Sqls.callback.entity());
dao.execute(sql);
return sql.getObject(LearningStudyRecord.class);
}
public NutMap state(LearningStudyRecord record, LearningOutlineResource resource) {
NutMap totals = NutMap.NEW();
if (record != null) {
Sql sql = Sqls.create("""
SELECT COUNT(1) AS visits, COALESCE(SUM(activeSeconds), 0) AS studySeconds,
COALESCE(SUM(CASE WHEN state = 'finished' THEN 1 ELSE 0 END), 0) AS finishes
FROM learning_study_segment WHERE recordId = @recordId AND resourceId = @resourceId
""");
sql.params().set("recordId", record.getId()).set("resourceId", resource.getId());
sql.setCallback(Sqls.callback.map());
dao.execute(sql);
totals = sql.getObject(NutMap.class);
}
if (totals == null) totals = NutMap.NEW();
NutMap positions = positions(record);
String id = resource.getId();
int seconds = Math.max(0, totals.getInt("studySeconds", 0));
int required = Math.max(0, resource.getDurationSeconds() == null ? 0 : resource.getDurationSeconds());
boolean studied = totals.getInt("visits", 0) > 0;
boolean media = "video".equals(resource.getResourceType()) || "audio".equals(resource.getResourceType());
boolean completed = positions.getInt("completed:" + id, 0) == 1
|| (required > 0 && seconds >= required)
|| (!media && required == 0 && totals.getInt("finishes", 0) > 0);
int percent = completed ? 100 : required > 0 ? Math.min(99, (int) (seconds * 100.0 / required)) : 0;
int maxPosition = Math.max(0, positions.getInt(id, 0));
return NutMap.NEW().addv("resourceId", id).addv("studied", studied)
.addv("studySeconds", seconds).addv("requiredSeconds", required)
.addv("progressPercent", percent)
.addv("completeStatus", completed ? "completed" : studied ? "studying" : "not_started")
.addv("lastPositionSeconds", Math.max(0, positions.getInt("last:" + id, maxPosition)))
.addv("maxPositionSeconds", maxPosition);
}
// 保留原有资源最高位置键,附加独立续播位置和完成标记,不修改数据库结构。
public void savePosition(LearningStudyRecord record, String resourceId, Integer position, Integer maxPosition, boolean completed) {
NutMap positions = positions(record);
if (position != null && position >= 0) positions.put("last:" + resourceId, position);
if (maxPosition != null) positions.put(resourceId, Math.max(positions.getInt(resourceId, 0), maxPosition));
if (completed) positions.put("completed:" + resourceId, 1);
record.setResourceMaxPositionData(Json.toJson(positions));
}
public LearningStudyRule outlineRule(String outlineId) {
return dao.fetch(LearningStudyRule.class, Cnd.where("targetType", "=", "outline")
.and("targetId", "=", outlineId).and("status", "=", "enabled"));
}
public int requiredSeconds(String outlineId) {
LearningStudyRule rule = outlineRule(outlineId);
if (rule != null && "min_study_time".equals(rule.getCompletionRule())) {
return Math.max(0, rule.getMinStudySeconds() == null ? 0 : rule.getMinStudySeconds());
}
Sql sql = Sqls.create("SELECT COALESCE(SUM(durationSeconds), 0) FROM learning_outline_resource WHERE outlineId = @outlineId AND status = 'enabled' AND COALESCE(allowPreview, 1) = 1");
sql.params().set("outlineId", outlineId);
sql.setCallback(Sqls.callback.integer());
dao.execute(sql);
return Math.max(0, sql.getInt());
}
public NutMap outlineState(LearningStudyRecord record, LearningCourseOutline outline) {
List<LearningOutlineResource> resources = outline != null
&& "enabled".equals(outline.getStatus())
? dao.query(LearningOutlineResource.class, Cnd.where("outlineId", "=", outline.getId())
.and("courseId", "=", outline.getCourseId()).and("status", "=", "enabled")
.and(Cnd.exps("allowPreview", "=", true).or("allowPreview", "is", null))) : List.of();
int seconds = 0;
int totalPercent = 0;
boolean studied = false;
boolean completed = !resources.isEmpty();
for (LearningOutlineResource resource : resources) {
NutMap state = state(record, resource);
seconds += state.getInt("studySeconds", 0);
totalPercent += state.getInt("progressPercent", 0);
studied |= state.getBoolean("studied", false);
completed &= "completed".equals(state.getString("completeStatus"));
}
int percent = resources.isEmpty() ? 0 : totalPercent / resources.size();
LearningStudyRule rule = outline == null ? null : outlineRule(outline.getId());
int required = resources.isEmpty() ? 0 : requiredSeconds(outline.getId());
String mode = rule == null ? "all_required_resource" : rule.getCompletionRule();
if (!resources.isEmpty() && "resource_progress".equals(mode)) {
int threshold = rule.getCompletePercent() == null ? 100 : Math.max(1, Math.min(100, rule.getCompletePercent()));
completed = studied && totalPercent >= threshold * resources.size();
percent = (int) Math.min(100, totalPercent * 100L / (resources.size() * threshold));
} else if (!resources.isEmpty() && "min_study_time".equals(mode)) {
completed = studied && required > 0 && seconds >= required;
percent = required > 0 ? (int) Math.min(100, seconds * 100L / required) : 0;
}
return NutMap.NEW().addv("counted", !resources.isEmpty()).addv("studySeconds", seconds)
.addv("requiredSeconds", required).addv("progressPercent", completed ? 100 : Math.min(99, percent))
.addv("completeStatus", completed ? "completed" : studied ? "studying" : "not_started");
}
public NutMap courseState(String courseId, String userId) {
List<LearningStudyRecord> records = dao.query(LearningStudyRecord.class,
Cnd.where("courseId", "=", courseId).and("userId", "=", userId));
java.util.Map<String, LearningStudyRecord> recordMap = new java.util.HashMap<>();
for (LearningStudyRecord record : records) recordMap.put(record.getOutlineId(), record);
int count = 0, completed = 0, percent = 0, seconds = 0;
for (LearningCourseOutline outline : dao.query(LearningCourseOutline.class,
Cnd.where("courseId", "=", courseId).and("status", "=", "enabled"))) {
NutMap state = outlineState(recordMap.get(outline.getId()), outline);
if (!state.getBoolean("counted", false)) continue;
count++;
percent += state.getInt("progressPercent", 0);
seconds += state.getInt("studySeconds", 0);
if ("completed".equals(state.getString("completeStatus"))) completed++;
}
return NutMap.NEW().addv("outlineCount", count).addv("completedOutlineCount", completed)
.addv("progressPercent", count == 0 ? 0 : percent / count).addv("studySeconds", seconds);
}
public void refreshRecords(Cnd condition) {
for (LearningStudyRecord record : dao.query(LearningStudyRecord.class, condition)) {
refreshOutline(record, requiredSeconds(record.getOutlineId()));
}
}
public void refreshOutline(String outlineId) {
refreshRecords(Cnd.where("outlineId", "=", outlineId));
}
public void refreshOutline(LearningStudyRecord record, int requiredSeconds) {
NutMap state = outlineState(record, dao.fetch(LearningCourseOutline.class, record.getOutlineId()));
boolean completed = "completed".equals(state.getString("completeStatus"));
dao.update(LearningStudyRecord.class, Chain.make("studySeconds", state.getInt("studySeconds", 0))
.add("requiredSeconds", state.getInt("requiredSeconds", 0)).add("progressPercent", state.getInt("progressPercent", 0))
.add("completeStatus", state.getString("completeStatus"))
.add("completedAt", completed ? record.getCompletedAt() == null ? new Date() : record.getCompletedAt() : null),
Cnd.where("id", "=", record.getId()));
}
private NutMap positions(LearningStudyRecord record) {
if (record == null || record.getResourceMaxPositionData() == null) return NutMap.NEW();
try {
NutMap positions = Json.fromJson(NutMap.class, record.getResourceMaxPositionData());
return positions == null ? NutMap.NEW() : positions;
} catch (Exception ignored) {
return NutMap.NEW();
}
}
}
@@ -34,6 +34,7 @@ import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param; import org.nutz.mvc.annotation.Param;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid; import javax.validation.Valid;
import java.util.List; import java.util.List;
@@ -148,6 +149,15 @@ public class SpecialStaffManageController {
} }
@At
@Ok("void")
@ApiOperation("特别人员管理,导出特别人员")
@SaCheckPermission("staff.special.manage")
public void doExport(SpecialStaffPageForm pageForm, HttpServletResponse response) {
specialStaffManageService.doExport(pageForm, response);
}
@At @At
@ApiOperation("特别人员管理,查询用户") @ApiOperation("特别人员管理,查询用户")
@SaCheckPermission("staff.special.manage") @SaCheckPermission("staff.special.manage")
@@ -6,6 +6,8 @@ import com.budwk.app.zhgh.staffmanage.specialstaff.model.SpecialStaff;
import com.budwk.app.zhgh.staffmanage.specialstaff.param.SpecialStaffPageForm; import com.budwk.app.zhgh.staffmanage.specialstaff.param.SpecialStaffPageForm;
import org.nutz.lang.util.NutMap; import org.nutz.lang.util.NutMap;
import javax.servlet.http.HttpServletResponse;
/** /**
* @version 1.0 * @version 1.0
* @Author zzr * @Author zzr
@@ -35,4 +37,11 @@ public interface SpecialStaffManageService extends BaseService<SpecialStaff> {
* @return * @return
*/ */
NutMap getSpecialStaffUserInfo(String userId); NutMap getSpecialStaffUserInfo(String userId);
/**
* 导出特别人员
* @param pageForm 查询条件
* @param response 响应
*/
void doExport(SpecialStaffPageForm pageForm, HttpServletResponse response);
} }
@@ -1,23 +1,38 @@
package com.budwk.app.zhgh.staffmanage.specialstaff.service.impl; package com.budwk.app.zhgh.staffmanage.specialstaff.service.impl;
import cn.afterturn.easypoi.excel.ExcelExportUtil;
import cn.afterturn.easypoi.excel.entity.ExportParams;
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.constant.RoleConstant; import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.page.Pagination; import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.base.utils.CommonDownloadUtil;
import com.budwk.app.base.utils.PageUtil; import com.budwk.app.base.utils.PageUtil;
import com.budwk.app.sys.models.Sys_dict;
import com.budwk.app.sys.services.SysDictService;
import com.budwk.app.web.commons.auth.utils.AuthUtil; import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.staffmanage.specialstaff.model.SpecialStaff; import com.budwk.app.zhgh.staffmanage.specialstaff.model.SpecialStaff;
import com.budwk.app.zhgh.staffmanage.specialstaff.param.SpecialStaffPageForm; import com.budwk.app.zhgh.staffmanage.specialstaff.param.SpecialStaffPageForm;
import com.budwk.app.zhgh.staffmanage.specialstaff.service.SpecialStaffManageService; import com.budwk.app.zhgh.staffmanage.specialstaff.service.SpecialStaffManageService;
import org.apache.poi.ss.usermodel.Workbook;
import org.nutz.dao.Cnd; import org.nutz.dao.Cnd;
import org.nutz.dao.Dao; import org.nutz.dao.Dao;
import org.nutz.dao.Sqls; import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql; import org.nutz.dao.sql.Sql;
import org.nutz.dao.util.cri.SqlExpressionGroup; import org.nutz.dao.util.cri.SqlExpressionGroup;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap; import org.nutz.lang.util.NutMap;
import javax.servlet.http.HttpServletResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/** /**
* @version 1.0 * @version 1.0
* @Author zzr * @Author zzr
@@ -27,12 +42,21 @@ import org.nutz.lang.util.NutMap;
*/ */
@IocBean(args = {"refer:dao"}) @IocBean(args = {"refer:dao"})
public class SpecialStaffManageServiceImpl extends BaseServiceImpl<SpecialStaff> implements SpecialStaffManageService { public class SpecialStaffManageServiceImpl extends BaseServiceImpl<SpecialStaff> implements SpecialStaffManageService {
@Inject
private SysDictService sysDictService;
public SpecialStaffManageServiceImpl(Dao dao) { public SpecialStaffManageServiceImpl(Dao dao) {
super(dao); super(dao);
} }
@Override @Override
public Pagination pageData(SpecialStaffPageForm pageForm) { public Pagination pageData(SpecialStaffPageForm pageForm) {
Sql sql = getSql(pageForm);
return listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql);
}
private Sql getSql(SpecialStaffPageForm pageForm) {
Sql sql = Sqls.create(""" Sql sql = Sqls.create("""
SELECT SELECT
s.*, s.*,
@@ -77,7 +101,7 @@ public class SpecialStaffManageServiceImpl extends BaseServiceImpl<SpecialStaff>
cnd.andEX("u.personType", "=", pageForm.getPersonType()); cnd.andEX("u.personType", "=", pageForm.getPersonType());
cnd.andEX("s.specialStaffType", "=", pageForm.getSpecialStaffType()); cnd.andEX("s.specialStaffType", "=", pageForm.getSpecialStaffType());
sql.setCondition(cnd); sql.setCondition(cnd);
return listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); return sql;
} }
@@ -133,4 +157,29 @@ public class SpecialStaffManageServiceImpl extends BaseServiceImpl<SpecialStaff>
dao().execute(sql); dao().execute(sql);
return (NutMap) sql.getResult(); return (NutMap) sql.getResult();
} }
@Override
public void doExport(SpecialStaffPageForm pageForm, HttpServletResponse response) {
List<NutMap> list = listMap(getSql(pageForm));
Map<String, String> specialStaffTypeMap = sysDictService.getSubListByCode("SPECIAL_STAFF_TYPE")
.stream()
.collect(Collectors.toMap(Sys_dict::getCode, Sys_dict::getName));
list.forEach(row -> row.put("specialStaffType", specialStaffTypeMap.getOrDefault(row.getString("specialStaffType"), row.getString("specialStaffType"))));
List<ExcelExportEntity> exportEntities = new ArrayList<>();
exportEntities.add(new ExcelExportEntity("工号", "loginname", 20));
exportEntities.add(new ExcelExportEntity("姓名", "username", 20));
exportEntities.add(new ExcelExportEntity("性别", "sex", 20));
exportEntities.add(new ExcelExportEntity("联系电话", "mobile", 20));
exportEntities.add(new ExcelExportEntity("人员类型", "personType", 20));
exportEntities.add(new ExcelExportEntity("在职状态", "userState", 20));
exportEntities.add(new ExcelExportEntity("所属工会", "personnelRelationUnionName", 20));
exportEntities.add(new ExcelExportEntity("所属单位", "personnelRelationUnitName", 30));
exportEntities.add(new ExcelExportEntity("特别人员类型", "specialStaffType", 20));
ExportParams exportParams = new ExportParams();
exportParams.setType(ExcelType.XSSF);
Workbook workbook = ExcelExportUtil.exportExcel(exportParams, exportEntities, list);
CommonDownloadUtil.download("特别人员名单.xlsx", workbook, response);
}
} }
@@ -116,6 +116,14 @@ public class WelfareListController {
welfareListService.exportAddress(pageForm, response); welfareListService.exportAddress(pageForm, response);
} }
@At
@SaCheckPermission("welfare.list.mange")
@ApiOperation("导出重复身份证号人员")
@Ok("void")
public void exportRepeatIdCard(WelfareListPageForm pageForm, HttpServletResponse response) {
welfareListService.exportRepeatIdCard(pageForm, response);
}
@At @At
@SaCheckPermission("welfare.list.mange") @SaCheckPermission("welfare.list.mange")
@ApiOperation("导出") @ApiOperation("导出")
@@ -15,12 +15,14 @@ import com.budwk.app.zhgh.welfare.model.WelfareUserSelection;
import com.budwk.app.zhgh.welfare.param.WelfareSelectionSituationPageForm; import com.budwk.app.zhgh.welfare.param.WelfareSelectionSituationPageForm;
import com.budwk.app.zhgh.welfare.service.WelfareListService; import com.budwk.app.zhgh.welfare.service.WelfareListService;
import com.budwk.app.zhgh.welfare.service.WelfareSelectionSituationService; import com.budwk.app.zhgh.welfare.service.WelfareSelectionSituationService;
import com.budwk.app.zhgh.welfare.service.WelfareSingleService;
import com.budwk.app.zhgh.welfare.service.WelfareStatisticsService; import com.budwk.app.zhgh.welfare.service.WelfareStatisticsService;
import com.jogamp.common.util.SecurityUtil; import com.jogamp.common.util.SecurityUtil;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd; import org.nutz.dao.Cnd;
import org.nutz.dao.Dao; import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop; import org.nutz.ioc.aop.Aop;
@@ -49,6 +51,8 @@ public class WelfareSelectionSituationController {
private WelfareStatisticsService welfareStatisticsService; private WelfareStatisticsService welfareStatisticsService;
@Inject @Inject
private WelfareSelectionSituationService situationService; private WelfareSelectionSituationService situationService;
@Inject
private WelfareSingleService welfareSingleService;
@At("") @At("")
@Ok("beetl:/platform/zhgh/welfare/selectionSituation/index.html") @Ok("beetl:/platform/zhgh/welfare/selectionSituation/index.html")
@@ -74,15 +78,31 @@ public class WelfareSelectionSituationController {
@ApiOperation("获取某个用户选择信息") @ApiOperation("获取某个用户选择信息")
public Result getUserSelection(String projectId, String userId) { public Result getUserSelection(String projectId, String userId) {
List<WelfareUserSelection> list = dao.query(WelfareUserSelection.class, Cnd.where(WelfareUserSelection::getWelfareId, "=", projectId).and(WelfareUserSelection::getSelectUserId, "=", userId)); List<WelfareUserSelection> list = dao.query(WelfareUserSelection.class, Cnd.where(WelfareUserSelection::getWelfareId, "=", projectId).and(WelfareUserSelection::getSelectUserId, "=", userId));
Sys_user user = dao.fetch(Sys_user.class, userId);
String welfareMobile = user == null ? null : user.getWelfareMobile();
// 管理员代选详情返回被代选用户的当前福利电话。
list.forEach(selection -> selection.setWelfareMobile(welfareMobile));
return Result.success(list); return Result.success(list);
} }
/**
* 管理员为指定用户提交福利选择。
*
* @param selections 选择项数组,每项包含选项 ID、选择数量,以及按项目要求填写的地址或签名
* @param projectId 福利项目 ID
* @param userId 被代选用户 ID
* @param welfareMobile 被代选用户福利电话,须为 11 位手机号码
* @return Result;成功时返回“选择成功”,校验失败时返回对应错误信息
*/
@At @At
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
@SLog(type = "welfare", tag = "选择福利", msg = "代选福利") @SLog(type = "welfare", tag = "选择福利", msg = "代选福利")
@SaCheckPermission("welfare.selection.situation") @SaCheckPermission("welfare.selection.situation")
public Result confirmSelect(@Param("selections") WelfareUserSelection[] selections, String projectId, String userId) { public Result confirmSelect(@Param("selections") WelfareUserSelection[] selections, String projectId, String userId, String welfareMobile) {
if (welfareMobile == null || !welfareMobile.matches("^1[3456789]\\d{9}$")) {
return Result.error("请输入正确的福利电话");
}
WelfareProject project = dao.fetch(WelfareProject.class, projectId); WelfareProject project = dao.fetch(WelfareProject.class, projectId);
if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) { if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) {
if(project.getChoiceTimeEnd().getTime()<new Date().getTime()){ if(project.getChoiceTimeEnd().getTime()<new Date().getTime()){
@@ -95,21 +115,37 @@ public class WelfareSelectionSituationController {
} }
int sum = Arrays.stream(selections).mapToInt(selection -> Objects.requireNonNullElse(selection.getSelectNum(), 0)).sum(); int sum = Arrays.stream(selections).mapToInt(selection -> Objects.requireNonNullElse(selection.getSelectNum(), 0)).sum();
if (sum > project.getMultiSelectNum()) { // 历史福利项目可能未配置“最多选几个”,按单选项目默认最多选择 1 份处理。
return Result.error("选择的数量不能超过" + project.getMultiSelectNum()); Integer multiSelectNum = Objects.requireNonNullElse(project.getMultiSelectNum(), 1);
if (sum > multiSelectNum) {
return Result.error("选择的数量不能超过" + multiSelectNum);
} }
// 管理员代选时同步维护被代选用户的福利电话,后续福利业务统一读取该字段。
dao.update(Sys_user.class, Chain.make("welfareMobile", welfareMobile), Cnd.where("id", "=", userId));
//删除上次选择的 //删除上次选择的
dao.clear(WelfareUserSelection.class, Cnd.where(WelfareUserSelection::getWelfareId, "=", projectId).and(WelfareUserSelection::getSelectUserId, "=", userId)); dao.clear(WelfareUserSelection.class, Cnd.where(WelfareUserSelection::getWelfareId, "=", projectId).and(WelfareUserSelection::getSelectUserId, "=", userId));
for (WelfareUserSelection welfareUserSelection : selections) { for (WelfareUserSelection welfareUserSelection : selections) {
welfareUserSelection.setWelfareId(projectId); welfareUserSelection.setWelfareId(projectId);
welfareUserSelection.setSelectUserId(userId); welfareUserSelection.setSelectUserId(userId);
welfareUserSelection.setSelectTime(new Date()); welfareUserSelection.setSelectTime(new Date());
welfareUserSelection.setIsSelectByAdmin(true);
} }
dao.insert(selections); dao.insert(selections);
return Result.success("选择成功"); return Result.success("选择成功");
} }
@At
@SaCheckPermission("welfare.selection.situation")
@Aop(TransAop.READ_COMMITTED)
@SLog(tag = "选择情况", msg = "管理员代选福利")
@ApiOperation("管理员代选福利")
public Result doSelectByAdmin(@Valid String projectId, @Param("welfareOptions") @Valid String welfareOptions) {
welfareSingleService.doSelectByAdmin(projectId, welfareOptions);
return Result.success();
}
@At @At
@SaCheckPermission("welfare.selection.situation") @SaCheckPermission("welfare.selection.situation")
@@ -119,11 +155,17 @@ public class WelfareSelectionSituationController {
situationService.exportXlsx(pageForm, response); situationService.exportXlsx(pageForm, response);
} }
/**
* 获取被代选用户的福利电话。
*
* @param userId 被代选用户 ID
* @return Result,data 为字符串类型的福利电话;用户不存在或尚未维护时为空
*/
@At @At
@SaCheckLogin @SaCheckLogin
public Result getMobileByUserId(String userId) { public Result getWelfareMobileByUserId(String userId) {
Sys_user user = dao.fetch(Sys_user.class, userId); Sys_user user = dao.fetch(Sys_user.class, userId);
return Result.success().addData(user.getMobile()); return Result.success().addData(user == null ? null : user.getWelfareMobile());
} }
} }
@@ -71,8 +71,8 @@ public class WelfareStatisticsController {
@At @At
@SaCheckPermission("welfare.statistics") @SaCheckPermission("welfare.statistics")
public Result pageData(String projectId, String unionId) { public Result pageData(String projectId, String unionId, Boolean isSelectByAdmin) {
NutMap data = welfareStatisticsService.pageData(projectId, unionId); NutMap data = welfareStatisticsService.pageData(projectId, unionId, isSelectByAdmin);
return Result.success(data); return Result.success(data);
} }
@@ -80,8 +80,8 @@ public class WelfareStatisticsController {
@At @At
@SaCheckPermission("welfare.statistics") @SaCheckPermission("welfare.statistics")
@ApiOperation("查询某分工会已选择人员") @ApiOperation("查询某分工会已选择人员")
public Result selectedUnionUserPageData(@Valid PageForm pageForm, String projectId, String unionId) { public Result selectedUnionUserPageData(@Valid PageForm pageForm, String projectId, String unionId, Boolean isSelectByAdmin) {
Pagination pagination = welfareStatisticsService.selectedUnionUserPageData(pageForm, projectId, unionId); Pagination pagination = welfareStatisticsService.selectedUnionUserPageData(pageForm, projectId, unionId, isSelectByAdmin);
return Result.success(pagination); return Result.success(pagination);
} }
@@ -89,8 +89,8 @@ public class WelfareStatisticsController {
@Ok("void") @Ok("void")
@SaCheckPermission("welfare.statistics") @SaCheckPermission("welfare.statistics")
@ApiOperation("导出某分工会已选择人员") @ApiOperation("导出某分工会已选择人员")
public void exportSelectedUnionUser(String projectId, String unionId, HttpServletResponse response) { public void exportSelectedUnionUser(String projectId, String unionId, Boolean isSelectByAdmin, HttpServletResponse response) {
welfareStatisticsService.exportSelectedUnionUser(projectId, unionId, response); welfareStatisticsService.exportSelectedUnionUser(projectId, unionId, isSelectByAdmin, response);
} }
@At @At
@@ -6,6 +6,7 @@ import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.page.Pagination; import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm; import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result; import com.budwk.app.base.result.Result;
import com.budwk.app.sys.models.Sys_user;
import com.budwk.app.web.commons.auth.utils.AuthUtil; import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.welfare.model.WelfareList; import com.budwk.app.zhgh.welfare.model.WelfareList;
@@ -82,11 +83,22 @@ public class WelfareUserSelectController {
return Result.success(pagination); return Result.success(pagination);
} }
/**
* 提交当前用户的福利选择。
*
* @param selections 选择项数组,每项包含选项 ID、选择数量,以及按项目要求填写的地址或签名
* @param projectId 福利项目 ID
* @param welfareMobile 当前用户福利电话,须为 11 位手机号码
* @return Result;成功时返回“选择成功”,校验失败时返回对应错误信息
*/
@At @At
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
@SLog(type = "welfare", tag = "选择福利", msg = "福利") @SLog(type = "welfare", tag = "选择福利", msg = "福利")
@SaCheckPermission("welfare.user.select") @SaCheckPermission("welfare.user.select")
public Result confirmSelect(@Param("selections") WelfareUserSelection[] selections, String projectId) { public Result confirmSelect(@Param("selections") WelfareUserSelection[] selections, String projectId, String welfareMobile) {
if (welfareMobile == null || !welfareMobile.matches("^1[3456789]\\d{9}$")) {
return Result.error("请输入正确的福利电话");
}
WelfareProject project = dao.fetch(WelfareProject.class, projectId); WelfareProject project = dao.fetch(WelfareProject.class, projectId);
if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) { if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) {
if (project.getChoiceTimeEnd().getTime() < new Date().getTime()) { if (project.getChoiceTimeEnd().getTime() < new Date().getTime()) {
@@ -105,6 +117,9 @@ public class WelfareUserSelectController {
return Result.error("选择的数量不能超过" + project.getMultiSelectNum()); return Result.error("选择的数量不能超过" + project.getMultiSelectNum());
} }
// 福利电话以用户资料为唯一数据源,福利选择记录不再重复保存普通 mobile 字段。
dao.update(Sys_user.class, Chain.make("welfareMobile", welfareMobile), Cnd.where("id", "=", SecurityUtil.getUserId()));
//删除上次选择的 //删除上次选择的
dao.clear(WelfareUserSelection.class, Cnd.where(WelfareUserSelection::getWelfareId, "=", projectId).and(WelfareUserSelection::getSelectUserId, "=", SecurityUtil.getUserId())); dao.clear(WelfareUserSelection.class, Cnd.where(WelfareUserSelection::getWelfareId, "=", projectId).and(WelfareUserSelection::getSelectUserId, "=", SecurityUtil.getUserId()));
for (WelfareUserSelection welfareUserSelection : selections) { for (WelfareUserSelection welfareUserSelection : selections) {
@@ -121,7 +136,24 @@ public class WelfareUserSelectController {
@ApiOperation("获取用户选择信息") @ApiOperation("获取用户选择信息")
public Result getUserSelection(String projectId) { public Result getUserSelection(String projectId) {
List<WelfareUserSelection> list = dao.query(WelfareUserSelection.class, Cnd.where(WelfareUserSelection::getWelfareId, "=", projectId).and(WelfareUserSelection::getSelectUserId, "=", SecurityUtil.getUserId())); List<WelfareUserSelection> list = dao.query(WelfareUserSelection.class, Cnd.where(WelfareUserSelection::getWelfareId, "=", projectId).and(WelfareUserSelection::getSelectUserId, "=", SecurityUtil.getUserId()));
Sys_user user = dao.fetch(Sys_user.class, SecurityUtil.getUserId());
String welfareMobile = user == null ? null : user.getWelfareMobile();
// 选择记录返回当前福利电话,供 PC 与 H5 的选择详情统一回显。
list.forEach(selection -> selection.setWelfareMobile(welfareMobile));
return Result.success(list); return Result.success(list);
} }
/**
* 获取当前用户的福利电话。
*
* @return 字符串类型的福利电话;用户不存在或尚未维护时返回空值
*/
@At
@SaCheckPermission("welfare.user.select")
@ApiOperation("获取当前用户福利电话")
public Result getWelfareMobile() {
Sys_user user = dao.fetch(Sys_user.class, SecurityUtil.getUserId());
return Result.success().addData(user == null ? null : user.getWelfareMobile());
}
} }
@@ -222,6 +222,8 @@ public class WelfareProject extends BaseModel implements SysHomeConvert {
sysHomeActivity.setAllowUserSql("select userId from welfare_list where projectId = '" + this.getId() + "' and userId = @userId"); sysHomeActivity.setAllowUserSql("select userId from welfare_list where projectId = '" + this.getId() + "' and userId = @userId");
sysHomeActivity.setEnable(true); sysHomeActivity.setEnable(true);
sysHomeActivity.setClassPath(this.getClass().getPackageName() + this.getClass().getName()); sysHomeActivity.setClassPath(this.getClass().getPackageName() + this.getClass().getName());
sysHomeActivity.setCreatedAt(this.getCreatedAt());
sysHomeActivity.setUpdatedAt(this.getUpdatedAt());
return sysHomeActivity; return sysHomeActivity;
} }
} }
@@ -81,10 +81,22 @@ public class WelfareUserSelection extends BaseModel implements Serializable {
private Integer selectNum; private Integer selectNum;
@Column @Column
@Comment("手机号") @Comment("是否管理员代选")
@ColDefine(type = ColType.BOOLEAN)
@Default("0")
private Boolean isSelectByAdmin;
@Deprecated
@Column
@Comment("历史联系电话(福利选择流程不再使用)")
@ColDefine(type = ColType.VARCHAR, width = 20) @ColDefine(type = ColType.VARCHAR, width = 20)
private String mobile; private String mobile;
/**
* 当前用户福利电话,仅用于福利选择查询结果回显,不映射选择记录表字段。
*/
private String welfareMobile;
@Column @Column
@Comment("物流信息") @Comment("物流信息")
@ColDefine(type = ColType.MYSQL_JSON) @ColDefine(type = ColType.MYSQL_JSON)
@@ -32,4 +32,7 @@ public class WelfareSelectionSituationPageForm extends PageForm {
@ApiModelProperty("是否已选择") @ApiModelProperty("是否已选择")
private Boolean isSelect; private Boolean isSelect;
@ApiModelProperty("是否管理员代选")
private Boolean isSelectByAdmin;
} }
@@ -53,6 +53,13 @@ public interface WelfareListService extends BaseService<WelfareList> {
*/ */
void exportAddress(WelfareListPageForm pageForm, HttpServletResponse response); void exportAddress(WelfareListPageForm pageForm, HttpServletResponse response);
/**
* 导出重复身份证号人员
* @param pageForm
* @param response
*/
void exportRepeatIdCard(WelfareListPageForm pageForm, HttpServletResponse response);
/** /**
* 导出 * 导出
* @param pageForm * @param pageForm
@@ -16,7 +16,7 @@ public interface WelfareStatisticsService extends BaseService<WelfareProject> {
* @param unionId * @param unionId
* @return * @return
*/ */
NutMap pageData(String projectId, String unionId); NutMap pageData(String projectId, String unionId, Boolean isSelectByAdmin);
/** /**
* 查询某分工会已选择人员 * 查询某分工会已选择人员
@@ -24,7 +24,7 @@ public interface WelfareStatisticsService extends BaseService<WelfareProject> {
* @param projectId * @param projectId
* @param unionId * @param unionId
*/ */
Pagination selectedUnionUserPageData(PageForm pageForm, String projectId, String unionId); Pagination selectedUnionUserPageData(PageForm pageForm, String projectId, String unionId, Boolean isSelectByAdmin);
/** /**
* 导出某分工会已选择人员 * 导出某分工会已选择人员
@@ -32,7 +32,7 @@ public interface WelfareStatisticsService extends BaseService<WelfareProject> {
* @param unionId * @param unionId
* @param response * @param response
*/ */
void exportSelectedUnionUser(String projectId, String unionId, HttpServletResponse response); void exportSelectedUnionUser(String projectId, String unionId, Boolean isSelectByAdmin, HttpServletResponse response);
/** /**
* Pagination * Pagination
@@ -157,7 +157,7 @@ public class WelfareListServiceImpl extends BaseServiceImpl<WelfareList> impleme
SELECT SELECT
u.loginname, u.loginname,
u.username, u.username,
u.mobile, u.welfareMobile,
un.name unionname, un.name unionname,
it.name unitname, it.name unitname,
IF(LOCATE('undefined',wpus.receiveAddress)>0,NULL,wpus.receiveAddress) as receiveAddress, IF(LOCATE('undefined',wpus.receiveAddress)>0,NULL,wpus.receiveAddress) as receiveAddress,
@@ -187,7 +187,7 @@ public class WelfareListServiceImpl extends BaseServiceImpl<WelfareList> impleme
List<ExcelExportEntity> exportEntities = new ArrayList<>(); List<ExcelExportEntity> exportEntities = new ArrayList<>();
exportEntities.add(new ExcelExportEntity("工号", "loginname", 20)); exportEntities.add(new ExcelExportEntity("工号", "loginname", 20));
exportEntities.add(new ExcelExportEntity("姓名", "username", 20)); exportEntities.add(new ExcelExportEntity("姓名", "username", 20));
exportEntities.add(new ExcelExportEntity("电话号码", "mobile", 20)); exportEntities.add(new ExcelExportEntity("福利电话", "welfareMobile", 20));
exportEntities.add(new ExcelExportEntity("所在分工会", "unionname", 30)); exportEntities.add(new ExcelExportEntity("所在分工会", "unionname", 30));
exportEntities.add(new ExcelExportEntity("所在单位", "unitname", 50)); exportEntities.add(new ExcelExportEntity("所在单位", "unitname", 50));
exportEntities.add(new ExcelExportEntity("选择份数", "selectNum", 20)); exportEntities.add(new ExcelExportEntity("选择份数", "selectNum", 20));
@@ -629,6 +629,72 @@ public class WelfareListServiceImpl extends BaseServiceImpl<WelfareList> impleme
} }
} }
@Override
public void exportRepeatIdCard(WelfareListPageForm pageForm, HttpServletResponse response) {
Sql sql = Sqls.create("""
SELECT
t1.*,
t2.loginname,
t2.username,
t2.idCard,
t2.sex,
DATE_FORMAT(t2.birthday, '%Y-%m-%d') AS birthday
FROM
`welfare_list` t1
LEFT JOIN sys_user t2 ON t2.id = t1.userId
$condition
""");
Cnd cnd = Cnd.NEW();
cnd.and("t1.projectId", "=", pageForm.getProjectId());
cnd.and("t2.idCard", "in", Sqls.create("select idCard from sys_user where idCard is not null and idCard != '' group by idCard having count(1) > 1"));
if (StrUtil.isNotBlank(pageForm.getUserName())) {
cnd.where().andLike("t2.username", pageForm.getUserName());
}
if (StrUtil.isNotBlank(pageForm.getLoginName())) {
cnd.where().andLike("t2.loginname", pageForm.getLoginName());
}
cnd.andEX("t2.sex", "=", pageForm.getSex());
cnd.andEX("t2.birthday", "=", pageForm.getBirthday());
if (pageForm.getBirthMonths() != null && pageForm.getBirthMonths().length > 0) {
cnd.andEX("MONTH(t2.birthday)", "in", pageForm.getBirthMonths());
}
cnd.andEX("t1.welfareUnitId", "in", pageForm.getUnitIds());
cnd.andEX("t1.welfareUnionId", "=", pageForm.getUnionId());
cnd.andEX("t1.personType", "in", pageForm.getPersonTypes());
cnd.andEX("t1.preparedBy", "in", pageForm.getPreparedBys());
cnd.andEX("t1.userState", "in", pageForm.getUserStates());
cnd.asc("t2.idCard");
cnd.asc("t1.welfareUnionName");
cnd.asc("t1.welfareUnitName");
sql.setCondition(cnd);
List<NutMap> list = listMap(sql);
try {
List<ExcelExportEntity> exportEntities = new ArrayList<>();
exportEntities.add(new ExcelExportEntity("工号", "loginname", 20));
exportEntities.add(new ExcelExportEntity("姓名", "username", 20));
exportEntities.add(new ExcelExportEntity("身份证号", "idCard", 30));
exportEntities.add(new ExcelExportEntity("性别", "sex", 20));
exportEntities.add(new ExcelExportEntity("出生日期", "birthday", 20));
exportEntities.add(new ExcelExportEntity("人员类型", "personType", 20));
exportEntities.add(new ExcelExportEntity("聘用方式", "preparedBy", 20));
exportEntities.add(new ExcelExportEntity("在职状态", "userState", 20));
exportEntities.add(new ExcelExportEntity("所属工会", "welfareUnionName", 20));
exportEntities.add(new ExcelExportEntity("所属单位", "welfareUnitName", 30));
exportEntities.add(new ExcelExportEntity("备注", "remark", 30));
ExportParams exportParams = new ExportParams();
exportParams.setType(ExcelType.XSSF);
Workbook workbook = ExcelExportUtil.exportExcel(exportParams, exportEntities, list);
CommonDownloadUtil.download("重复身份证号人员.xlsx", workbook, response);
} catch (Exception e) {
e.printStackTrace();
}
}
@Override @Override
public void exportXlsx(WelfareListPageForm pageForm, HttpServletResponse response) { public void exportXlsx(WelfareListPageForm pageForm, HttpServletResponse response) {
Sql sql = Sqls.create(""" Sql sql = Sqls.create("""
@@ -637,6 +703,7 @@ public class WelfareListServiceImpl extends BaseServiceImpl<WelfareList> impleme
t2.loginname, t2.loginname,
t2.username, t2.username,
t2.sex, t2.sex,
t2.welfareMobile,
DATE_FORMAT(t2.birthday, '%Y-%m-%d') AS birthday DATE_FORMAT(t2.birthday, '%Y-%m-%d') AS birthday
FROM FROM
`welfare_list` t1 `welfare_list` t1
@@ -699,7 +766,7 @@ public class WelfareListServiceImpl extends BaseServiceImpl<WelfareList> impleme
exportEntities.add(new ExcelExportEntity("工号", "loginname", 20)); exportEntities.add(new ExcelExportEntity("工号", "loginname", 20));
exportEntities.add(new ExcelExportEntity("姓名", "username", 20)); exportEntities.add(new ExcelExportEntity("姓名", "username", 20));
exportEntities.add(new ExcelExportEntity("性别", "sex", 20)); exportEntities.add(new ExcelExportEntity("性别", "sex", 20));
exportEntities.add(new ExcelExportEntity("联系电话", "mobile", 20)); exportEntities.add(new ExcelExportEntity("福利电话", "welfareMobile", 20));
exportEntities.add(new ExcelExportEntity("身份证号", "idCard", 30)); exportEntities.add(new ExcelExportEntity("身份证号", "idCard", 30));
exportEntities.add(new ExcelExportEntity("在职状态", "userState", 20)); exportEntities.add(new ExcelExportEntity("在职状态", "userState", 20));
exportEntities.add(new ExcelExportEntity("聘用方式", "preparedBy", 20)); exportEntities.add(new ExcelExportEntity("聘用方式", "preparedBy", 20));
@@ -37,6 +37,15 @@ public class WelfareSelectionSituationServiceImpl extends BaseServiceImpl<Welfar
super(dao); super(dao);
} }
private void appendSelectByAdminCnd(Cnd cnd, Boolean isSelectByAdmin) {
if (Boolean.TRUE.equals(isSelectByAdmin)) {
cnd.and("t2.isSelectByAdmin", "=", true);
} else if (Boolean.FALSE.equals(isSelectByAdmin)) {
cnd.and("t2.id", "IS NOT", null);
cnd.and(Cnd.exps("t2.isSelectByAdmin", "=", false).or("t2.isSelectByAdmin", "is", null));
}
}
@Override @Override
public Pagination pageData(WelfareSelectionSituationPageForm pageForm) { public Pagination pageData(WelfareSelectionSituationPageForm pageForm) {
Sql sql = Sqls.create(""" Sql sql = Sqls.create("""
@@ -48,8 +57,11 @@ public class WelfareSelectionSituationServiceImpl extends BaseServiceImpl<Welfar
t1.welfareUnitName, t1.welfareUnitName,
t1.welfareUnitId, t1.welfareUnitId,
GROUP_CONCAT(DISTINCT t3.optionName) AS selectedOptions, GROUP_CONCAT(DISTINCT t3.optionName) AS selectedOptions,
GROUP_CONCAT(DISTINCT t2.mobile) AS mobile,
GROUP_CONCAT(DISTINCT t2.receiveAddress) AS receiveAddress, GROUP_CONCAT(DISTINCT t2.receiveAddress) AS receiveAddress,
CASE
WHEN MAX(t2.isSelectByAdmin) = 1 THEN '是'
ELSE '否'
END AS selectByAdminName,
t4.username AS userName, t4.username AS userName,
t4.loginname AS loginName, t4.loginname AS loginName,
t4.sex, t4.sex,
@@ -79,6 +91,7 @@ public class WelfareSelectionSituationServiceImpl extends BaseServiceImpl<Welfar
} }
cnd.andEX("t1.welfareUnitId", "in", pageForm.getUnitIds()); cnd.andEX("t1.welfareUnitId", "in", pageForm.getUnitIds());
cnd.andEX("t1.welfareUnionId", "=", pageForm.getUnionId()); cnd.andEX("t1.welfareUnionId", "=", pageForm.getUnionId());
appendSelectByAdminCnd(cnd, pageForm.getIsSelectByAdmin());
if (pageForm.getIsSelect() != null) { if (pageForm.getIsSelect() != null) {
if (pageForm.getIsSelect()) { if (pageForm.getIsSelect()) {
@@ -108,8 +121,11 @@ public class WelfareSelectionSituationServiceImpl extends BaseServiceImpl<Welfar
t1.welfareUnionName, t1.welfareUnionName,
t1.welfareUnitName, t1.welfareUnitName,
GROUP_CONCAT(DISTINCT t3.optionName) AS selectedOptions, GROUP_CONCAT(DISTINCT t3.optionName) AS selectedOptions,
GROUP_CONCAT(DISTINCT t2.mobile) AS mobile,
GROUP_CONCAT(DISTINCT t2.receiveAddress) AS receiveAddress, GROUP_CONCAT(DISTINCT t2.receiveAddress) AS receiveAddress,
CASE
WHEN MAX(t2.isSelectByAdmin) = 1 THEN '是'
ELSE '否'
END AS selectByAdminName,
t4.username AS userName, t4.username AS userName,
t4.loginname AS loginName, t4.loginname AS loginName,
t4.sex, t4.sex,
@@ -138,6 +154,7 @@ public class WelfareSelectionSituationServiceImpl extends BaseServiceImpl<Welfar
} }
cnd.andEX("t1.welfareUnitId", "in", pageForm.getUnitIds()); cnd.andEX("t1.welfareUnitId", "in", pageForm.getUnitIds());
cnd.andEX("t1.welfareUnionId", "=", pageForm.getUnionId()); cnd.andEX("t1.welfareUnionId", "=", pageForm.getUnionId());
appendSelectByAdminCnd(cnd, pageForm.getIsSelectByAdmin());
if (pageForm.getIsSelect() != null) { if (pageForm.getIsSelect() != null) {
if (pageForm.getIsSelect()) { if (pageForm.getIsSelect()) {
@@ -170,8 +187,8 @@ public class WelfareSelectionSituationServiceImpl extends BaseServiceImpl<Welfar
entities.add(new ExcelExportEntity("所属单位", "welfareUnitName", 20)); entities.add(new ExcelExportEntity("所属单位", "welfareUnitName", 20));
entities.add(new ExcelExportEntity("所属工会", "welfareUnionName", 20)); entities.add(new ExcelExportEntity("所属工会", "welfareUnionName", 20));
entities.add(new ExcelExportEntity("所选福利", "selectedOptions", 20)); entities.add(new ExcelExportEntity("所选福利", "selectedOptions", 20));
entities.add(new ExcelExportEntity("福利号码", "welfareMobile", 20)); entities.add(new ExcelExportEntity("是否管理员代选", "selectByAdminName", 20));
entities.add(new ExcelExportEntity("联系电话", "mobile", 20)); entities.add(new ExcelExportEntity("福利电话", "welfareMobile", 20));
if(project.getProvideMode() == 3){ if(project.getProvideMode() == 3){
entities.add(new ExcelExportEntity("收货地址", "receiveAddress", 40)); entities.add(new ExcelExportEntity("收货地址", "receiveAddress", 40));
@@ -158,7 +158,7 @@ public class WelfareSingleServiceImpl extends BaseServiceImpl implements Welfare
u.username AS userName, u.username AS userName,
u.unitname AS unitName, u.unitname AS unitName,
u.unionname AS unionName, u.unionname AS unionName,
u.mobile, u.welfareMobile,
IF(LOCATE('undefined',wpus.receiveAddress)>0,NULL,wpus.receiveAddress) as receiveAddress, IF(LOCATE('undefined',wpus.receiveAddress)>0,NULL,wpus.receiveAddress) as receiveAddress,
GROUP_CONCAT(DISTINCT wpso.optionName ,'(',wpus.selectNum,'份)') optionName, GROUP_CONCAT(DISTINCT wpso.optionName ,'(',wpus.selectNum,'份)') optionName,
wpus.courierNumber wpus.courierNumber
@@ -407,6 +407,7 @@ public class WelfareSingleServiceImpl extends BaseServiceImpl implements Welfare
selection.setReceiveAddress(null); selection.setReceiveAddress(null);
selection.setSubjectId(v.getString("subjectId")); selection.setSubjectId(v.getString("subjectId"));
selection.setSelectNum(v.getInt("selectNum")); selection.setSelectNum(v.getInt("selectNum"));
selection.setIsSelectByAdmin(true);
return selection; return selection;
}).collect(Collectors.toList()); }).collect(Collectors.toList());
dao().insert(userSelections); dao().insert(userSelections);
@@ -41,8 +41,25 @@ public class WelfareStatisticsServiceImpl extends BaseServiceImpl<WelfareProject
super(dao); super(dao);
} }
private void appendSelectByAdminCnd(Cnd cnd, String field, Boolean isSelectByAdmin) {
if (Boolean.TRUE.equals(isSelectByAdmin)) {
cnd.and(field, "=", true);
} else if (Boolean.FALSE.equals(isSelectByAdmin)) {
cnd.and(Cnd.exps(field, "=", false).or(field, "is", null));
}
}
private String buildSelectByAdminJoinCnd(String field, Boolean isSelectByAdmin) {
if (Boolean.TRUE.equals(isSelectByAdmin)) {
return " AND " + field + " = 1";
} else if (Boolean.FALSE.equals(isSelectByAdmin)) {
return " AND (" + field + " = 0 OR " + field + " IS NULL)";
}
return "";
}
@Override @Override
public NutMap pageData(String projectId, String unionId) { public NutMap pageData(String projectId, String unionId, Boolean isSelectByAdmin) {
// 分工会数据 // 分工会数据
List<Sys_union> unions = dao().query(Sys_union.class, Cnd.NEW().andEX(Sys_union::getId, "=", unionId).asc(Sys_union::getUnionCode)); List<Sys_union> unions = dao().query(Sys_union.class, Cnd.NEW().andEX(Sys_union::getId, "=", unionId).asc(Sys_union::getUnionCode));
List<NutMap> mapUnions = BeanUtil.copyToList(unions, NutMap.class); List<NutMap> mapUnions = BeanUtil.copyToList(unions, NutMap.class);
@@ -71,8 +88,10 @@ public class WelfareStatisticsServiceImpl extends BaseServiceImpl<WelfareProject
`welfare_list` t1 `welfare_list` t1
LEFT JOIN welfare_project_user_selection t2 ON t2.selectUserId = t1.userId LEFT JOIN welfare_project_user_selection t2 ON t2.selectUserId = t1.userId
AND t2.welfareId = t1.projectId AND t2.welfareId = t1.projectId
$selectByAdminJoinCnd
$condition $condition
"""); """);
sql.setVar("selectByAdminJoinCnd", buildSelectByAdminJoinCnd("t2.isSelectByAdmin", isSelectByAdmin));
Cnd cnd = Cnd.NEW(); Cnd cnd = Cnd.NEW();
cnd.and("t1.projectId", "=", projectId); cnd.and("t1.projectId", "=", projectId);
cnd.andEX("t1.welfareUnionId", "=", unionId); cnd.andEX("t1.welfareUnionId", "=", unionId);
@@ -92,6 +111,7 @@ public class WelfareStatisticsServiceImpl extends BaseServiceImpl<WelfareProject
"""); """);
Cnd cnd2 = Cnd.NEW(); Cnd cnd2 = Cnd.NEW();
cnd2.and("t1.welfareId", "=", projectId); cnd2.and("t1.welfareId", "=", projectId);
appendSelectByAdminCnd(cnd2, "t1.isSelectByAdmin", isSelectByAdmin);
sql2.setCondition(cnd2); sql2.setCondition(cnd2);
List<NutMap> userSelections = listMap(sql2); List<NutMap> userSelections = listMap(sql2);
@@ -119,7 +139,7 @@ public class WelfareStatisticsServiceImpl extends BaseServiceImpl<WelfareProject
} }
@Override @Override
public Pagination selectedUnionUserPageData(PageForm pageForm, String projectId, String unionId) { public Pagination selectedUnionUserPageData(PageForm pageForm, String projectId, String unionId, Boolean isSelectByAdmin) {
Sql sql = Sqls.create(""" Sql sql = Sqls.create("""
SELECT SELECT
u.loginname AS loginName, u.loginname AS loginName,
@@ -131,7 +151,8 @@ public class WelfareStatisticsServiceImpl extends BaseServiceImpl<WelfareProject
wl.welfareUnionName, wl.welfareUnionName,
u.postDoctoralJoinDate, u.postDoctoralJoinDate,
DATE_FORMAT(u.birthday, '%Y-%m-%d') AS birthday, DATE_FORMAT(u.birthday, '%Y-%m-%d') AS birthday,
wpus.mobile, u.welfareMobile,
CASE WHEN MAX(wpus.isSelectByAdmin) = 1 THEN '管理员代选' ELSE '个人选择' END AS selectByAdminName,
GROUP_CONCAT(DISTINCT wpso.optionName, '(', wpus.selectNum, '份)') selectOptionName GROUP_CONCAT(DISTINCT wpso.optionName, '(', wpus.selectNum, '份)') selectOptionName
FROM FROM
welfare_project_user_selection wpus welfare_project_user_selection wpus
@@ -143,6 +164,7 @@ public class WelfareStatisticsServiceImpl extends BaseServiceImpl<WelfareProject
Cnd cnd = Cnd.NEW(); Cnd cnd = Cnd.NEW();
cnd.and("wl.welfareUnionId", "=", unionId); cnd.and("wl.welfareUnionId", "=", unionId);
cnd.and("wpus.welfareId", "=", projectId); cnd.and("wpus.welfareId", "=", projectId);
appendSelectByAdminCnd(cnd, "wpus.isSelectByAdmin", isSelectByAdmin);
cnd.groupBy("u.loginname"); cnd.groupBy("u.loginname");
cnd.desc("wl.welfareUnionName").desc("wl.welfareUnitName").desc("wpus.selectOptionId"); cnd.desc("wl.welfareUnionName").desc("wl.welfareUnitName").desc("wpus.selectOptionId");
@@ -150,6 +172,7 @@ public class WelfareStatisticsServiceImpl extends BaseServiceImpl<WelfareProject
SqlExpressionGroup seg = new SqlExpressionGroup(); SqlExpressionGroup seg = new SqlExpressionGroup();
seg.orLike("u.loginname", pageForm.getSearchKeyword()); seg.orLike("u.loginname", pageForm.getSearchKeyword());
seg.orLike("u.username", pageForm.getSearchKeyword()); seg.orLike("u.username", pageForm.getSearchKeyword());
cnd.and(seg);
} }
sql.setCondition(cnd); sql.setCondition(cnd);
Pagination pagination = listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); Pagination pagination = listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql);
@@ -157,7 +180,7 @@ public class WelfareStatisticsServiceImpl extends BaseServiceImpl<WelfareProject
} }
@Override @Override
public void exportSelectedUnionUser(String projectId, String unionId, HttpServletResponse response) { public void exportSelectedUnionUser(String projectId, String unionId, Boolean isSelectByAdmin, HttpServletResponse response) {
Sql sql = Sqls.create(""" Sql sql = Sqls.create("""
SELECT SELECT
u.loginname AS loginName, u.loginname AS loginName,
@@ -169,7 +192,8 @@ public class WelfareStatisticsServiceImpl extends BaseServiceImpl<WelfareProject
wl.welfareUnionName, wl.welfareUnionName,
u.postDoctoralJoinDate, u.postDoctoralJoinDate,
DATE_FORMAT(u.birthday, '%Y-%m-%d') AS birthday, DATE_FORMAT(u.birthday, '%Y-%m-%d') AS birthday,
wpus.mobile, u.welfareMobile,
CASE WHEN MAX(wpus.isSelectByAdmin) = 1 THEN '管理员代选' ELSE '个人选择' END AS selectByAdminName,
GROUP_CONCAT(DISTINCT wpso.optionName, '(', wpus.selectNum, '份)') selectOptionName GROUP_CONCAT(DISTINCT wpso.optionName, '(', wpus.selectNum, '份)') selectOptionName
FROM FROM
welfare_project_user_selection wpus welfare_project_user_selection wpus
@@ -182,6 +206,7 @@ public class WelfareStatisticsServiceImpl extends BaseServiceImpl<WelfareProject
Cnd cnd = Cnd.NEW(); Cnd cnd = Cnd.NEW();
cnd.and("wl.welfareUnionId", "=", unionId); cnd.and("wl.welfareUnionId", "=", unionId);
cnd.and("wpus.welfareId", "=", projectId); cnd.and("wpus.welfareId", "=", projectId);
appendSelectByAdminCnd(cnd, "wpus.isSelectByAdmin", isSelectByAdmin);
cnd.groupBy("u.loginname"); cnd.groupBy("u.loginname");
cnd.desc("wl.welfareUnionName").desc("wl.welfareUnitName").desc("wpus.selectOptionId"); cnd.desc("wl.welfareUnionName").desc("wl.welfareUnitName").desc("wpus.selectOptionId");
sql.setCondition(cnd); sql.setCondition(cnd);
@@ -207,7 +232,8 @@ public class WelfareStatisticsServiceImpl extends BaseServiceImpl<WelfareProject
entities.add(new ExcelExportEntity("进站时间", "postDoctoralJoinDate", 20)); entities.add(new ExcelExportEntity("进站时间", "postDoctoralJoinDate", 20));
entities.add(new ExcelExportEntity("单位", "welfareUnitName", 20)); entities.add(new ExcelExportEntity("单位", "welfareUnitName", 20));
entities.add(new ExcelExportEntity("工会", "welfareUnionName", 20)); entities.add(new ExcelExportEntity("工会", "welfareUnionName", 20));
entities.add(new ExcelExportEntity("手机号", "mobile", 20)); entities.add(new ExcelExportEntity("福利电话", "welfareMobile", 20));
entities.add(new ExcelExportEntity("选择方式", "selectByAdminName", 20));
entities.add(new ExcelExportEntity("所选福利", "selectOptionName", 20)); entities.add(new ExcelExportEntity("所选福利", "selectOptionName", 20));
// 设置导出参数 // 设置导出参数
@@ -330,7 +356,7 @@ public class WelfareStatisticsServiceImpl extends BaseServiceImpl<WelfareProject
u.username AS userName, u.username AS userName,
wl.welfareUnitName, wl.welfareUnitName,
wl.welfareUnionName, wl.welfareUnionName,
wpus.mobile, u.welfareMobile,
GROUP_CONCAT(DISTINCT wpso.optionName, '(', wpus.selectNum, '份)') selectOptionName GROUP_CONCAT(DISTINCT wpso.optionName, '(', wpus.selectNum, '份)') selectOptionName
FROM FROM
welfare_project_user_selection wpus welfare_project_user_selection wpus
@@ -358,9 +384,10 @@ public class WelfareStatisticsServiceImpl extends BaseServiceImpl<WelfareProject
List<ExcelExportEntity> exportEntities = new ArrayList<>(); List<ExcelExportEntity> exportEntities = new ArrayList<>();
exportEntities.add(new ExcelExportEntity("工号", "loginName", 20)); exportEntities.add(new ExcelExportEntity("工号", "loginName", 20));
exportEntities.add(new ExcelExportEntity("姓名", "userName", 20)); exportEntities.add(new ExcelExportEntity("姓名", "userName", 20));
exportEntities.add(new ExcelExportEntity("电话号码", "mobile", 20)); exportEntities.add(new ExcelExportEntity("福利电话", "welfareMobile", 20));
exportEntities.add(new ExcelExportEntity("所在分工会", "welfareUnionName", 20)); exportEntities.add(new ExcelExportEntity("所在分工会", "welfareUnionName", 20));
exportEntities.add(new ExcelExportEntity("所在单位", "welfareUnitName", 20)); exportEntities.add(new ExcelExportEntity("所在单位", "welfareUnitName", 20));
exportEntities.add(new ExcelExportEntity("选择方式", "selectByAdminName", 20));
exportEntities.add(new ExcelExportEntity("所在福利", "optionName", 50)); exportEntities.add(new ExcelExportEntity("所在福利", "optionName", 50));
Sql sql = Sqls.create(""" Sql sql = Sqls.create("""
@@ -369,9 +396,10 @@ public class WelfareStatisticsServiceImpl extends BaseServiceImpl<WelfareProject
u.username AS userName, u.username AS userName,
wl.welfareUnitName, wl.welfareUnitName,
wl.welfareUnionName, wl.welfareUnionName,
wpus.mobile, u.welfareMobile,
wpus.selectOptionId, wpus.selectOptionId,
wpso.optionName, wpso.optionName,
CASE WHEN MAX(wpus.isSelectByAdmin) = 1 THEN '管理员代选' ELSE '个人选择' END AS selectByAdminName,
GROUP_CONCAT(DISTINCT wpso.optionName, '(', wpus.selectNum, '份)') selectOptionName GROUP_CONCAT(DISTINCT wpso.optionName, '(', wpus.selectNum, '份)') selectOptionName
FROM FROM
welfare_project_user_selection wpus welfare_project_user_selection wpus
+7
View File
@@ -0,0 +1,7 @@
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('56e0941221324d04bea7329cb8f549c4', '', '0079', '协会审核', null, 'menu', '/platform/h5/club/audit', null, '/platform/sys/file/download?id=19cl7j298kj3logm5qlb0vslhi', 1, 0, 'h5.club.audit', null, 223, 0, '17a7f8ad3ee947b4a26175049a9c253d', 1790154555839, '17a7f8ad3ee947b4a26175049a9c253d', 1790155973918, 0, null, 'H5', '5710401a805645d59060f888eb863b60', 0);
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', '56e0941221324d04bea7329cb8f549c4');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('53650479ee954e748fda6cceabdd2918', '56e0941221324d04bea7329cb8f549c4');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('419844ad90f34ca58807e84224905f27', '56e0941221324d04bea7329cb8f549c4');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('d8d30588c356492ebb8645bc2b593270', '56e0941221324d04bea7329cb8f549c4');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('cf0a07b773f444d3800bc18216d8cffb', '56e0941221324d04bea7329cb8f549c4');
@@ -0,0 +1,212 @@
-- 学习教育平台初始化(MySQL),不包含课程、附件或学习记录。
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `learning_course` (
`id` VARCHAR(32) NOT NULL COMMENT 'ID',
`courseName` VARCHAR(100) DEFAULT NULL COMMENT '课程名称',
`courseTypeId` VARCHAR(32) DEFAULT NULL COMMENT '课程类型',
`lecturerName` VARCHAR(100) DEFAULT NULL COMMENT '授课讲师',
`lecturerInfo` VARCHAR(500) DEFAULT NULL COMMENT '讲师基本信息',
`courseIntro` LONGTEXT DEFAULT NULL COMMENT '课程简介',
`suitablePeople` VARCHAR(500) DEFAULT NULL COMMENT '适合人群',
`learningGoal` VARCHAR(500) DEFAULT NULL COMMENT '学习目标',
`coursePeriod` VARCHAR(50) DEFAULT NULL COMMENT '课程周期',
`openType` VARCHAR(20) DEFAULT NULL COMMENT '开课类型',
`startTime` DATETIME DEFAULT NULL COMMENT '开始时间',
`endTime` DATETIME DEFAULT NULL COMMENT '结束时间',
`status` VARCHAR(20) DEFAULT NULL COMMENT '课程状态',
`recommendFlags` VARCHAR(500) DEFAULT NULL COMMENT '推荐标识',
`courseTags` VARCHAR(500) DEFAULT NULL COMMENT '课程标签',
`targetType` VARCHAR(20) DEFAULT NULL COMMENT '学习对象',
`targetOrgText` VARCHAR(500) DEFAULT NULL COMMENT '指定组织',
`sortNum` INT DEFAULT NULL COMMENT '排序编码',
`viewCount` INT DEFAULT NULL COMMENT '观看次数',
`cover` VARCHAR(500) DEFAULT NULL COMMENT '课程封面',
`createdBy` VARCHAR(32) DEFAULT NULL,
`createdAt` BIGINT DEFAULT NULL,
`updatedBy` VARCHAR(32) DEFAULT NULL,
`updatedAt` BIGINT DEFAULT NULL,
`delFlag` BOOLEAN DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `learning_course_outline` (
`id` VARCHAR(32) NOT NULL COMMENT 'ID',
`courseId` VARCHAR(32) DEFAULT NULL COMMENT '所属课程ID',
`courseName` VARCHAR(100) DEFAULT NULL COMMENT '所属课程名称',
`parentId` VARCHAR(32) DEFAULT NULL COMMENT '父节点ID',
`nodeType` VARCHAR(20) DEFAULT NULL COMMENT '节点类型',
`title` VARCHAR(100) DEFAULT NULL COMMENT '章/节标题',
`subtitle` VARCHAR(200) DEFAULT NULL COMMENT '副标题',
`description` LONGTEXT DEFAULT NULL COMMENT '简介',
`sortOrder` INT DEFAULT NULL COMMENT '排序值',
`required` BOOLEAN DEFAULT NULL COMMENT '是否必学',
`status` VARCHAR(20) DEFAULT NULL COMMENT '状态',
`createdBy` VARCHAR(32) DEFAULT NULL,
`createdAt` BIGINT DEFAULT NULL,
`updatedBy` VARCHAR(32) DEFAULT NULL,
`updatedAt` BIGINT DEFAULT NULL,
`delFlag` BOOLEAN DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `learning_course_type` (
`id` VARCHAR(32) NOT NULL COMMENT 'ID',
`typeName` VARCHAR(100) DEFAULT NULL COMMENT '课程类型名称',
`sortNum` INT DEFAULT NULL COMMENT '排序编号',
`enabled` BOOLEAN DEFAULT NULL COMMENT '是否启用',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
`createdBy` VARCHAR(32) DEFAULT NULL,
`createdAt` BIGINT DEFAULT NULL,
`updatedBy` VARCHAR(32) DEFAULT NULL,
`updatedAt` BIGINT DEFAULT NULL,
`delFlag` BOOLEAN DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `learning_outline_resource` (
`id` VARCHAR(32) NOT NULL COMMENT 'ID',
`courseId` VARCHAR(32) DEFAULT NULL COMMENT '所属课程ID',
`outlineId` VARCHAR(32) DEFAULT NULL COMMENT '所属章/节ID',
`resourceTitle` VARCHAR(100) DEFAULT NULL COMMENT '资料标题',
`resourceType` VARCHAR(20) DEFAULT NULL COMMENT '资料类型',
`fileExt` VARCHAR(20) DEFAULT NULL COMMENT '文件扩展名',
`fileData` LONGTEXT DEFAULT NULL COMMENT '附件',
`durationSeconds` INT DEFAULT NULL COMMENT '视频/音频时长',
`sortOrder` INT DEFAULT NULL COMMENT '排序值',
`required` BOOLEAN DEFAULT NULL COMMENT '是否必学',
`allowPreview` BOOLEAN DEFAULT NULL COMMENT '是否允许预览',
`allowDownload` BOOLEAN DEFAULT NULL COMMENT '是否允许下载',
`status` VARCHAR(20) DEFAULT NULL COMMENT '状态',
`createdBy` VARCHAR(32) DEFAULT NULL,
`createdAt` BIGINT DEFAULT NULL,
`updatedBy` VARCHAR(32) DEFAULT NULL,
`updatedAt` BIGINT DEFAULT NULL,
`delFlag` BOOLEAN DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `learning_study_record` (
`id` VARCHAR(32) NOT NULL COMMENT 'ID',
`userId` VARCHAR(32) DEFAULT NULL COMMENT '学习人ID',
`loginName` VARCHAR(120) DEFAULT NULL COMMENT '工号',
`userName` VARCHAR(100) DEFAULT NULL COMMENT '姓名',
`unionId` VARCHAR(32) DEFAULT NULL COMMENT '所属分工会ID',
`unionName` VARCHAR(100) DEFAULT NULL COMMENT '所属分工会',
`courseId` VARCHAR(32) DEFAULT NULL COMMENT '课程ID',
`courseName` VARCHAR(100) DEFAULT NULL COMMENT '课程名称',
`outlineId` VARCHAR(32) DEFAULT NULL COMMENT '章节ID',
`outlineName` VARCHAR(100) DEFAULT NULL COMMENT '章节名称',
`firstStudyTime` DATETIME DEFAULT NULL COMMENT '第一次进入时间',
`latestStudyTime` DATETIME DEFAULT NULL COMMENT '最近学习时间',
`studySeconds` INT DEFAULT NULL COMMENT '累计有效学习时长(秒)',
`lastPositionSeconds` INT DEFAULT NULL COMMENT '最近播放位置(秒)',
`resourceMaxPositionData` TEXT DEFAULT NULL COMMENT '各资源最高已观看位置(JSON)',
`requiredSeconds` INT DEFAULT NULL COMMENT '要求学习时长(秒)',
`progressPercent` INT DEFAULT NULL COMMENT '学习进度',
`completeStatus` VARCHAR(20) DEFAULT NULL COMMENT '完成状态',
`completedAt` DATETIME DEFAULT NULL COMMENT '完成时间',
`createdBy` VARCHAR(32) DEFAULT NULL,
`createdAt` BIGINT DEFAULT NULL,
`updatedBy` VARCHAR(32) DEFAULT NULL,
`updatedAt` BIGINT DEFAULT NULL,
`delFlag` BOOLEAN DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_learning_record_user_course_outline` (`userId`, `courseId`, `outlineId`),
KEY `idx_learning_record_union` (`unionId`),
KEY `idx_learning_record_course` (`courseId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `learning_study_rule` (
`id` VARCHAR(32) NOT NULL COMMENT 'ID',
`courseId` VARCHAR(32) DEFAULT NULL COMMENT '所属课程ID',
`targetType` VARCHAR(20) DEFAULT NULL COMMENT '规则对象类型',
`targetId` VARCHAR(32) DEFAULT NULL COMMENT '规则对象ID',
`required` BOOLEAN DEFAULT NULL COMMENT '是否必学',
`studyMode` VARCHAR(20) DEFAULT NULL COMMENT '学习方式',
`completionRule` VARCHAR(50) DEFAULT NULL COMMENT '完成规则',
`completePercent` INT DEFAULT NULL COMMENT '完成比例',
`minStudySeconds` INT DEFAULT NULL COMMENT '最少学习时长',
`unlockRule` VARCHAR(20) DEFAULT NULL COMMENT '解锁规则',
`allowSkip` BOOLEAN DEFAULT NULL COMMENT '是否允许跳过',
`allowDrag` BOOLEAN DEFAULT NULL COMMENT '是否允许拖动',
`pauseCountTime` BOOLEAN DEFAULT NULL COMMENT '暂停是否计时',
`hiddenCountTime` BOOLEAN DEFAULT NULL COMMENT '页面隐藏是否计时',
`inactiveCountTime` BOOLEAN DEFAULT NULL COMMENT '长时间无操作是否计时',
`status` VARCHAR(20) DEFAULT NULL COMMENT '状态',
`createdBy` VARCHAR(32) DEFAULT NULL,
`createdAt` BIGINT DEFAULT NULL,
`updatedBy` VARCHAR(32) DEFAULT NULL,
`updatedAt` BIGINT DEFAULT NULL,
`delFlag` BOOLEAN DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `learning_study_segment` (
`id` VARCHAR(32) NOT NULL COMMENT 'ID',
`recordId` VARCHAR(32) DEFAULT NULL COMMENT '学习记录ID',
`userId` VARCHAR(32) DEFAULT NULL COMMENT '学习人ID',
`courseId` VARCHAR(32) DEFAULT NULL COMMENT '课程ID',
`outlineId` VARCHAR(32) DEFAULT NULL COMMENT '章节ID',
`resourceId` VARCHAR(32) DEFAULT NULL COMMENT '资源ID',
`resourceName` VARCHAR(100) DEFAULT NULL COMMENT '资源名称',
`startTime` DATETIME DEFAULT NULL COMMENT '开始时间',
`endTime` DATETIME DEFAULT NULL COMMENT '结束时间',
`lastHeartbeatTime` DATETIME DEFAULT NULL COMMENT '最近心跳时间',
`activeSeconds` INT DEFAULT NULL COMMENT '本时段有效学习时长(秒)',
`state` VARCHAR(20) DEFAULT NULL COMMENT '状态',
`createdBy` VARCHAR(32) DEFAULT NULL,
`createdAt` BIGINT DEFAULT NULL,
`updatedBy` VARCHAR(32) DEFAULT NULL,
`updatedAt` BIGINT DEFAULT NULL,
`delFlag` BOOLEAN DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_learning_segment_record` (`recordId`),
KEY `idx_learning_segment_user_state` (`userId`, `state`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
START TRANSACTION;
INSERT INTO sys_module (id, platform, name, icon, sortNum, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('a03149321797435d9d54f9ba03cb275b', 'H5', '培训教育', '/platform/sys/file/download?id=tveo21cc2ki3toav43rpvtankg', 31, '17a7f8ad3ee947b4a26175049a9c253d', 1757990226056, '17a7f8ad3ee947b4a26175049a9c253d', 1764214972112, false);
INSERT INTO sys_module (id, platform, name, icon, sortNum, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('b1ae9f3fcb164be9a8d857f3acf3c3bd', 'PC', '培训教育', '/platform/sys/file/download?id=p7if6ffrrshi7ppttcaajkvdbt', 9, '17a7f8ad3ee947b4a26175049a9c253d', 1756798359497, '17a7f8ad3ee947b4a26175049a9c253d', 1770772532570, false);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('a0f47f6a6d734c8b9c6ce5c0dfd80001', '', '0167', '学习教育平台', 'Learning', 'menu', '', '', 'ti-book', true, false, 'learning', null, 611, false, '1af78f95c16c4504a8bd714d9e76def3', 1777739844000, '17a7f8ad3ee947b4a26175049a9c253d', 1780199079929, false, null, 'PC', 'b1ae9f3fcb164be9a8d857f3acf3c3bd', false);
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('6d603b652abee584ba6274dc1fcee3bb', 'a0f47f6a6d734c8b9c6ce5c0dfd80001');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('fd753e8a8c5a404e8e00ef8e0b0c69da', 'a0f47f6a6d734c8b9c6ce5c0dfd80001');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'a0f47f6a6d734c8b9c6ce5c0dfd80001');
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('3e9a7611fa96490d8adc393387b1d65a', '', '0170', '我的学习', null, 'menu', '/platform/learning/my/record/h5', null, null, true, false, 'learning.my.record.h5', null, 1005, false, '1af78f95c16c4504a8bd714d9e76def3', 1779111719650, '1af78f95c16c4504a8bd714d9e76def3', 1780209965809, false, null, 'H5', 'a03149321797435d9d54f9ba03cb275b', false);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('82946217f37e466d87f123c6987991e1', '', '0171', '学习教育', '', 'menu', '/platform/learning/course/h5', null, null, true, false, 'learning.course.h5', null, 1004, false, '1af78f95c16c4504a8bd714d9e76def3', 1779011454247, '1af78f95c16c4504a8bd714d9e76def3', 1780209948730, false, null, 'H5', 'a03149321797435d9d54f9ba03cb275b', false);
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('6d603b652abee584ba6274dc1fcee3bb', '82946217f37e466d87f123c6987991e1');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('6d603b652abee584ba6274dc1fcee3bb', '3e9a7611fa96490d8adc393387b1d65a');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', '82946217f37e466d87f123c6987991e1');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', '3e9a7611fa96490d8adc393387b1d65a');
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('a0f47f6a6d734c8b9c6ce5c0dfd80002', 'a0f47f6a6d734c8b9c6ce5c0dfd80001', '01670001', '课程类型设置', 'Course Type', 'menu', '/platform/learning/course/type', 'data-pjax', '', true, false, 'learning.course.type', null, 612, false, '', 1777739844000, '', 1777739844000, false, null, 'PC', null, false);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('a0f47f6a6d734c8b9c6ce5c0dfd80003', 'a0f47f6a6d734c8b9c6ce5c0dfd80001', '01670002', '课程管理', 'Course Manage', 'menu', '/platform/learning/course/manage', 'data-pjax', '', true, false, 'learning.course.manage', null, 613, false, '', 1777739844000, '', 1777739844000, false, null, 'PC', null, false);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('a0f47f6a6d734c8b9c6ce5c0dfd80004', 'a0f47f6a6d734c8b9c6ce5c0dfd80001', '01670003', '章节内容管理', 'Chapter Content', 'menu', '/platform/learning/chapter/content', 'data-pjax', '', true, false, 'learning.chapter.content', null, 614, false, '', 1777739844000, '', 1777739844000, false, null, 'PC', null, false);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('a0f47f6a6d734c8b9c6ce5c0dfd80005', 'a0f47f6a6d734c8b9c6ce5c0dfd80001', '01670004', '课程展示', 'Course Display', 'menu', '/platform/learning/course/display', 'data-pjax', '', true, false, 'learning.course.display', null, 615, false, '', 1777739844000, '', 1777739844000, false, null, 'PC', null, false);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('a0f47f6a6d734c8b9c6ce5c0dfd80007', 'a0f47f6a6d734c8b9c6ce5c0dfd80001', '01670006', '学习统计', 'Learning Statistics', 'menu', '/platform/learning/statistics', 'data-pjax', '', true, false, 'learning.statistics', null, 617, false, '', 1777739844000, '', 1777739844000, false, null, 'PC', null, false);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('a0f47f6a6d734c8b9c6ce5c0dfd80008', 'a0f47f6a6d734c8b9c6ce5c0dfd80001', '01670007', '我的学习记录', 'My Learning Record', 'menu', '/platform/learning/my/record', 'data-pjax', '', true, false, 'learning.my.record', null, 618, false, '', 1777792288000, '', 1777792288000, false, null, 'PC', null, false);
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('6d603b652abee584ba6274dc1fcee3bb', 'a0f47f6a6d734c8b9c6ce5c0dfd80005');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('6d603b652abee584ba6274dc1fcee3bb', 'a0f47f6a6d734c8b9c6ce5c0dfd80008');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('fd753e8a8c5a404e8e00ef8e0b0c69da', 'a0f47f6a6d734c8b9c6ce5c0dfd80008');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'a0f47f6a6d734c8b9c6ce5c0dfd80002');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'a0f47f6a6d734c8b9c6ce5c0dfd80003');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'a0f47f6a6d734c8b9c6ce5c0dfd80004');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'a0f47f6a6d734c8b9c6ce5c0dfd80005');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'a0f47f6a6d734c8b9c6ce5c0dfd80007');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'a0f47f6a6d734c8b9c6ce5c0dfd80008');
INSERT INTO sys_dict (id, parentId, path, name, code, disabled, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('077f4c7f51ec41cc9e47559a6b8bf6d5', '', '0038', '学习教育', 'LEARNING', false, 991, true, '17a7f8ad3ee947b4a26175049a9c253d', 1777768520956, '17a7f8ad3ee947b4a26175049a9c253d', 1777768520956, false);
INSERT INTO sys_dict (id, parentId, path, name, code, disabled, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('5477dc43ee1044108aae0e6241d3a87a', '6fa4bb9701e34511ab86ae3cbc39a053', '003800020004', '专题课程', '专题课程', false, 1002, false, '17a7f8ad3ee947b4a26175049a9c253d', 1777769110156, '17a7f8ad3ee947b4a26175049a9c253d', 1777769110156, false);
INSERT INTO sys_dict (id, parentId, path, name, code, disabled, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('6fa4bb9701e34511ab86ae3cbc39a053', '077f4c7f51ec41cc9e47559a6b8bf6d5', '00380002', '推荐标识', 'Recommended Mark', false, 993, true, '17a7f8ad3ee947b4a26175049a9c253d', 1777768764849, '17a7f8ad3ee947b4a26175049a9c253d', 1777768764850, false);
INSERT INTO sys_dict (id, parentId, path, name, code, disabled, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('7863497c73d14e5bb14245d4e7f60089', '6fa4bb9701e34511ab86ae3cbc39a053', '003800020002', '新开课程', '新开课程', false, 1000, false, '17a7f8ad3ee947b4a26175049a9c253d', 1777768964121, '17a7f8ad3ee947b4a26175049a9c253d', 1777769085321, false);
INSERT INTO sys_dict (id, parentId, path, name, code, disabled, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('d8afd3f8e1fe4c25a8089a810707b433', '6fa4bb9701e34511ab86ae3cbc39a053', '003800020001', '热门课程', '热门课程', false, 999, false, '17a7f8ad3ee947b4a26175049a9c253d', 1777768946919, '17a7f8ad3ee947b4a26175049a9c253d', 1777769069517, false);
INSERT INTO sys_dict (id, parentId, path, name, code, disabled, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('e1dbf9e5dd8a45ea89cec68eb36d901f', '6fa4bb9701e34511ab86ae3cbc39a053', '003800020003', '推荐课程', '推荐课程', false, 1001, false, '17a7f8ad3ee947b4a26175049a9c253d', 1777768990516, '17a7f8ad3ee947b4a26175049a9c253d', 1777769094915, false);
COMMIT;
+1 -1
View File
@@ -32,7 +32,7 @@ var ioc={
// 单个文件最大尺寸(大约的值,单位为字节,即 209715200 为 200M) // 单个文件最大尺寸(大约的值,单位为字节,即 209715200 为 200M)
maxFileSize : 209715200, maxFileSize : 209715200,
// 正则表达式匹配可以支持的文件名 // 正则表达式匹配可以支持的文件名
nameFilter : '^(.+[.])(gif||svg|jpg|png|jpeg|doc|docx|xls|xlsx|rar|zip|7z|txt|pdf|pptx|ppt|mp4)$' nameFilter : '^(.+[.])(gif||svg|jpg|png|jpeg|doc|docx|xls|xlsx|rar|zip|7z|txt|pdf|pptx|ppt|mp4|(?i:mp3|wav|aac))$'
} }
}, },
fileUpload : { fileUpload : {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,76 @@
const h5PopupHistory = (() => {
const popupStack = []
let skipNextPop = false
const handlePopState = (event) => {
if (skipNextPop) {
skipNextPop = false
event.stopImmediatePropagation()
return
}
if (popupStack.length === 0) return
event.stopImmediatePropagation()
const popup = popupStack.pop()
popup.closeFromHistory()
}
window.addEventListener("popstate", handlePopState)
return {
/**
* 注册已打开的 H5 弹框并增加一层历史记录。
* @param id 弹框唯一标识,同一标识不会重复入栈
* @param owner 页面或组件标识,用于提交成功及销毁时统一清理
* @param closeFromHistory 返回键触发时关闭弹框的回调
* @return 无返回值
*/
open(id, owner, closeFromHistory) {
if (popupStack.some((item) => item.id === id)) return
popupStack.push({id: id, owner: owner, closeFromHistory: closeFromHistory})
const state = Object.assign({}, window.history.state || {}, {
h5PopupId: id,
url: window.location.href
})
window.history.pushState(state, "", window.location.href)
},
/**
* 通过取消、关闭按钮关闭指定弹框时,同步回退对应历史记录。
* @param id 弹框唯一标识,必须是当前最上层弹框
* @return boolean;true 表示已发起历史回退,false 表示弹框不在栈顶
*/
close(id) {
const top = popupStack[popupStack.length - 1]
if (!top || top.id !== id) return false
window.history.back()
return true
},
/**
* 提交成功前清理当前组件的全部弹框及其历史记录。
* @param owner 页面或组件标识
* @return 无返回值
*/
clearOwner(owner) {
const ownerPopups = popupStack.filter((item) => item.owner === owner)
if (ownerPopups.length === 0) return
ownerPopups.slice().reverse().forEach((item) => item.closeFromHistory())
for (let i = popupStack.length - 1; i >= 0; i--) {
if (popupStack[i].owner === owner) popupStack.splice(i, 1)
}
skipNextPop = true
window.history.go(-ownerPopups.length)
},
/**
* 组件销毁时注销回调,防止旧组件被历史返回事件再次调用。
* @param owner 页面或组件标识
* @return 无返回值
*/
unregisterOwner(owner) {
for (let i = popupStack.length - 1; i >= 0; i--) {
if (popupStack[i].owner === owner) popupStack.splice(i, 1)
}
}
}
})()
@@ -1,7 +1,7 @@
<template> <template>
<div class="search"> <div class="search">
<slot></slot> <slot></slot>
<div class="search-query"> <div v-if="isSearchButton" class="search-query">
<el-button type="primary" icon="el-icon-search" @click="$emit('search', null)">搜索</el-button> <el-button type="primary" icon="el-icon-search" @click="$emit('search', null)">搜索</el-button>
</div> </div>
</div> </div>
@@ -9,7 +9,13 @@
<script> <script>
module.exports = { module.exports = {
name: "pageFormSearch" name: "pageFormSearch",
props: {
isSearchButton: {
type: Boolean,
default: true
}
}
} }
</script> </script>
@@ -356,7 +356,7 @@
if (!state.activeModuleId && getters.pcModules) { if (!state.activeModuleId && getters.pcModules) {
commit("setActiveMenuModuleId", getters.pcModules[0].id) commit("setActiveMenuModuleId", getters.pcModules[0].id)
} }
pjaxReplace(pathname === "/platform/home" ? pathname : pathname) pjaxReplace(pathname + search)
return return
} }
//拷贝一份 //拷贝一份
@@ -57,6 +57,7 @@
<script src="${base!}/assets/platform/js/mixin/styleMixin.js"></script> <script src="${base!}/assets/platform/js/mixin/styleMixin.js"></script>
<script src="${base!}/components/plugins/sysDict/DictData.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/commonUtil.js"></script>
<script src="${base!}/assets/platform/js/util/h5PopupHistory.js"></script>
<script src="${base!}/assets/platform/js/tool/businessTool.js"></script> <script src="${base!}/assets/platform/js/tool/businessTool.js"></script>
<!--富文本编辑器--> <!--富文本编辑器-->
@@ -0,0 +1,9 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<el-card shadow="never">学习活动管理模块待完善</el-card>
</div>
<!--#
}
#-->
@@ -0,0 +1,889 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
.learning-outline-layout {
height: calc(100vh - 235px);
}
.learning-outline-tree {
height: 100%;
overflow: auto;
}
.outline-context-menu {
position: fixed;
z-index: 3000;
padding: 5px 0;
margin: 0;
list-style: none;
background: #fff;
border: 1px solid #dcdfe6;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, .1);
}
.outline-context-menu li {
min-width: 120px;
padding: 7px 18px;
cursor: pointer;
}
.outline-context-menu li:hover {
background: #f2f6fc;
}
</style>
<div id="app" v-cloak>
<el-card shadow="never">
<search :is-search-button="false">
<search-item label="课程名称">
<el-select
v-model="currentCourseId"
clearable
filterable
remote
:remote-method="courseNameRemoteSearch"
placeholder="请选择或搜索课程名称"
style="width: 100%"
@change="courseChange"
@clear="courseClear">
<el-option v-for="item in courseOptions" :key="item.id" :label="item.courseName" :value="item.id"></el-option>
</el-select>
</search-item>
<div class="search-query">
<el-button size="medium" type="primary" @click="loadCourses">搜索</el-button>
<el-button size="medium" @click="resetCourseQuery">重置</el-button>
</div>
</search>
</el-card>
<el-row :gutter="10" class="mt10 learning-outline-layout">
<el-col :span="6" style="height: 100%">
<el-card shadow="never" style="height: 100%">
<table-tool label="课程大纲">
<el-button size="mini" type="primary" icon="el-icon-plus" @click="openNodeForm('chapter')">新增章</el-button>
</table-tool>
<div class="learning-outline-tree">
<el-tree
ref="outlineTree"
:data="outlineTreeData"
node-key="id"
default-expand-all
highlight-current
:expand-on-click-node="false"
:props="{children:'children', label:'title'}"
@node-click="nodeClick"
@node-contextmenu="openContextMenu">
<span slot-scope="{ node, data }">
<i :class="data.nodeType === 'course' ? 'el-icon-collection' : (data.nodeType === 'chapter' ? 'el-icon-folder' : 'el-icon-document')"></i>
<span>{{data.title}}</span>
<el-tag v-if="data.status === 'disabled'" size="mini" type="info" style="margin-left: 6px">禁用</el-tag>
<el-tag v-if="data.required" size="mini" type="warning" style="margin-left: 6px">必学</el-tag>
</span>
</el-tree>
<el-empty v-if="treeData.length === 0" description="暂无课程大纲"></el-empty>
</div>
</el-card>
</el-col>
<el-col :span="18" style="height: 100%">
<el-card shadow="never" style="height: 100%; overflow: auto">
<el-empty v-if="!selectedNode.id" description="请选择左侧章/节节点"></el-empty>
<el-tabs v-else v-model="activeTab">
<el-tab-pane label="基础信息" name="basic" style="padding-top: 12px">
<el-form :model="nodeForm" ref="nodeForm" label-width="110px" :rules="nodeRules">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="节点类型">
<el-tag :type="nodeForm.nodeType === 'chapter' ? 'primary' : 'success'">{{nodeForm.nodeType === 'chapter' ? '章' : '节'}}</el-tag>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序值" prop="sortOrder">
<el-input-number v-model="nodeForm.sortOrder" :controls="false" :min="1" :precision="0" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="标题" prop="title">
<el-input v-model="nodeForm.title" maxlength="100" placeholder="请输入章/节标题"></el-input>
</el-form-item>
<el-form-item label="副标题" prop="subtitle">
<el-input v-model="nodeForm.subtitle" maxlength="200" placeholder="请输入副标题"></el-input>
</el-form-item>
<el-form-item label="简介" prop="description">
<el-input v-model="nodeForm.description" type="textarea" :rows="5" maxlength="2000" show-word-limit placeholder="请输入章/节简介"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveNodeBasic">保存基础信息</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="学习资料" name="resource">
<table-tool label="学习资料">
<el-button size="mini" type="primary" icon="el-icon-plus" @click="openResourceForm()">新增资料</el-button>
</table-tool>
<el-table :data="resourceData" border @sort-change="resourcePageOrder">
<el-table-column label="序号" type="index" width="70" :index="resourceIndex"></el-table-column>
<el-table-column label="资料标题" prop="resourceTitle" sortable="custom" show-overflow-tooltip></el-table-column>
<el-table-column label="资料类型" prop="resourceType" sortable="custom" width="110">
<template slot-scope="{row}">{{getResourceTypeName(row.resourceType)}}</template>
</el-table-column>
<el-table-column label="文件格式" prop="fileExt" sortable="custom" width="110"></el-table-column>
<el-table-column label="学习时长(秒)" prop="durationSeconds" sortable="custom" width="140">
<template slot-scope="{row}">{{formatDurationSeconds(row.durationSeconds)}}</template>
</el-table-column>
<el-table-column label="排序" prop="sortOrder" sortable="custom" width="90"></el-table-column>
<el-table-column label="必学" prop="required" sortable="custom" width="90">
<template slot-scope="{row}">
<el-tag :type="row.required ? 'warning' : 'info'" size="mini">{{row.required ? '是' : '否'}}</el-tag>
</template>
</el-table-column>
<el-table-column label="预览" prop="allowPreview" sortable="custom" width="90">
<template slot-scope="{row}">{{row.allowPreview ? '允许' : '不允许'}}</template>
</el-table-column>
<el-table-column label="下载" prop="allowDownload" sortable="custom" width="90">
<template slot-scope="{row}">{{row.allowDownload ? '允许' : '不允许'}}</template>
</el-table-column>
<el-table-column label="状态" prop="status" sortable="custom" width="90">
<template slot-scope="{row}">
<el-tag :type="row.status === 'enabled' ? 'success' : 'info'" size="mini">{{row.status === 'enabled' ? '启用' : '禁用'}}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150">
<template slot-scope="{row}">
<el-button size="mini" type="primary" @click="openResourceForm(row)">编辑</el-button>
<el-button size="mini" type="danger" @click="deleteResource(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="mt20"
@size-change="resourceSizeChange"
@current-change="resourceNumberChange"
:current-page="resourcePageForm.pageNumber"
:page-sizes="[5,10,20,30,50]"
:page-size="resourcePageForm.pageSize"
layout="total, sizes, prev, pager, next"
:total="resourcePageForm.totalCount">
</el-pagination>
</el-tab-pane>
<el-tab-pane label="完成规则" name="rule" style="padding-top: 12px">
<el-form :model="ruleForm" label-width="150px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="是否必学">
<el-switch v-model="ruleForm.required"></el-switch>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="学习方式">
<el-select v-model="ruleForm.studyMode" style="width: 100%">
<el-option v-for="item in studyModeOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="完成规则">
<el-select v-model="ruleForm.completionRule" style="width: 100%">
<el-option v-for="item in completionRuleOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="完成比例">
<el-input-number v-model="ruleForm.completePercent" :min="0" :max="100" :precision="0" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="最少学习时长(分钟)">
<el-input-number v-model="ruleForm.minStudyMinutes" :min="0" :precision="0" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<!--
<el-col :span="12">
<el-form-item label="解锁规则">
<el-select v-model="ruleForm.unlockRule" style="width: 100%">
<el-option v-for="item in unlockRuleOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</el-form-item>
</el-col>
-->
</el-row>
<!--
<el-row :gutter="20">
<el-col :span="8"><el-form-item label="允许跳过"><el-switch v-model="ruleForm.allowSkip"></el-switch></el-form-item></el-col>
<el-col :span="8"><el-form-item label="允许拖动"><el-switch v-model="ruleForm.allowDrag"></el-switch></el-form-item></el-col>
<el-col :span="8"><el-form-item label="暂停计时"><el-switch v-model="ruleForm.pauseCountTime"></el-switch></el-form-item></el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8"><el-form-item label="隐藏计时"><el-switch v-model="ruleForm.hiddenCountTime"></el-switch></el-form-item></el-col>
<el-col :span="8"><el-form-item label="无操作计时"><el-switch v-model="ruleForm.inactiveCountTime"></el-switch></el-form-item></el-col>
</el-row>
-->
<el-form-item>
<el-button type="primary" @click="saveRule">保存完成规则</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="状态设置" name="status" style="padding-top: 12px">
<el-form :model="nodeForm" label-width="110px">
<el-form-item label="节点状态">
<el-radio-group v-model="nodeForm.status">
<el-radio label="enabled">启用</el-radio>
<el-radio label="disabled">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="是否必学">
<el-switch v-model="nodeForm.required"></el-switch>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveNodeBasic">保存状态</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</el-card>
</el-col>
</el-row>
<ul v-show="contextVisible" class="outline-context-menu" :style="{left: contextLeft + 'px', top: contextTop + 'px'}">
<li @click="openNodeForm('chapter')">新增章</li>
<li v-if="contextNode.nodeType === 'chapter'" @click="openNodeForm('section', contextNode)">新增节</li>
<li v-if="contextNode.nodeType !== 'course'" @click="openNodeForm(contextNode.nodeType, contextNode)">编辑</li>
<li v-if="contextNode.nodeType !== 'course'" @click="deleteNode(contextNode)">删除</li>
<li v-if="contextNode.nodeType !== 'course'" @click="toggleNode(contextNode)">{{contextNode.status === 'enabled' ? '禁用' : '启用'}}</li>
<li v-if="contextNode.nodeType !== 'course'" @click="moveNode(contextNode, 'up')">上移</li>
<li v-if="contextNode.nodeType !== 'course'" @click="moveNode(contextNode, 'down')">下移</li>
<li v-if="contextNode.nodeType !== 'course'" @click="openResourceForm(null, contextNode)">上传资料</li>
<li v-if="contextNode.nodeType !== 'course'" @click="setRequired(contextNode)">设置必学</li>
</ul>
<el-dialog :title="nodeDialogTitle" :visible.sync="nodeDialogVisible" :close-on-click-modal="false" width="45%">
<el-form :model="nodeDialogForm" ref="nodeDialogForm" label-width="100px" :rules="nodeRules">
<el-form-item label="节点类型">
<el-tag>{{nodeDialogForm.nodeType === 'chapter' ? '章' : '节'}}</el-tag>
</el-form-item>
<el-form-item label="上级节点">
<el-input v-model="nodeDialogForm.parentName" disabled placeholder="上级节点"></el-input>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="nodeDialogForm.title" maxlength="100" placeholder="请输入标题"></el-input>
</el-form-item>
<el-form-item label="副标题">
<el-input v-model="nodeDialogForm.subtitle" maxlength="200" placeholder="请输入副标题"></el-input>
</el-form-item>
<el-form-item label="简介">
<el-input v-model="nodeDialogForm.description" type="textarea" :rows="4" maxlength="2000" show-word-limit></el-input>
</el-form-item>
<el-form-item label="排序值">
<el-input-number v-model="nodeDialogForm.sortOrder" :min="1" :precision="0" style="width: 100%"></el-input-number>
</el-form-item>
<el-form-item label="是否必学">
<el-switch v-model="nodeDialogForm.required"></el-switch>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="nodeDialogForm.status">
<el-radio label="enabled">启用</el-radio>
<el-radio label="disabled">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="nodeDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitNodeDialog">确定</el-button>
</span>
</el-dialog>
<el-dialog :title="resourceForm.id ? '编辑学习资料' : '新增学习资料'" :visible.sync="resourceDialogVisible" :close-on-click-modal="false" width="55%">
<el-form :model="resourceForm" ref="resourceForm" label-width="120px" :rules="resourceRules">
<el-form-item label="所属章节">
<el-input v-model="resourceForm.outlineName" disabled placeholder="所属章节"></el-input>
</el-form-item>
<el-form-item label="资料标题" prop="resourceTitle">
<el-input v-model="resourceForm.resourceTitle" maxlength="100" placeholder="请输入资料标题"></el-input>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="资料类型" prop="resourceType">
<el-select v-model="resourceForm.resourceType" placeholder="请选择资料类型" style="width: 100%" @change="resourceTypeChange">
<el-option v-for="item in resourceTypeOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序值" prop="sortOrder">
<el-input-number v-model="resourceForm.sortOrder" :min="1" :precision="0" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="学习时长(秒)">
<el-input-number v-model="resourceForm.durationSeconds" :min="0" :precision="0" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态">
<el-radio-group v-model="resourceForm.status">
<el-radio label="enabled">启用</el-radio>
<el-radio label="disabled">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8"><el-form-item label="是否必学"><el-switch v-model="resourceForm.required" @change="resourceRequiredChange"></el-switch></el-form-item></el-col>
<el-col :span="8"><el-form-item label="允许预览"><el-switch :value="resourceForm.allowPreview" @input="resourcePreviewChange"></el-switch></el-form-item></el-col>
<el-col :span="8"><el-form-item label="允许下载"><el-switch v-model="resourceForm.allowDownload"></el-switch></el-form-item></el-col>
</el-row>
<el-form-item label="附件" prop="fileData">
<file-upload
:value.sync="resourceForm.fileData"
:upload_number="1"
upload_mode="drag"
upload_result_category="array"
complete_result
:accept="fileAccept">
</file-upload>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="resourceDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitResource">确定</el-button>
</span>
</el-dialog>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
mixins: [initTableMixins],
data() {
return {
courseQuery: {},
courseOptions: [],
courseTypeOptions: [],
currentCourseId: "",
treeData: [],
selectedNode: {},
activeTab: "basic",
nodeForm: {},
nodeDialogVisible: false,
nodeDialogTitle: "",
nodeDialogForm: {},
nodeRules: {
title: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
sortOrder: [{ required: true, message: "必填", trigger: ["blur", "change"] }]
},
contextVisible: false,
contextLeft: 0,
contextTop: 0,
contextNode: {},
resourceData: [],
resourcePageForm: {
pageNumber: 1,
pageSize: 10,
totalCount: 0,
pageOrderName: "",
pageOrderBy: ""
},
resourceDialogVisible: false,
resourceForm: {},
resourceRules: {
resourceTitle: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
resourceType: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
sortOrder: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
fileData: [{ required: true, message: "请上传附件", trigger: ["blur", "change"] }]
},
fileAccept: "",
ruleForm: {},
statusOptions: [
{ name: "草稿", code: "draft" },
{ name: "待发布", code: "pending" },
{ name: "已发布", code: "published" },
{ name: "已下架", code: "offline" }
],
resourceTypeOptions: [
{ name: "视频", code: "video", accept: ".mp4,.mov" },
{ name: "音频", code: "audio", accept: ".mp3,.wav,.aac" },
{ name: "PDF", code: "pdf", accept: ".pdf" },
{ name: "PPT课件", code: "ppt", accept: ".ppt,.pptx" },
{ name: "Word文档", code: "word", accept: ".doc,.docx" },
{ name: "图片", code: "image", accept: ".jpg,.jpeg,.png" },
{ name: "其他", code: "other", accept: "" }
],
studyModeOptions: [
{ name: "视频", code: "video" },
{ name: "音频", code: "audio" },
{ name: "文档", code: "document" },
{ name: "图片", code: "image" },
{ name: "混合资料", code: "mixed" }
],
completionRuleOptions: [
{ name: "完成全部学习资料", code: "all_required_resource" },
{ name: "按资料学习进度", code: "resource_progress" },
{ name: "达到最少学习时长", code: "min_study_time" },
{ name: "管理员手动确认", code: "manual" }
],
unlockRuleOptions: [
{ name: "自由学习", code: "free" },
{ name: "顺序解锁", code: "sequential" }
]
}
},
computed: {
currentCourse() {
return this.courseOptions.find(v => v.id === this.currentCourseId)
},
outlineTreeData() {
if (!this.currentCourseId || !this.currentCourse) {
return []
}
return [{
id: "course-" + this.currentCourseId,
courseId: this.currentCourseId,
title: this.currentCourse.courseName,
nodeType: "course",
children: this.treeData
}]
}
},
methods: {
resetCourseQuery() {
this.courseQuery = {}
this.currentCourseId = ""
this.loadCourses()
},
getQueryParam(name) {
return new URLSearchParams(window.location.search).get(name) || ""
},
async loadCourseTypes() {
const resp = await this.$axios.post(loc() + "/courseTypes")
if (resp.code === 0) {
this.courseTypeOptions = resp.data
}
},
async loadCourses(keepCurrent = false) {
const resp = await this.$axios.post(loc() + "/courses", this.courseQuery)
if (resp.code === 0) {
this.courseOptions = resp.data
if (!keepCurrent && (!this.currentCourseId || !this.courseOptions.some(v => v.id === this.currentCourseId))) {
this.currentCourseId = this.courseOptions.length ? this.courseOptions[0].id : ""
}
await this.loadTree()
}
},
courseNameRemoteSearch(query) {
this.courseQuery.courseName = query
this.loadCourses(true)
},
courseChange() {
this.selectedNode = {}
this.loadTree()
},
courseClear() {
this.currentCourseId = ""
this.selectedNode = {}
this.treeData = []
},
async loadTree() {
if (!this.currentCourseId) {
this.treeData = []
this.selectedNode = {}
return
}
const resp = await this.$axios.post(loc() + "/tree", { courseId: this.currentCourseId })
if (resp.code === 0) {
this.treeData = resp.data
let nodeToSelect = null
if (this.selectedNode.id) {
nodeToSelect = this.findNode(this.treeData, this.selectedNode.id)
}
if (!nodeToSelect) {
nodeToSelect = this.findFirstOutlineNode(this.treeData)
}
if (nodeToSelect) {
this.nodeClick(nodeToSelect)
this.$nextTick(() => {
if (this.$refs.outlineTree) {
this.$refs.outlineTree.setCurrentKey(nodeToSelect.id)
}
})
} else {
this.selectedNode = {}
this.nodeForm = {}
this.resourceData = []
}
}
},
findFirstOutlineNode(nodes) {
if (!nodes || !nodes.length) return null
for (const node of nodes) {
if (node.nodeType !== "course") return node
const child = this.findFirstOutlineNode(node.children || [])
if (child) return child
}
return null
},
findNode(nodes, id) {
for (const node of nodes) {
if (node.id === id) return node
const child = this.findNode(node.children || [], id)
if (child) return child
}
return null
},
getParentName(type, node, course, isEdit) {
if (type === "chapter") {
return course ? course.courseName : ""
}
if (!isEdit) {
return node ? node.title : ""
}
const parent = node && node.parentId ? this.findNode(this.treeData, node.parentId) : null
return parent ? parent.title : (course ? course.courseName : "")
},
nodeClick(data) {
if (data.nodeType === "course") {
this.selectedNode = {}
this.nodeForm = {}
this.resourceData = []
return
}
this.selectedNode = data
this.nodeForm = Object.assign({}, data)
this.activeTab = "basic"
this.loadResources()
this.loadRule()
},
openContextMenu(event, data) {
event.preventDefault()
this.contextNode = data
this.contextVisible = true
this.contextLeft = event.clientX
this.contextTop = event.clientY
},
openNodeForm(type, node = null) {
if (!this.currentCourseId) {
this.$message.warning("请先选择课程")
return
}
const course = this.courseOptions.find(v => v.id === this.currentCourseId)
if (node && node.id && node.nodeType === type) {
this.nodeDialogTitle = "编辑" + (type === "chapter" ? "章" : "节")
this.nodeDialogForm = Object.assign({}, node, {
parentName: this.getParentName(type, node, course, true)
})
} else {
this.nodeDialogTitle = "新增" + (type === "chapter" ? "章" : "节")
this.nodeDialogForm = {
courseId: this.currentCourseId,
courseName: course ? course.courseName : "",
parentId: type === "section" && node ? node.id : "",
parentName: this.getParentName(type, node, course, false),
nodeType: type,
title: "",
subtitle: "",
description: "",
sortOrder: 1,
required: false,
status: "enabled"
}
}
this.nodeDialogVisible = true
this.$nextTick(() => this.$refs.nodeDialogForm && this.$refs.nodeDialogForm.clearValidate())
},
submitNodeDialog() {
this.$refs.nodeDialogForm.validate(async valid => {
if (!valid) return
const resp = await this.$axios.post(loc() + "/saveNode", this.nodeDialogForm)
if (resp.code === 0) {
this.$message.success(resp.msg)
this.nodeDialogVisible = false
await this.loadTree()
} else {
this.$message.warning(resp.msg)
}
})
},
saveNodeBasic() {
this.$refs.nodeForm.validate(async valid => {
if (!valid) return
const resp = await this.$axios.post(loc() + "/saveNode", this.nodeForm)
if (resp.code === 0) {
this.$message.success(resp.msg)
this.selectedNode = Object.assign({}, this.nodeForm)
await this.loadTree()
await this.loadRule()
} else {
this.$message.warning(resp.msg)
}
})
},
deleteNode(node) {
this.$confirm("确定要删除该节点吗?若下级有内容将不允许删除。", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(async () => {
const resp = await this.$axios.post(loc() + "/deleteNode", { id: node.id })
if (resp.code === 0) {
this.$message.success(resp.msg)
this.selectedNode = {}
await this.loadTree()
} else {
this.$message.warning(resp.msg)
}
})
},
async toggleNode(node) {
const status = node.status === "enabled" ? "disabled" : "enabled"
const resp = await this.$axios.post(loc() + "/toggleNode", { id: node.id, status })
if (resp.code === 0) {
this.$message.success(resp.msg)
await this.loadTree()
}
},
async moveNode(node, direction) {
const resp = await this.$axios.post(loc() + "/moveNode", { id: node.id, direction })
if (resp.code === 0) {
await this.loadTree()
}
},
async setRequired(node) {
const next = !node.required
const data = Object.assign({}, node, { required: next })
const resp = await this.$axios.post(loc() + "/saveNode", data)
if (resp.code === 0) {
this.$message.success(resp.msg)
await this.loadTree()
if (this.selectedNode.id === node.id) {
this.nodeClick(Object.assign({}, node, { required: next }))
}
}
},
async loadResources() {
if (!this.selectedNode.id) return
const resp = await this.$axios.post(loc() + "/resourcePage", Object.assign({}, this.resourcePageForm, { outlineId: this.selectedNode.id }))
if (resp.code === 0) {
this.resourceData = resp.data.list
this.resourcePageForm.totalCount = resp.data.totalCount
}
},
resourcePageOrder(column) {
this.resourcePageForm.pageOrderName = column.prop
this.resourcePageForm.pageOrderBy = column.order
this.loadResources()
},
resourceSizeChange(val) {
this.resourcePageForm.pageSize = val
this.loadResources()
},
resourceNumberChange(val) {
this.resourcePageForm.pageNumber = val
this.loadResources()
},
resourceIndex(index) {
return index + (this.resourcePageForm.pageNumber - 1) * this.resourcePageForm.pageSize + 1
},
openResourceForm(row = null, node = null) {
const target = node || this.selectedNode
if (!target.id) {
this.$message.warning("请先选择章/节节点")
return
}
if (row) {
this.resourceForm = Object.assign({}, row, {
outlineName: target.title,
fileData: this.parseFiles(row.fileData)
})
} else {
this.resourceForm = {
courseId: target.courseId,
outlineId: target.id,
outlineName: target.title,
resourceTitle: "",
resourceType: "video",
fileExt: "",
fileData: [],
durationSeconds: 0,
sortOrder: 1,
required: false,
allowPreview: true,
allowDownload: false,
status: "enabled"
}
}
if (this.resourceForm.required) this.$set(this.resourceForm, "allowPreview", true)
this.resourceTypeChange(this.resourceForm.resourceType)
this.resourceDialogVisible = true
this.$nextTick(() => this.$refs.resourceForm && this.$refs.resourceForm.clearValidate())
},
resourceRequiredChange(required) {
if (required) this.$set(this.resourceForm, "allowPreview", true)
},
resourcePreviewChange(allowPreview) {
if (!allowPreview && this.resourceForm.required) {
this.$set(this.resourceForm, "allowPreview", true)
this.$message.warning("必学资料必须允许预览,请先关闭“是否必学”")
return
}
this.$set(this.resourceForm, "allowPreview", allowPreview)
},
resourceTypeChange(val) {
const type = this.resourceTypeOptions.find(v => v.code === val)
this.fileAccept = type ? type.accept : ""
this.autoFillMediaDuration()
},
submitResource() {
this.$refs.resourceForm.validate(async valid => {
if (!valid) return
const data = Object.assign({}, this.resourceForm)
data.fileExt = this.getFileExt(data.fileData)
if (!this.validResourceExt(data.resourceType, data.fileExt)) {
this.$message.warning("当前资料类型不支持上传 ." + data.fileExt + " 格式文件")
return
}
delete data.outlineName
data.fileData = JSON.stringify(data.fileData || [])
const resp = await this.$axios.post(loc() + "/saveResource", data)
if (resp.code === 0) {
this.$message.success(resp.msg)
this.resourceDialogVisible = false
this.loadResources()
} else {
this.$message.warning(resp.msg)
}
})
},
deleteResource(row) {
this.$confirm("确定要删除该学习资料吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(async () => {
const resp = await this.$axios.post(loc() + "/deleteResource", { id: row.id })
if (resp.code === 0) {
this.$message.success(resp.msg)
this.loadResources()
}
})
},
async loadRule() {
if (!this.selectedNode.id) return
const resp = await this.$axios.post(loc() + "/getRule", {
courseId: this.selectedNode.courseId,
targetType: "outline",
targetId: this.selectedNode.id
})
if (resp.code === 0) {
this.ruleForm = Object.assign({}, resp.data, {
minStudyMinutes: this.formatDurationMinutes(resp.data.minStudySeconds)
})
}
},
async saveRule() {
const data = Object.assign({}, this.ruleForm, {
minStudySeconds: Math.round((this.ruleForm.minStudyMinutes || 0) * 60)
})
delete data.minStudyMinutes
const resp = await this.$axios.post(loc() + "/saveRule", data)
if (resp.code === 0) {
this.$message.success(resp.msg)
await this.loadTree()
await this.loadRule()
} else {
this.$message.warning(resp.msg)
}
},
parseFiles(value) {
if (!value) return []
if (Array.isArray(value)) return value
try {
return JSON.parse(value)
} catch (e) {
return []
}
},
getFileUrl(files) {
const file = Array.isArray(files) && files.length ? files[0] : null
if (!file) return ""
return file.url || file.response?.data || file.data || ""
},
getFileExt(files) {
const file = Array.isArray(files) && files.length ? files[0] : null
if (!file) return ""
const name = file.name || file.url || file.response?.data || ""
const index = name.lastIndexOf(".")
return index > -1 ? name.substring(index + 1).toLowerCase() : ""
},
formatDurationMinutes(durationSeconds) {
const seconds = Number(durationSeconds || 0)
return seconds > 0 ? Math.ceil(seconds / 60) : 0
},
formatDurationSeconds(durationSeconds) {
const seconds = Math.ceil(Number(durationSeconds || 0))
return seconds > 0 ? seconds + "秒" : "0秒"
},
autoFillMediaDuration() {
if (!["video", "audio"].includes(this.resourceForm.resourceType)) return
const url = this.getFileUrl(this.resourceForm.fileData)
if (!url) return
const media = document.createElement(this.resourceForm.resourceType === "video" ? "video" : "audio")
media.preload = "metadata"
media.onloadedmetadata = () => {
window.URL.revokeObjectURL(media.src)
if (isFinite(media.duration) && media.duration > 0) {
// 媒体时长按秒保存,避免先取整到分钟再换算导致实际时长被放大。
this.$set(this.resourceForm, "durationSeconds", Math.ceil(media.duration))
}
}
media.onerror = () => {
window.URL.revokeObjectURL(media.src)
}
const fileId = url.match(/[?&]id=([^&]+)/)
media.src = this.resourceForm.resourceType === "audio" && fileId
? "/platform/sys/file/preview?id=" + fileId[1] : url
},
validResourceExt(resourceType, fileExt) {
const item = this.resourceTypeOptions.find(v => v.code === resourceType)
if (!item || !item.accept) return true
return item.accept.split(",").map(v => v.replace(".", "").toLowerCase()).includes(fileExt)
},
getResourceTypeName(code) {
const item = this.resourceTypeOptions.find(v => v.code === code)
return item ? item.name : code
}
},
watch: {
"resourceForm.fileData": {
handler() {
this.autoFillMediaDuration()
},
deep: true
}
},
mounted() {
document.addEventListener("click", () => this.contextVisible = false)
},
async created() {
await this.loadCourseTypes()
const courseId = this.getQueryParam("courseId")
if (courseId) {
this.currentCourseId = courseId
this.courseQuery.courseId = courseId
await this.loadCourses(true)
this.$delete(this.courseQuery, "courseId")
} else {
await this.loadCourses()
}
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,12 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<el-card shadow="never">
<div slot="header">课程介绍</div>
<el-empty description="课程介绍页面后续设计"></el-empty>
</el-card>
</div>
<!--#
}
#-->
@@ -0,0 +1,350 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
.course-display {
padding: 8px 10px 22px;
background: #f4f5f7;
min-height: calc(100vh - 88px);
}
.course-filter {
background: #fff;
border: 1px solid #e5e8ef;
margin-bottom: 28px;
}
.filter-row {
display: flex;
min-height: 70px;
border-bottom: 1px solid #e8ebf0;
}
.filter-row:last-child {
border-bottom: 0;
}
.filter-label {
width: 132px;
padding: 20px 16px;
background: #eef5ff;
color: #1f2d3d;
font-weight: 600;
text-align: center;
}
.filter-options {
flex: 1;
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: 10px 18px;
padding: 16px 18px;
}
.filter-chip {
min-width: 76px;
height: 32px;
padding: 0 18px;
border: 0;
border-radius: 9px;
background: transparent;
color: #526070;
cursor: pointer;
line-height: 32px;
text-align: center;
}
.filter-chip.active {
background: #1e63e9;
color: #fff;
font-weight: 600;
}
.course-result-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
}
.course-count {
color: #1f2d3d;
}
.course-count span {
color: #2468f2;
padding: 0 4px;
}
.course-search {
width: 310px;
}
.course-list {
background: #fff;
border-top: 1px solid #dfe4ec;
}
.course-item {
display: flex;
gap: 20px;
padding: 18px 0;
border-bottom: 1px solid #dfe4ec;
}
.course-cover {
position: relative;
width: 270px;
height: 150px;
flex: 0 0 270px;
background: #e9eef5;
border-radius: 4px;
overflow: hidden;
}
.course-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.course-cover-empty {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #8a97a8;
background: linear-gradient(135deg, #eaf0f8, #d8e3f2);
font-weight: 600;
}
.course-cover-tag {
position: absolute;
right: 0;
bottom: 12px;
min-width: 70px;
height: 24px;
padding: 0 10px;
background: #f5a400;
color: #fff;
font-size: 12px;
line-height: 24px;
text-align: center;
border-radius: 12px 0 0 12px;
}
.course-info {
flex: 1;
min-width: 0;
padding-right: 12px;
}
.course-title {
display: inline-block;
margin: 4px 0 8px;
color: #111827;
font-size: 18px;
font-weight: 700;
cursor: pointer;
}
.course-title:hover {
color: #006fc9;
}
.course-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
color: #8b96a6;
font-size: 13px;
margin-bottom: 12px;
}
.course-intro {
color: #4f5f73;
line-height: 1.8;
margin-bottom: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.course-time {
color: #8b96a6;
font-size: 13px;
}
.recommend-tags {
display: inline-flex;
gap: 6px;
margin-left: 8px;
vertical-align: 2px;
}
.course-empty {
background: #fff;
padding: 80px 0;
text-align: center;
color: #8b96a6;
border-top: 1px solid #dfe4ec;
}
</style>
<div id="app" class="course-display" v-cloak>
<div class="course-filter">
<div class="filter-row">
<div class="filter-label">推荐标识</div>
<div class="filter-options">
<button class="filter-chip" :class="{active: !query.recommendFlag}" @click="selectRecommend('')">全部</button>
<button
v-for="item in recommendOptions"
:key="item.code"
class="filter-chip"
:class="{active: query.recommendFlag === item.code}"
@click="selectRecommend(item.code)">
{{item.name}}
</button>
</div>
</div>
<div class="filter-row">
<div class="filter-label">课程类型</div>
<div class="filter-options">
<button class="filter-chip" :class="{active: !query.courseTypeId}" @click="selectCourseType('')">全部</button>
<button
v-for="item in courseTypeOptions"
:key="item.id"
class="filter-chip"
:class="{active: query.courseTypeId === item.id}"
@click="selectCourseType(item.id)">
{{item.typeName}}
</button>
</div>
</div>
</div>
<div class="course-result-bar">
<div class="course-count">为您找到相关课程<span>{{pageForm.totalCount || 0}}</span>门</div>
<el-input
class="course-search"
v-model="query.keyword"
clearable
placeholder="搜索关键字"
prefix-icon="el-icon-search"
@keyup.enter.native="doSearch"
@clear="doSearch">
<el-button slot="append" icon="el-icon-search" @click="doSearch"></el-button>
</el-input>
</div>
<div v-if="pageForm.list && pageForm.list.length" class="course-list">
<div v-for="course in pageForm.list" :key="course.id" class="course-item">
<div class="course-cover">
<img v-if="getCoverUrl(course.cover)" :src="getCoverUrl(course.cover)" :alt="course.courseName">
<div v-else class="course-cover-empty">{{course.courseTypeName || '课程'}}</div>
<div v-if="firstRecommendName(course.recommendFlags)" class="course-cover-tag">{{firstRecommendName(course.recommendFlags)}}</div>
</div>
<div class="course-info">
<div>
<span class="course-title" @click="openDetail(course)">{{course.courseName}}</span>
<span class="recommend-tags">
<el-tag v-for="code in splitFlags(course.recommendFlags)" :key="code" size="mini" type="warning">{{getRecommendName(code)}}</el-tag>
</span>
</div>
<div class="course-meta">
<span><i class="el-icon-office-building"></i> {{course.courseTypeName || '未设置课程类型'}}</span>
<span><i class="el-icon-user"></i> {{course.lecturerName || '未设置讲师'}}</span>
</div>
<div class="course-intro">{{course.courseIntro || '暂无课程介绍'}}</div>
<div class="course-time">
<i class="el-icon-time"></i>
<span v-if="course.openType === 'long_term'">长期开放</span>
<span v-else>{{course.startTimeText || '-'}} 至 {{course.endTimeText || '-'}}</span>
</div>
</div>
</div>
</div>
<div v-else class="course-empty">暂无相关课程</div>
<el-pagination
class="mt20"
background
layout="total, sizes, prev, pager, next"
:current-page.sync="pageForm.pageNumber"
:page-size.sync="pageForm.pageSize"
:total="pageForm.totalCount"
@size-change="pageData"
@current-change="pageData">
</el-pagination>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
query: {
keyword: "",
courseTypeId: "",
recommendFlag: ""
},
courseTypeOptions: [],
recommendOptions: [],
pageForm: {
pageNumber: 1,
pageSize: 10,
totalCount: 0,
list: []
}
}
},
methods: {
async pageData() {
const resp = await this.$axios.post(loc() + "/pageData", Object.assign({}, this.query, {
pageNumber: this.pageForm.pageNumber,
pageSize: this.pageForm.pageSize
}))
if (resp.code === 0) {
this.pageForm = resp.data
}
},
doSearch() {
this.pageForm.pageNumber = 1
this.pageData()
},
selectRecommend(code) {
this.query.recommendFlag = code
this.doSearch()
},
selectCourseType(id) {
this.query.courseTypeId = id
this.doSearch()
},
async loadOptions() {
const typeResp = await this.$axios.post(loc() + "/courseTypes")
if (typeResp.code === 0) {
this.courseTypeOptions = typeResp.data
}
const recommendResp = await this.$axios.post(loc() + "/recommendOptions")
if (recommendResp.code === 0) {
this.recommendOptions = recommendResp.data
}
},
getCoverUrl(cover) {
if (!cover) return ""
if (Array.isArray(cover)) {
return cover.length ? (cover[0].url || cover[0].response?.data || cover[0].data || "") : ""
}
if (typeof cover === "string" && cover.trim().startsWith("[")) {
try {
const files = JSON.parse(cover)
return files.length ? (files[0].url || files[0].response?.data || files[0].data || "") : ""
} catch (e) {
return ""
}
}
return cover
},
splitFlags(flags) {
return flags ? flags.split(",").filter(Boolean) : []
},
getRecommendName(code) {
const item = this.recommendOptions.find(v => v.code === code)
return item ? item.name : code
},
firstRecommendName(flags) {
const codes = this.splitFlags(flags)
return codes.length ? this.getRecommendName(codes[0]) : ""
},
openDetail(course) {
window.location.href = loc() + "/study?id=" + course.id
}
},
async created() {
await this.loadOptions()
await this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,213 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
[v-cloak] { display: none; }
#sub-app-container-main-content { padding: 0 !important; overflow: hidden; background: #f5f8fc; }
#sub-app-container-main-content-body { height: 100%; overflow: hidden; }
.learning-list-page { height: 100%; min-height: 0; overflow: hidden; color: #17233d; background: #f5f8fc; }
.learning-body { width: min(1180px, calc(100% - 24px)); height: 100%; min-height: 0; margin: 0 auto; padding: 12px 0 22px; display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 30px; box-sizing: border-box; }
.filter-panel { min-height: 0; overflow: hidden; display: flex; flex-direction: column; gap: 16px; }
.filter-group { padding: 16px; flex: none; background: #fff; border-radius: 9px; box-shadow: 0 6px 22px rgba(31, 70, 121, .08); }
.filter-group + .filter-group { margin-top: 0; }
.filter-title { margin: 2px 0 10px; padding-left: 9px; position: relative; color: #24344e; font-size: 14px; font-weight: 700; line-height: 20px; }
.filter-title::before { content: ""; width: 3px; height: 14px; position: absolute; left: 0; top: 3px; border-radius: 2px; background: #1677ff; }
.filter-option { height: 34px; padding: 0 10px; display: flex; align-items: center; color: #45536a; border-radius: 6px; cursor: pointer; font-size: 14px; transition: .2s; }
.filter-option-icon { width: 18px; margin-right: 10px; color: #6e7d94; font-size: 16px; text-align: center; }
.filter-option:hover { color: #1677ff; background: #edf5ff; }
.filter-option.active { color: #1677ff; background: #eaf3ff; font-weight: 600; }
.filter-option.active .filter-option-icon { color: #1677ff; }
.course-content { min-width: 0; min-height: 0; overflow-y: auto; padding: 0 10px 6px 0; scrollbar-gutter: stable; }
.course-content::-webkit-scrollbar { width: 7px; }
.course-content::-webkit-scrollbar-thumb { border-radius: 5px; background: #d6e1ef; }
.course-toolbar { height: 38px; margin-bottom: 10px; position: sticky; top: 0; z-index: 2; display: flex; align-items: flex-start; justify-content: space-between; color: #4e5d74; font-size: 14px; background: #f5f8fc; }
.course-toolbar > span { padding-top: 8px; font-size: 15px; font-weight: 600; }
.course-toolbar .el-select { width: 122px; }
.course-toolbar .el-input__inner { border-color: #e2e9f2; border-radius: 6px; background: #fff; color: #53627a; }
.course-list { display: flex; flex-direction: column; gap: 16px; }
.course-card { min-height: 160px; display: grid; grid-template-columns: 236px minmax(0, 1fr) 112px; overflow: hidden; background: #fff; border: 0; border-radius: 10px; box-shadow: 0 5px 18px rgba(30, 66, 115, .08); transition: box-shadow .2s, transform .2s; }
.course-card:hover { transform: translateY(-1px); box-shadow: 0 9px 24px rgba(30, 67, 119, .12); }
.course-cover { position: relative; width: 100%; aspect-ratio: 16 / 9; align-self: center; overflow: hidden; background: #eaf5ee; }
.course-cover img { position: absolute; inset: 0; width: 100%; height: 100%; display: block; object-fit: cover; }
.course-cover-empty { position: absolute; inset: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: #77a28a; font-size: 18px; background: linear-gradient(135deg, #e5f5ea, #f3faf6); }
.course-cover-empty i { margin-right: 8px; font-size: 32px; }
.course-card:nth-child(3n+2) .course-cover-empty { color: #7188ae; background: linear-gradient(135deg, #e7effc, #f3f7fe); }
.course-card:nth-child(3n) .course-cover-empty { color: #bc8768; background: linear-gradient(135deg, #fff0e7, #fff8f3); }
.course-info { min-width: 0; padding: 18px 18px 14px 22px; box-sizing: border-box; }
.course-heading { display: flex; align-items: center; gap: 8px; min-width: 0; }
.course-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #17243b; font-size: 19px; font-weight: 700; cursor: pointer; }
.course-type { flex: none; color: #98a2b3; font-size: 13px; }
.recommend-tag { flex: none; padding: 2px 5px; color: #ff4d4f; background: #fff1f0; border-radius: 3px; font-size: 12px; }
.course-intro { height: 43px; margin-top: 7px; overflow: hidden; color: #59677c; font-size: 14px; line-height: 22px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.course-meta { margin-top: 11px; display: flex; align-items: center; flex-wrap: wrap; gap: 18px; color: #91a0b7; font-size: 13px; }
.course-meta span { display: inline-flex; align-items: center; gap: 4px; }
.course-meta i { font-size: 14px; }
.course-action { display: flex; align-items: center; justify-content: center; padding-right: 18px; }
.course-action .el-button { width: 84px; border-radius: 18px; color: #1677ff; border-color: #a9d0ff; background: #f5faff; font-weight: 600; }
.course-empty { height: 280px; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #9aa7ba; background: #fff; border: 1px solid #e7e9ed; border-radius: 12px; }
.course-empty i { margin-bottom: 12px; font-size: 45px; }
.course-pagination { margin: 24px 0 4px; text-align: right; }
@media (max-width: 980px) { .learning-body { grid-template-columns: 180px minmax(0, 1fr); gap: 18px; } .course-card { grid-template-columns: 190px minmax(0, 1fr) 96px; } }
@media (max-width: 820px) { #sub-app-container-main-content, #sub-app-container-main-content-body { overflow-y: auto; } .learning-list-page { height: auto; min-height: 100%; overflow: visible; } .learning-body { height: auto; min-height: auto; grid-template-columns: 1fr; padding-bottom: 24px; overflow: visible; } .filter-panel { display: flex; flex-direction: row; gap: 12px; overflow-x: auto; } .filter-group { min-width: 180px; } .course-content { overflow: visible; padding-right: 0; } .course-toolbar { position: static; } .course-card { grid-template-columns: 170px minmax(0, 1fr); } .course-action { grid-column: 2; justify-content: flex-end; padding: 0 16px 14px; } }
</style>
<div id="app" class="learning-list-page" v-cloak>
<div class="learning-body">
<aside class="filter-panel">
<div class="filter-group">
<div class="filter-title">课程分类</div>
<div class="filter-option" :class="{active: !query.courseTypeId}" @click="selectCourseType('')"><i class="filter-option-icon el-icon-menu"></i>全部</div>
<div v-for="item in courseTypeOptions" :key="item.id" class="filter-option" :class="{active: query.courseTypeId === item.id}" @click="selectCourseType(item.id)"><i class="filter-option-icon el-icon-collection-tag"></i>{{item.typeName}}</div>
</div>
<div v-if="recommendOptions.length" class="filter-group">
<div class="filter-title">推荐类型</div>
<div class="filter-option" :class="{active: !query.recommendFlag}" @click="selectRecommend('')"><i class="filter-option-icon el-icon-star-on"></i>全部</div>
<div v-for="item in recommendOptions" :key="item.code" class="filter-option" :class="{active: query.recommendFlag === item.code}" @click="selectRecommend(item.code)"><i class="filter-option-icon el-icon-star-off"></i>{{item.name}}</div>
</div>
</aside>
<main class="course-content" v-loading="loading">
<div class="course-toolbar">
<span>共 {{pageForm.totalCount || 0}} 门课程</span>
<div>
<span style="margin-right: 8px">排序:</span>
<el-select v-model="query.sortType" size="small" @change="doSearch">
<el-option label="综合排序" value="default"></el-option>
<el-option label="观看最多" value="view_count"></el-option>
</el-select>
</div>
</div>
<div v-if="pageForm.list && pageForm.list.length" class="course-list">
<article v-for="course in pageForm.list" :key="course.id" class="course-card">
<div class="course-cover">
<img v-if="getCoverUrl(course.cover)" :src="getCoverUrl(course.cover)" :alt="course.courseName">
<div v-else class="course-cover-empty"><i class="el-icon-reading"></i>{{course.courseTypeName || '在线课程'}}</div>
</div>
<div class="course-info">
<div class="course-heading">
<span class="course-name" @click="startStudy(course)">{{course.courseName}}</span>
<span class="course-type">{{course.courseTypeName || '未分类'}}</span>
<span v-if="firstRecommendName(course.recommendFlags)" class="recommend-tag">{{firstRecommendName(course.recommendFlags)}}</span>
</div>
<div class="course-intro">{{course.courseIntro || '暂无课程介绍'}}</div>
<div class="course-meta">
<span><i class="el-icon-collection"></i>{{course.resourceCount || 0}} 课节</span>
<span><i class="el-icon-time"></i>{{formatDuration(course.totalDurationSeconds)}}</span>
<span><i class="el-icon-user"></i>{{course.lecturerName || '未设置讲师'}}</span>
<span><i class="el-icon-view"></i>{{course.viewCount || 0}}</span>
</div>
</div>
<div class="course-action">
<el-button size="small" type="primary" plain @click="startStudy(course)">开始学习</el-button>
</div>
</article>
</div>
<div v-else-if="!loading" class="course-empty"><i class="el-icon-reading"></i><span>暂无相关课程</span></div>
<el-pagination
v-if="pageForm.totalCount > pageForm.pageSize"
class="course-pagination"
background
layout="total, prev, pager, next"
:current-page.sync="pageForm.pageNumber"
:page-size="pageForm.pageSize"
:total="pageForm.totalCount"
@current-change="pageData">
</el-pagination>
</main>
</div>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
loading: false,
query: { keyword: "", courseTypeId: "", recommendFlag: "", sortType: "default" },
courseTypeOptions: [],
recommendOptions: [],
pageForm: { pageNumber: 1, pageSize: 10, totalCount: 0, list: [] }
}
},
methods: {
pageData() {
this.loading = true
this.$axios.post(loc() + "/pageData", Object.assign({}, this.query, {
pageNumber: this.pageForm.pageNumber,
pageSize: this.pageForm.pageSize
})).then((resp) => {
if (resp.code === 0) {
this.pageForm = resp.data
} else {
this.$message.warning(resp.msg)
}
}).finally(() => {
this.loading = false
})
},
loadOptions() {
return Promise.all([
this.$axios.post(loc() + "/courseTypes"),
this.$axios.post(loc() + "/recommendOptions")
]).then((responses) => {
this.courseTypeOptions = responses[0].code === 0 ? responses[0].data : []
this.recommendOptions = responses[1].code === 0 ? responses[1].data : []
})
},
doSearch() {
this.pageForm.pageNumber = 1
this.pageData()
},
selectCourseType(id) {
this.query.courseTypeId = id
this.doSearch()
},
selectRecommend(code) {
this.query.recommendFlag = code
this.doSearch()
},
getCoverUrl(cover) {
if (!cover) return ""
if (Array.isArray(cover)) return cover.length ? (cover[0].url || cover[0].data || "") : ""
if (typeof cover === "string" && cover.trim().startsWith("[")) {
try {
const files = JSON.parse(cover)
return files.length ? (files[0].url || files[0].data || "") : ""
} catch (e) {
return ""
}
}
return cover
},
splitFlags(flags) {
return flags ? flags.split(",").filter(Boolean) : []
},
firstRecommendName(flags) {
const codes = this.splitFlags(flags)
if (!codes.length) return ""
const item = this.recommendOptions.find((option) => option.code === codes[0])
return item ? item.name : codes[0]
},
formatDuration(seconds) {
const total = Number(seconds) || 0
if (total < 3600) return Math.max(1, Math.ceil(total / 60)) + " 分钟"
const hours = Math.floor(total / 3600)
const minutes = Math.ceil((total % 3600) / 60)
return minutes ? hours + "小时" + minutes + "分钟" : hours + "小时"
},
startStudy(course) {
pjaxReplace("/platform/learning/course/display/study?id=" + encodeURIComponent(course.id || ""))
}
},
created() {
this.loadOptions().then(() => {
this.pageData()
})
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,755 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
#sidebar-menu,
#menu-toggle-btn,
#menu-overlay {
display: none !important;
}
#sub-app-container-main-content {
padding: 0 !important;
}
#sub-app-container-main-content-body {
height: 100%;
}
.study-page {
min-height: calc(100vh - 50px);
background: #eef3f8;
}
.study-header {
height: 150px;
margin: 12px 14px 0;
padding: 20px 24px;
color: #1f2937;
background: linear-gradient(120deg, #ffffff 0%, #f4f8ff 58%, #eaf2ff 100%);
border: 1px solid #dbe7f5;
border-radius: 10px;
box-shadow: 0 6px 18px rgba(31, 64, 116, .08);
display: flex;
align-items: center;
gap: 22px;
box-sizing: border-box;
}
.study-back {
align-self: flex-start;
margin-left: auto;
order: 5;
color: #1e63c8;
border-color: #b8d0f2;
background: #fff;
}
.study-cover {
width: 190px;
height: 102px;
object-fit: cover;
background: #eef4fb;
border: 1px solid #d7e3f1;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(31, 64, 116, .12);
}
.study-cover-empty {
width: 190px;
height: 102px;
display: flex;
align-items: center;
justify-content: center;
background: #eef4fb;
color: #6b7d90;
border: 1px solid #d7e3f1;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(31, 64, 116, .12);
font-weight: 600;
}
.study-course-main {
flex: 1;
min-width: 0;
}
.study-title {
font-size: 28px;
line-height: 1.2;
font-weight: 700;
margin-bottom: 14px;
}
.study-title .el-tag {
margin-left: 8px;
vertical-align: 5px;
}
.study-meta {
display: flex;
flex-wrap: wrap;
gap: 10px 26px;
color: #526376;
font-size: 14px;
}
.study-body {
display: flex;
gap: 14px;
height: calc(100vh - 212px);
padding: 14px;
box-sizing: border-box;
}
.study-tree-panel {
width: 340px;
background: #fff;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.study-tree-title {
height: 54px;
padding: 0 18px;
display: flex;
align-items: center;
justify-content: space-between;
color: #1768e5;
font-size: 18px;
font-weight: 700;
border-bottom: 1px solid #edf1f7;
}
.study-tree {
flex: 1;
overflow: auto;
padding: 10px;
}
.study-node {
display: inline-flex;
align-items: center;
max-width: 100%;
gap: 6px;
}
.study-node-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.study-content {
flex: 1;
min-width: 0;
background: #fff;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.resource-head {
min-height: 54px;
padding: 0 18px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #edf1f7;
}
.resource-title {
color: #1f2937;
font-size: 16px;
font-weight: 600;
}
.resource-actions {
display: flex;
align-items: center;
gap: 10px;
}
.study-progress {
width: 180px;
}
.resource-view {
flex: 1;
overflow: auto;
padding: 20px;
background: #f8fafc;
}
.media-player {
width: 100%;
max-height: calc(100vh - 300px);
background: #000;
}
.audio-wrap {
display: flex;
align-items: center;
justify-content: center;
height: 320px;
background: linear-gradient(135deg, #132544, #0a1224);
border-radius: 6px;
}
.audio-wrap audio {
width: 80%;
}
.image-preview {
max-width: 100%;
display: block;
margin: 0 auto;
background: #fff;
}
.doc-frame {
width: 100%;
height: calc(100vh - 275px);
border: 0;
background: #fff;
}
.file-fallback {
padding: 60px 20px;
text-align: center;
color: #667085;
background: #fff;
border-radius: 6px;
}
</style>
<div id="app" class="study-page" v-cloak>
<div class="study-header">
<el-button class="study-back" icon="el-icon-back" size="small" @click="goBack">返回</el-button>
<img v-if="coverUrl" class="study-cover" :src="coverUrl" :alt="course.courseName">
<div v-else class="study-cover-empty">课程图片</div>
<div class="study-course-main">
<div class="study-title">
{{course.courseName || '课程学习'}}
<el-tag v-for="code in splitFlags(course.recommendFlags)" :key="code" size="small" type="warning">{{getRecommendName(code)}}</el-tag>
</div>
<div class="study-meta">
<span><i class="el-icon-user"></i> 授课讲师:{{course.lecturerName || '未设置'}}</span>
<span><i class="el-icon-date"></i> {{course.openType === 'long_term' ? '长期开放' : ((course.startTimeText || '-') + ' 至 ' + (course.endTimeText || '-'))}}</span>
<span><i class="el-icon-collection-tag"></i> 课程类型:{{course.courseTypeName || '未设置'}}</span>
</div>
</div>
</div>
<div class="study-body">
<div class="study-tree-panel">
<div class="study-tree-title">
<span>课程安排</span>
<el-button type="text" size="mini" @click="collapseAll">收起</el-button>
</div>
<div class="study-tree">
<el-tree
ref="studyTree"
:data="treeData"
node-key="id"
default-expand-all
highlight-current
:expand-on-click-node="false"
:props="{children:'children', label:'title'}"
@node-click="nodeClick">
<span slot-scope="{ data }" class="study-node">
<i :class="getNodeIcon(data)"></i>
<span class="study-node-title">{{data.title}}</span>
<el-tag v-if="data.required" size="mini" type="warning">必学</el-tag>
</span>
</el-tree>
</div>
</div>
<div class="study-content">
<div class="resource-head">
<div class="resource-title">{{selectedResource.title || '请选择课程资料'}}</div>
<div class="resource-actions">
<el-progress
v-if="selectedResource.id"
class="study-progress"
:percentage="recordProgress"
:stroke-width="8">
</el-progress>
<el-tag v-if="studying" size="mini" type="success">学习中 {{recordStudyTime}}</el-tag>
<el-button v-if="selectedResource.id && !studying" size="mini" type="primary" icon="el-icon-video-play" @click="startStudy">开始学习</el-button>
<el-button v-if="studying" size="mini" type="danger" icon="el-icon-video-pause" @click="finishStudy(false)">结束学习</el-button>
<el-button v-if="fileUrl" size="mini" type="text" icon="el-icon-download" @click="openFile">打开原文件</el-button>
</div>
</div>
<div class="resource-view">
<template v-if="selectedResource.id">
<video
v-if="selectedResource.resourceType === 'video'"
ref="mediaPlayer"
class="media-player"
:src="fileUrl"
controls
controlslist="nodownload"
@play="mediaPlay"
@ended="mediaEnded"
@timeupdate="saveProgress"
@loadedmetadata="mediaReady">
</video>
<div v-else-if="selectedResource.resourceType === 'audio'" class="audio-wrap">
<audio
ref="mediaPlayer"
:src="fileUrl"
controls
controlslist="nodownload"
@play="mediaPlay"
@ended="mediaEnded"
@timeupdate="saveProgress"
@loadedmetadata="mediaReady">
</audio>
</div>
<img v-else-if="selectedResource.resourceType === 'image'" class="image-preview" :src="fileUrl" :alt="selectedResource.title">
<iframe v-else-if="canInlinePreview(selectedResource)" class="doc-frame" :src="inlinePreviewUrl"></iframe>
<div v-else class="file-fallback">
<file-preview :files="selectedResource.fileData" complete_result></file-preview>
<el-button class="mt20" type="primary" icon="el-icon-view" @click="openFile">打开资料</el-button>
</div>
</template>
<el-empty v-else description="请选择左侧课程资料"></el-empty>
</div>
</div>
</div>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
courseId: "",
apiBase: "/platform/learning/course/display",
recordApi: "/platform/learning/study/record",
course: {},
treeData: [],
selectedResource: {},
recommendOptions: [],
studying: false,
currentSegmentId: "",
recordProgress: 0,
recordStudyTime: "0秒",
pendingSeconds: 0,
heartbeatTimer: null,
startingStudy: false,
autoResumeKey: "",
lastPositionSyncAt: 0,
lastActiveAt: Date.now(),
inactiveLimit: 60000
}
},
computed: {
coverUrl() {
return this.getFileUrl(this.course.cover)
},
fileUrl() {
return this.getFileUrl(this.selectedResource.fileData)
},
inlinePreviewUrl() {
const id = this.getFileId(this.selectedResource.fileData)
return id ? this.apiBase + "/pdfPreview?id=" + encodeURIComponent(id) : this.fileUrl
}
},
methods: {
getQuery(name) {
return new URLSearchParams(window.location.search).get(name) || ""
},
async loadCourse() {
const resp = await this.$axios.post(this.apiBase + "/courseInfo", { id: this.courseId })
if (resp.code === 0) {
this.course = resp.data
} else {
this.$message.warning(resp.msg)
}
},
async loadTree() {
const resp = await this.$axios.post(this.apiBase + "/studyTree", { courseId: this.courseId })
if (resp.code === 0) {
this.treeData = resp.data || []
const first = this.findFirstResource(this.treeData)
if (first) {
this.nodeClick(first)
this.$nextTick(() => this.$refs.studyTree && this.$refs.studyTree.setCurrentKey(first.id))
}
}
},
async loadRecommendOptions() {
const resp = await this.$axios.post(this.apiBase + "/recommendOptions")
if (resp.code === 0) {
this.recommendOptions = resp.data
}
},
findFirstResource(nodes) {
for (const node of nodes || []) {
if (node.type === "resource") return node
const child = this.findFirstResource(node.children || [])
if (child) return child
}
return null
},
nodeClick(data) {
if (data.type !== "resource") return
if (this.studying && this.selectedResource.id !== data.id) {
this.$message.warning("请先结束当前章节资料的学习")
return
}
this.selectedResource = data
this.recordProgress = data.progressPercent || 0
this.recordStudyTime = this.formatSeconds(Number(data.studySeconds || 0))
},
getNodeIcon(data) {
if (data.type === "resource") {
const map = {
video: "el-icon-video-camera",
audio: "el-icon-headset",
image: "el-icon-picture-outline",
pdf: "el-icon-document",
word: "el-icon-document",
ppt: "el-icon-document"
}
return map[data.resourceType] || "el-icon-paperclip"
}
return data.nodeType === "chapter" ? "el-icon-folder" : "el-icon-notebook-2"
},
getFileUrl(value) {
if (!value) return ""
if (Array.isArray(value)) {
return value.length ? (value[0].url || value[0].response?.data || value[0].data || "") : ""
}
if (typeof value === "string" && value.trim().startsWith("[")) {
try {
const files = JSON.parse(value)
return files.length ? (files[0].url || files[0].response?.data || files[0].data || "") : ""
} catch (e) {
return ""
}
}
return value
},
getFileId(value) {
const url = this.getFileUrl(value)
if (!url) return ""
const matched = url.match(/[?&]id=([^&]+)/)
if (matched) return decodeURIComponent(matched[1])
if (!url.includes("/") && !url.includes(".")) return url
return ""
},
canInlinePreview(resource) {
const type = resource.resourceType
const ext = (resource.fileExt || "").toLowerCase()
return ["pdf", "ppt", "word"].includes(type) || ["pdf", "ppt", "pptx", "doc", "docx"].includes(ext)
},
progressKey() {
return "learning-progress-" + this.courseId + "-" + this.selectedResource.id
},
mediaReady() {
this.askResume()
},
askResume() {
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return
if (this.studying || this.startingStudy) return
const currentKey = this.progressKey()
if (this.autoResumeKey === currentKey) return
this.autoResumeKey = currentKey
const player = this.$refs.mediaPlayer
const localSaved = Number(localStorage.getItem(this.progressKey()) || 0)
const serverSaved = Number(this.selectedResource.lastPositionSeconds || 0)
const saved = Math.max(localSaved, serverSaved)
if (!player || !saved || saved < 5 || saved >= player.duration - 5) return
this.$confirm("检测到上次学习到 " + this.formatSeconds(saved) + ",请选择继续学习或从头开始。", "继续学习", {
confirmButtonText: "继续学习",
cancelButtonText: "从头开始",
type: "info"
}).then(() => {
this.startStudy({ autoPlay: true, seekTo: saved })
}).catch(() => {
localStorage.removeItem(this.progressKey())
this.saveServerPosition(0)
this.startStudy({ autoPlay: true, seekTo: 0 })
})
},
async mediaPlay() {
if (this.studying || this.startingStudy) return
await this.startStudy({ fromMedia: true })
},
mediaEnded() {
this.finishStudy(false, { silent: true })
localStorage.removeItem(this.progressKey())
},
async playSelectedMedia(seekTo) {
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return
await this.$nextTick()
const player = this.$refs.mediaPlayer
if (!player) return
try {
if (typeof seekTo === "number" && !Number.isNaN(seekTo)) {
await this.seekBeforePlay(player, seekTo)
}
const playPromise = player.play && player.play()
if (playPromise && playPromise.catch) {
await playPromise.catch(() => {
this.$message.warning("浏览器阻止了自动播放,请点击播放器播放按钮继续")
})
}
} catch (e) {
this.$message.warning("播放器定位失败,请手动点击播放后继续")
}
},
async seekBeforePlay(player, seekTo) {
await this.waitForMediaReady(player)
const target = this.normalizeSeekTarget(player, seekTo)
if (target === null) return
if (Math.abs((player.currentTime || 0) - target) <= 1) return
player.pause()
player.currentTime = target
await this.waitForSeek(player, target, 1800)
if (Math.abs((player.currentTime || 0) - target) > 1) {
player.currentTime = target
await this.waitForSeek(player, target, 1200)
}
},
waitForMediaReady(player) {
if (player.readyState >= 1 && !Number.isNaN(player.duration)) {
return Promise.resolve()
}
return new Promise(resolve => {
const done = () => {
player.removeEventListener("loadedmetadata", done)
player.removeEventListener("durationchange", done)
resolve()
}
player.addEventListener("loadedmetadata", done, { once: true })
player.addEventListener("durationchange", done, { once: true })
setTimeout(done, 2000)
})
},
waitForSeek(player, target, timeout) {
return new Promise(resolve => {
const done = () => {
player.removeEventListener("seeked", done)
player.removeEventListener("timeupdate", done)
resolve()
}
player.addEventListener("seeked", done, { once: true })
player.addEventListener("timeupdate", done, { once: true })
setTimeout(done, timeout)
})
},
normalizeSeekTarget(player, seekTo) {
const raw = Math.max(0, Number(seekTo || 0))
if (Number.isNaN(raw)) return null
if (!Number.isNaN(player.duration) && player.duration > 0) {
return Math.min(raw, Math.max(0, player.duration - 1))
}
return raw
},
saveProgress() {
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return
const player = this.$refs.mediaPlayer
if (player && player.currentTime > 0) {
const position = Math.floor(player.currentTime)
localStorage.setItem(this.progressKey(), String(position))
this.$set(this.selectedResource, "lastPositionSeconds", position)
if (Date.now() - this.lastPositionSyncAt > 5000) {
this.saveServerPosition(position)
}
}
},
saveServerPosition(position, useBeacon) {
if (!this.selectedResource.id || !["video", "audio"].includes(this.selectedResource.resourceType)) return
const seconds = Math.max(0, Math.floor(Number(position || 0)))
this.lastPositionSyncAt = Date.now()
if (useBeacon && navigator.sendBeacon) {
const body = new URLSearchParams()
body.append("courseId", this.courseId)
body.append("resourceId", this.selectedResource.id)
body.append("positionSeconds", String(seconds))
const blob = new Blob([body.toString()], { type: "application/x-www-form-urlencoded;charset=UTF-8" })
navigator.sendBeacon(this.recordApi + "/position", blob)
return
}
this.$axios.post(this.recordApi + "/position", {
courseId: this.courseId,
resourceId: this.selectedResource.id,
positionSeconds: seconds
})
},
async startStudy(options) {
const config = options && !options.target ? options : {}
if (!this.selectedResource.id) {
this.$message.warning("请选择左侧课程资料")
return
}
if (this.studying) {
if (config.autoPlay) {
this.playSelectedMedia(config.seekTo)
}
return
}
this.startingStudy = true
let resp
try {
resp = await this.$axios.post(this.recordApi + "/start", {
courseId: this.courseId,
resourceId: this.selectedResource.id,
positionSeconds: typeof config.seekTo === "number" ? Math.floor(config.seekTo) : this.getMediaPosition()
})
} finally {
this.startingStudy = false
}
if (resp.code !== 0) {
this.$message.warning(resp.msg)
return
}
this.currentSegmentId = resp.data.segmentId
this.studying = true
this.pendingSeconds = 0
this.lastActiveAt = Date.now()
this.applyRecordState(resp.data)
this.startHeartbeatTimer()
if (config.autoPlay !== false && !config.fromMedia) {
this.playSelectedMedia(config.seekTo)
}
this.$message.success("已开始学习")
},
async heartbeat() {
if (!this.studying || !this.currentSegmentId || this.pendingSeconds <= 0) return
const seconds = this.pendingSeconds
this.pendingSeconds = 0
const resp = await this.$axios.post(this.recordApi + "/heartbeat", {
segmentId: this.currentSegmentId,
activeSeconds: seconds,
positionSeconds: this.getMediaPosition()
})
if (resp.code === 0) {
this.applyRecordState(resp.data)
} else {
this.stopHeartbeatTimer()
this.studying = false
this.currentSegmentId = ""
this.$message.warning(resp.msg)
}
},
async finishStudy(force, options) {
const config = options || {}
if (!this.currentSegmentId) return
const segmentId = this.currentSegmentId
const seconds = this.pendingSeconds
this.pendingSeconds = 0
this.stopHeartbeatTimer()
this.studying = false
this.currentSegmentId = ""
if (!force) {
this.pauseSelectedMedia()
}
if (force && navigator.sendBeacon) {
this.saveServerPosition(this.getMediaPosition(), true)
const formData = new FormData()
formData.append("segmentId", segmentId)
formData.append("activeSeconds", String(seconds))
formData.append("positionSeconds", String(this.getMediaPosition()))
navigator.sendBeacon(this.recordApi + "/finish", formData)
return
}
const resp = await this.$axios.post(this.recordApi + "/finish", {
segmentId: segmentId,
activeSeconds: seconds,
positionSeconds: this.getMediaPosition()
})
if (resp.code === 0 && resp.data) {
this.applyRecordState(resp.data)
if (!config.silent) {
this.$message.success("学习已结束")
}
}
},
pauseSelectedMedia() {
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return
const player = this.$refs.mediaPlayer
if (player && !player.paused) {
player.pause()
}
},
startHeartbeatTimer() {
this.stopHeartbeatTimer()
this.heartbeatTimer = setInterval(() => {
if (!this.studying) return
if (this.isEffectiveLearning()) {
this.pendingSeconds += 1
}
if (this.pendingSeconds >= 15) {
this.heartbeat()
}
}, 1000)
},
stopHeartbeatTimer() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
},
isEffectiveLearning() {
if (document.hidden) return false
if (Date.now() - this.lastActiveAt > this.inactiveLimit) return false
if (["video", "audio"].includes(this.selectedResource.resourceType)) {
const player = this.$refs.mediaPlayer
return !!player && !player.paused && !player.ended
}
return true
},
markActive() {
this.lastActiveAt = Date.now()
},
applyRecordState(data) {
this.recordProgress = Number(data.progressPercent || 0)
this.recordStudyTime = data.studyTimeText || this.formatSeconds(Number(data.studySeconds || 0))
if (this.selectedResource.id && data.lastPositionSeconds !== undefined) {
this.$set(this.selectedResource, "lastPositionSeconds", Number(data.lastPositionSeconds || 0))
}
},
getMediaPosition() {
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return 0
const player = this.$refs.mediaPlayer
return player ? Math.floor(player.currentTime || 0) : Number(this.selectedResource.lastPositionSeconds || 0)
},
formatSeconds(seconds) {
const hour = Math.floor(seconds / 3600)
const minute = Math.floor(seconds % 3600 / 60)
const second = Math.floor(seconds % 60)
if (hour > 0) return hour + "小时" + minute + "分" + second + "秒"
if (minute > 0) return minute + "分" + second + "秒"
return second + "秒"
},
splitFlags(flags) {
return flags ? flags.split(",").filter(Boolean) : []
},
getRecommendName(code) {
const item = this.recommendOptions.find(v => v.code === code)
return item ? item.name : code
},
openFile() {
if (this.fileUrl) window.open(this.fileUrl)
},
collapseAll() {
const nodesMap = this.$refs.studyTree && this.$refs.studyTree.store.nodesMap
Object.keys(nodesMap || {}).forEach(key => {
nodesMap[key].expanded = false
})
},
async goBack() {
this.saveServerPosition(this.getMediaPosition())
await this.finishStudy(false, { silent: true })
window.location.href = this.apiBase
}
},
async created() {
this.courseId = this.getQuery("id")
window.addEventListener("mousemove", this.markActive)
window.addEventListener("keydown", this.markActive)
window.addEventListener("click", this.markActive)
window.addEventListener("scroll", this.markActive, true)
window.addEventListener("beforeunload", () => this.finishStudy(true))
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
this.saveServerPosition(this.getMediaPosition(), true)
}
})
await this.loadRecommendOptions()
await this.loadCourse()
await this.loadTree()
},
beforeDestroy() {
this.finishStudy(true)
this.stopHeartbeatTimer()
window.removeEventListener("mousemove", this.markActive)
window.removeEventListener("keydown", this.markActive)
window.removeEventListener("click", this.markActive)
window.removeEventListener("scroll", this.markActive, true)
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,266 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
[v-cloak] { display: none; }
#sidebar-menu, #menu-toggle-btn, #menu-overlay { display: none !important; }
#sub-app-container-main-content { height: calc(100vh - 64px); padding: 0 !important; overflow: hidden; background: #f6f8fb; }
#sub-app-container-main-content-body { height: 100%; overflow: hidden; }
.study-v2 { height: 100%; min-height: 0; display: flex; flex-direction: column; overflow: hidden; color: #17233d; background: #f6f8fb; }
.study-topbar { height: 56px; flex: none; background: #fff; border-bottom: 1px solid #e7eaf0; }
.study-topbar-inner { width: 1124px; height: 100%; margin: 0 auto; display: flex; align-items: center; }
.back-link { color: #65728a; cursor: pointer; font-size: 13px; }
.breadcrumb-course { margin-left: 16px; padding-left: 16px; border-left: 1px solid #dce1e8; font-size: 13px; }
.breadcrumb-course strong { color: #111d34; }
.top-progress { width: 166px; margin-left: auto; display: flex; align-items: center; gap: 9px; color: #56637a; font-size: 12px; }
.top-progress .el-progress { flex: 1; }
.top-progress-value { color: #1769ff; }
.study-layout { width: 1124px; min-height: 0; flex: 1; margin: 0 auto; padding: 18px 0; display: grid; grid-template-columns: 230px 875px; gap: 19px; overflow: hidden; box-sizing: border-box; }
.study-catalog { max-height: 100%; align-self: start; display: flex; flex-direction: column; overflow: hidden; background: #fff; border: 1px solid #e5e8ed; border-radius: 10px; box-sizing: border-box; }
.catalog-head { height: 52px; padding: 0 15px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #edf0f4; }
.catalog-title { font-size: 15px; font-weight: 700; }
.catalog-summary { color: #8793a7; font-size: 11px; }
.catalog-tree { max-height: calc(100vh - 190px); overflow-x: hidden; overflow-y: auto; padding: 0 0 8px; }
.catalog-node { width: 100%; min-width: 0; height: 32px; display: inline-flex; align-items: center; gap: 8px; padding: 0 14px; box-sizing: border-box; }
.catalog-node-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #344258; font-size: 13px; }
.catalog-outline-node .catalog-node-title { color: #202d43; font-weight: 400; }
.catalog-resource-node { padding-left: 15px; }
.catalog-resource-node .catalog-node-title { font-weight: 400; }
.catalog-node-time { flex: none; color: #8e9bb0; font-size: 11px; }
.catalog-status-complete { flex: none; color: #2f80ed; font-size: 15px; }
.catalog-status-radio { flex: none; margin-right: 0; line-height: 1; }
.catalog-status-radio .el-radio__inner { width: 15px; height: 15px; border-color: #cbd4e1; }
.catalog-status-radio .el-radio__input.is-checked .el-radio__inner { border-color: #2f80ed; background: #fff; }
.catalog-status-radio .el-radio__input.is-checked .el-radio__inner::after { width: 5px; height: 5px; background: #2f80ed; }
.catalog-status-radio .el-radio__label { display: none; }
.catalog-toggle { width: 22px; height: 22px; flex: none; padding: 0; display: inline-flex; align-items: center; justify-content: center; color: #96a3b6; border: 0; border-radius: 4px; background: transparent; cursor: pointer; }
.catalog-toggle:hover { color: #2f80ed; background: #eef5ff; }
.catalog-toggle i { font-size: 12px; }
.study-catalog .el-tree-node__content { height: 32px; padding-left: 0 !important; }
.study-catalog .el-tree-node__expand-icon { display: none; }
.study-catalog .el-tree-node__content:hover { background: #f1f6ff; }
.study-catalog .el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content { background: #eaf2ff; }
.study-catalog .el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content .catalog-node-title { color: #1769ff; }
.catalog-node-current { color: #1769ff; background: #eaf2ff; }
.catalog-node-current .catalog-node-title { color: #1769ff; }
/* 桌面端仅允许右侧课程学习内容滚动,顶部导航和左侧课程清单始终保持固定。 */
.study-main { min-width: 0; min-height: 0; height: 100%; overflow-x: hidden; overflow-y: auto; box-sizing: border-box; }
.study-main::-webkit-scrollbar { width: 6px; }
.study-main::-webkit-scrollbar-thumb { background: #c8d2df; border-radius: 6px; }
.study-main::-webkit-scrollbar-track { background: transparent; }
.player-card { height: 345px; display: flex; align-items: center; justify-content: center; overflow: hidden; background: #101010; }
.media-player { width: 100%; height: 345px; display: block; background: #000; object-fit: contain; }
.audio-panel { width: 100%; height: 345px; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #dfe9f5; background: #142c46; }
.audio-panel i { margin-bottom: 24px; font-size: 52px; }
.audio-panel audio { width: 80%; }
.image-preview { max-width: 100%; max-height: 345px; }
.doc-frame { width: 100%; height: 345px; border: 0; background: #fff; }
.file-fallback { width: 100%; padding: 40px; color: #65748a; text-align: center; background: #fff; box-sizing: border-box; }
.empty-resource { color: #9aa6b7; text-align: center; }
.empty-resource i { display: block; margin-bottom: 12px; font-size: 44px; }
.course-detail-card { margin-top: 14px; padding: 18px 20px 20px; background: #fff; border: 1px solid #e6e9ee; border-radius: 10px; box-sizing: border-box; }
.course-detail-head { display: flex; align-items: flex-start; justify-content: space-between; }
.detail-course-title { font-size: 18px; font-weight: 700; }
.detail-course-type { margin-left: 6px; color: #7f8ba0; font-size: 13px; font-weight: 400; }
.detail-learning-state { margin-top: 5px; color: #5f6d82; font-size: 12px; }
.detail-actions { display: flex; gap: 7px; }
.detail-actions .el-button { margin: 0; border-radius: 17px; }
.course-statistics { margin-top: 12px; padding-top: 12px; display: flex; gap: 16px; color: #8290a6; border-top: 1px solid #edf0f4; font-size: 12px; }
.course-description { margin-top: 12px; color: #344258; font-size: 13px; line-height: 22px; }
.study-bottom { margin-top: 11px; display: flex; align-items: center; justify-content: space-between; color: #8491a5; font-size: 12px; }
.study-bottom .el-button { border-radius: 18px; }
.page-loading { min-height: 0; flex: 1; display: flex; align-items: center; justify-content: center; color: #2878e5; font-size: 28px; }
@media (max-width: 1180px) { .study-topbar-inner, .study-layout { width: calc(100% - 32px); } .study-layout { grid-template-columns: 230px minmax(0, 1fr); } }
@media (max-width: 760px) { #sub-app-container-main-content, #sub-app-container-main-content-body { overflow-y: auto; } .study-v2 { height: auto; min-height: 100%; overflow: visible; } .study-layout { min-height: auto; flex: none; grid-template-columns: 1fr; overflow: visible; } .study-catalog { height: auto; order: 2; } .catalog-tree { max-height: 360px; overflow-y: auto; } .study-main { height: auto; padding-right: 0; overflow: visible; } .player-card, .media-player, .audio-panel, .doc-frame { height: 320px; min-height: 320px; } .breadcrumb-course { display: none; } }
</style>
<div id="app" class="study-v2" v-cloak>
<div v-if="pageLoading" class="page-loading"><i class="el-icon-loading"></i></div>
<template v-else>
<header class="study-topbar">
<div class="study-topbar-inner">
<span class="back-link" @click="goBack"><i class="el-icon-arrow-left"></i> 返回课程列表</span>
<div class="breadcrumb-course"><strong>{{course.courseName || '课程学习'}}</strong><span v-if="selectedResource.title"> / {{selectedResource.title}}</span></div>
<div class="top-progress"><span>学习进度</span><el-progress :percentage="courseProgress" :show-text="false" :stroke-width="5"></el-progress><span class="top-progress-value">{{courseProgress}}%</span></div>
</div>
</header>
<main class="study-layout">
<aside class="study-catalog">
<div class="catalog-head"><span class="catalog-title">课程清单</span><span class="catalog-summary">{{completedCount}}/{{resourceCount}} 已完成</span></div>
<div class="catalog-tree">
<el-tree ref="studyTree" :data="treeData" node-key="id" default-expand-all highlight-current :expand-on-click-node="false" :props="{children:'children', label:'title'}" @node-click="nodeClick">
<span slot-scope="{ node, data }" class="catalog-node" :class="{'catalog-outline-node': data.type === 'outline', 'catalog-resource-node': data.type === 'resource', 'catalog-node-current': data.type === 'resource' && data.id === selectedResource.id}">
<template v-if="data.type === 'resource'">
<i v-if="isResourceCompleted(data)" class="el-icon-success catalog-status-complete"></i>
<el-radio v-else class="catalog-status-radio" :value="selectedResource.id" :label="data.id" @click.native.stop @change="nodeClick(data)"><span></span></el-radio>
</template>
<span class="catalog-node-title" :title="data.title">{{data.title}}</span>
<span v-if="data.type === 'resource'" class="catalog-node-time">{{formatShortDuration(data.durationSeconds)}}</span>
<button v-else-if="node.childNodes && node.childNodes.length" type="button" class="catalog-toggle" :aria-label="node.expanded ? '收起' + data.title : '展开' + data.title" @click.stop="toggleCatalogNode(node)">
<i :class="node.expanded ? 'el-icon-arrow-up' : 'el-icon-arrow-down'"></i>
</button>
</span>
</el-tree>
</div>
</aside>
<section class="study-main">
<div class="player-card">
<template v-if="selectedResource.id">
<div v-if="!canPreview" class="empty-resource">该资料不允许预览</div>
<video v-else-if="selectedResource.resourceType === 'video'" ref="mediaPlayer" class="media-player" :src="mediaUrl" controls :controlslist="canDownload ? null : 'nodownload'" @play="mediaPlay" @ended="mediaEnded" @timeupdate="saveProgress" @loadedmetadata="mediaReady" @seeking="handleMediaSeeking" @seeked="handleMediaSeeked"></video>
<div v-else-if="selectedResource.resourceType === 'audio'" class="audio-panel"><i class="el-icon-headset"></i><div class="mb20">{{selectedResource.title}}</div><audio ref="mediaPlayer" :src="fileUrl" preload="metadata" controls :controlslist="canDownload ? null : 'nodownload'" @play="mediaPlay" @ended="mediaEnded" @timeupdate="saveProgress" @loadedmetadata="mediaReady" @seeking="handleMediaSeeking" @seeked="handleMediaSeeked"></audio></div>
<img v-else-if="selectedResource.resourceType === 'image'" class="image-preview" :src="fileUrl" :alt="selectedResource.title">
<iframe v-else-if="canInlinePreview(selectedResource)" class="doc-frame" :src="inlinePreviewUrl"></iframe>
<div v-else class="file-fallback">该资料暂不支持在线预览</div>
</template>
<div v-else class="empty-resource"><i class="el-icon-reading"></i><div>请从左侧目录选择需要学习的资料</div></div>
</div>
<div class="course-detail-card">
<div class="course-detail-head">
<div><div><span class="detail-course-title">{{course.courseName}}</span><span class="detail-course-type">{{course.courseTypeName || '课程'}}</span></div><div class="detail-learning-state">正在学习:{{selectedResource.title || '请选择课节'}}</div></div>
<div class="detail-actions">
<el-button v-if="canPreview && (isMediaResource || selectedResource.resourceType === 'image' || canInlinePreview(selectedResource))" size="mini" icon="el-icon-view" @click="openFile">预览资料</el-button>
<el-button v-if="canDownload" size="mini" icon="el-icon-download" @click="downloadFile">下载资料</el-button>
</div>
</div>
<div class="course-statistics"><span><i class="el-icon-collection"></i> 共 {{resourceCount}} 课节</span><span><i class="el-icon-time"></i> 总时长 {{formatCourseDuration(course.totalDurationSeconds)}}</span><span><i class="el-icon-view"></i> {{course.viewCount || 0}} 次观看</span></div>
<div class="course-description">{{course.courseIntro || '暂无课程介绍'}}</div>
</div>
<div class="study-bottom">
<span>{{studying ? '正在记录有效学习时长:' + recordStudyTime : '播放课程后,系统将自动记录有效学习时长。'}}</span>
<div>
<el-button v-if="!studying && canPreview && !isMediaResource" size="small" type="primary" plain :loading="startingStudy" @click="startStudy">开始学习</el-button>
<el-button v-if="studying" size="small" type="danger" plain @click="finishStudy(false)">结束学习</el-button>
<el-button v-if="hasNextResource" size="small" type="primary" @click="nextResource">下一节 <i class="el-icon-arrow-right"></i></el-button>
</div>
</div>
</section>
</main>
</template>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return { courseId: "", preferredResourceId: "", apiBase: "/platform/learning/course/display", recordApi: "/platform/learning/study/record", course: {}, treeData: [], selectedResource: {}, studying: false, startingStudy: false, currentSegmentId: "", recordProgress: 0, recordStudyTime: "0秒", pendingSeconds: 0, heartbeatTimer: null, autoResumeKey: "", lastPositionSyncAt: 0, lastActiveAt: Date.now(), inactiveLimit: 60000, lastAllowedPositionSeconds: 0, restoringMediaPosition: false, pageLoading: true, loadingCount: 0 }
},
computed: {
canPreview() { return !!this.selectedResource.id && this.selectedResource.allowPreview !== false },
canDownload() { return !!this.selectedResource.id && this.selectedResource.allowDownload === true },
fileUrl() {
if (!this.canPreview) return ""
const id = this.getFileId(this.selectedResource.fileData)
return id ? "/platform/sys/file/preview?id=" + encodeURIComponent(id) : this.getFileUrl(this.selectedResource.fileData)
},
downloadUrl() {
if (!this.canDownload) return ""
const id = this.getFileId(this.selectedResource.fileData)
return id ? "/platform/sys/file/download?id=" + encodeURIComponent(id) : this.getFileUrl(this.selectedResource.fileData)
},
mediaUrl() {
if (!this.canPreview) return ""
if (this.selectedResource.resourceType !== "video") return this.fileUrl
const fileId = this.getFileId(this.selectedResource.fileData)
// 视频续播和手动拖动依赖 Range 请求,普通文件地址会导致 currentTime 定位被浏览器重置。
return fileId ? "/platform/sys/file/videoPlay?id=" + encodeURIComponent(fileId) : this.fileUrl
},
inlinePreviewUrl() { if (!this.canPreview) return ""; const id = this.getFileId(this.selectedResource.fileData); const url = id ? this.apiBase + "/pdfPreview?id=" + encodeURIComponent(id) : this.fileUrl; return this.canDownload ? url : url + "#toolbar=0" },
resourceTypeText() { const names = {video: "视频资料", audio: "音频资料", image: "图片资料", pdf: "文档资料", word: "文档资料", ppt: "演示资料"}; return names[this.selectedResource.resourceType] || "课程资料" },
resourceList() { return this.collectResources(this.treeData) },
learnableResources() { return this.resourceList.filter(item => item.allowPreview !== false) },
resourceCount() { return this.learnableResources.length },
completedCount() { return this.learnableResources.filter((item) => item.completeStatus === "completed" || Number(item.progressPercent || 0) >= 100).length },
courseProgress() { return Number(this.course.progressPercent || 0) },
currentResourceIndex() { return this.resourceList.findIndex((item) => item.id === this.selectedResource.id) },
hasNextResource() { return this.currentResourceIndex >= 0 && this.currentResourceIndex < this.resourceList.length - 1 },
isMediaResource() { return ["video", "audio"].includes(this.selectedResource.resourceType) }
},
methods: {
beginLoading() { this.loadingCount += 1; this.pageLoading = true },
endLoading() { this.loadingCount -= 1; if (this.loadingCount <= 0) { this.loadingCount = 0; this.pageLoading = false } },
getQuery(name) { return new URLSearchParams(window.location.search).get(name) || "" },
// 请求参数 id 为课程主键;接口返回课程标题等基础字段,作为学习页顶部信息。
loadCourse() { this.beginLoading(); this.$axios.post(this.apiBase + "/courseInfo", {id: this.courseId}).then((res) => { if (res.code === 0) { this.course = res.data || {} } else { this.$message.warning(res.msg) } }).finally(() => { this.endLoading() }) },
// 请求参数 courseId 为课程主键;优先选中 URL 中 resourceId 指定的资料,否则选中首个可学习资料。
loadTree() { this.beginLoading(); this.$axios.post(this.apiBase + "/studyTree", {courseId: this.courseId}).then((res) => { if (res.code !== 0) { this.$message.warning(res.msg); return } this.treeData = res.data || []; const resources = this.collectResources(this.treeData); const current = resources.find(item => item.id === this.preferredResourceId) || resources[0]; if (current) { this.selectResource(current); this.$nextTick(() => { if (this.$refs.studyTree) this.$refs.studyTree.setCurrentKey(current.id) }) } }).finally(() => { this.endLoading() }) },
collectResources(nodes) { let resources = []; (nodes || []).forEach((node) => { if (node.type === "resource") resources.push(node); if (node.children && node.children.length) resources = resources.concat(this.collectResources(node.children)) }); return resources },
nodeClick(data) { if (data.type !== "resource") return; if (this.studying && this.selectedResource.id !== data.id) { this.$message.warning("请先结束当前资料的学习"); return } this.selectResource(data) },
selectResource(resource) { this.selectedResource = resource; this.recordProgress = Number(resource.progressPercent || 0); this.recordStudyTime = this.formatSeconds(Number(resource.studySeconds || 0)); this.autoResumeKey = ""; this.lastAllowedPositionSeconds = Number(resource.maxPositionSeconds || 0); this.restoringMediaPosition = false },
// 参数 data 为课节资源;接口完成状态或进度达到 100% 时,清单显示蓝色完成图标。
isResourceCompleted(data) { return data.completeStatus === "completed" || Number(data.progressPercent || 0) >= 100 },
// 参数 node 为 Element Tree 章节节点;点击右侧按钮只切换章节展开状态,不触发课节选择。
toggleCatalogNode(node) { if (!node || node.isLeaf) return; this.$set(node, "expanded", !node.expanded) },
getFileUrl(value) { if (!value) return ""; if (Array.isArray(value)) return value.length ? (value[0].url || value[0].response?.data || value[0].data || "") : ""; if (typeof value === "string" && value.trim().startsWith("[")) { try { const files = JSON.parse(value); return files.length ? (files[0].url || files[0].response?.data || files[0].data || "") : "" } catch (e) { return "" } } return value },
getFileId(value) { const url = this.getFileUrl(value); if (!url) return ""; const matched = url.match(/[?&]id=([^&]+)/); if (matched) return decodeURIComponent(matched[1]); return !url.includes("/") && !url.includes(".") ? url : "" },
canInlinePreview(resource) { const ext = (resource.fileExt || "").toLowerCase(); return ["pdf", "ppt", "word"].includes(resource.resourceType) || ["pdf", "ppt", "pptx", "doc", "docx"].includes(ext) },
progressKey() { return "learning-progress-" + this.courseId + "-" + this.selectedResource.id },
mediaReady() { this.askResume() },
askResume() {
if (!["video", "audio"].includes(this.selectedResource.resourceType) || this.studying || this.startingStudy) return
const currentKey = this.progressKey()
if (this.autoResumeKey === currentKey) return
this.autoResumeKey = currentKey
const player = this.$refs.mediaPlayer
// 仅使用当前登录人的服务端学习记录,加载视频后直接定位但不自动播放。
const saved = Math.max(0, Number(this.selectedResource.lastPositionSeconds || 0))
if (!player || !saved || saved < 5 || saved >= player.duration - 5) return
this.restoreMediaPosition(player, saved, 0)
},
mediaPlay() { if (!this.studying && !this.startingStudy) this.startStudy({fromMedia: true}) },
mediaEnded() { this.finishStudy(false, {silent: true, completed: true}); localStorage.removeItem(this.progressKey()) },
playSelectedMedia(seekTo) { if (!["video", "audio"].includes(this.selectedResource.resourceType)) return; this.$nextTick(() => { const player = this.$refs.mediaPlayer; if (!player) return; const play = () => { if (typeof seekTo === "number" && !Number.isNaN(seekTo)) this.restoreMediaPosition(player, seekTo, 0); const playPromise = player.play && player.play(); if (playPromise && playPromise.catch) playPromise.catch(() => { this.$message.warning("浏览器阻止了自动播放,请点击播放器继续") }) }; if (player.readyState >= 1) { play() } else { player.addEventListener("loadedmetadata", play, {once: true}) } }) },
// player 为当前视频或音频播放器,positionSeconds 为服务端保存的续播秒数;定位失败时仅重试一次,避免播放器无法拖动时循环请求。
restoreMediaPosition(player, positionSeconds, retryCount) {
if (!player || positionSeconds <= 0) return
let targetPosition = Math.max(0, Math.floor(positionSeconds))
if (Number.isFinite(player.duration) && player.duration > 0) {
targetPosition = Math.min(targetPosition, Math.max(0, Math.floor(player.duration) - 2))
}
if (targetPosition <= 0) return
this.restoringMediaPosition = true
player.addEventListener("seeked", () => {
this.lastAllowedPositionSeconds = targetPosition
this.restoringMediaPosition = false
if (Math.abs(Number(player.currentTime || 0) - targetPosition) <= 1 || retryCount >= 1) return
setTimeout(() => this.restoreMediaPosition(player, targetPosition, retryCount + 1), 100)
}, {once: true})
player.currentTime = targetPosition
},
// 原生播放器点击进度条会在 seeking 后再次写入时间,因此开始定位和完成定位均需限制向前跳转;回退复习不受影响。
restoreBlockedMediaPosition() { const player = this.$refs.mediaPlayer; if (!player || this.selectedResource.allowDrag !== false || this.restoringMediaPosition) return false; const allowedPosition = Math.max(0, Number(this.lastAllowedPositionSeconds || 0)); if (Number(player.currentTime || 0) <= allowedPosition + 0.25) return false; player.currentTime = allowedPosition; setTimeout(() => { if (!this.restoringMediaPosition && this.selectedResource.allowDrag === false && Number(player.currentTime || 0) > allowedPosition + 0.25) player.currentTime = allowedPosition }, 0); return true },
handleMediaSeeking() { this.restoreBlockedMediaPosition() },
handleMediaSeeked() { this.restoreBlockedMediaPosition() },
saveProgress() { if (!["video", "audio"].includes(this.selectedResource.resourceType)) return; const player = this.$refs.mediaPlayer; if (!player || player.currentTime <= 0) return; const position = Math.floor(player.currentTime); if (this.selectedResource.allowDrag === false && !this.restoringMediaPosition && position > Number(this.lastAllowedPositionSeconds || 0) + 2) { this.restoreBlockedMediaPosition(); return } this.lastAllowedPositionSeconds = Math.max(Number(this.lastAllowedPositionSeconds || 0), position); localStorage.setItem(this.progressKey(), String(position)); this.$set(this.selectedResource, "lastPositionSeconds", position); if (Date.now() - this.lastPositionSyncAt > 5000) this.saveServerPosition(position) },
// position 为当前播放秒数;页面隐藏或退出时优先使用 Beacon,避免请求被浏览器中断。
saveServerPosition(position, useBeacon) { if (!this.selectedResource.id || !["video", "audio"].includes(this.selectedResource.resourceType)) return; const seconds = Math.max(0, Math.floor(Number(position || 0))); this.lastPositionSyncAt = Date.now(); if (useBeacon && navigator.sendBeacon) { const body = new URLSearchParams(); body.append("courseId", this.courseId); body.append("resourceId", this.selectedResource.id); body.append("positionSeconds", String(seconds)); navigator.sendBeacon(this.recordApi + "/position", new Blob([body.toString()], {type: "application/x-www-form-urlencoded;charset=UTF-8"})); return } this.$axios.post(this.recordApi + "/position", {courseId: this.courseId, resourceId: this.selectedResource.id, positionSeconds: seconds}).then(() => {}).finally(() => {}) },
// options 支持 autoPlay、seekTo 与 fromMedia;返回的 segmentId 用于后续心跳和结束学习请求。
startStudy(options) { const config = options && !options.target ? options : {}; if (!this.selectedResource.id) { this.$message.warning("请选择课程资料"); return } if (this.studying) { if (config.autoPlay) this.playSelectedMedia(config.seekTo); return } this.startingStudy = true; this.$axios.post(this.recordApi + "/start", {courseId: this.courseId, resourceId: this.selectedResource.id, positionSeconds: typeof config.seekTo === "number" ? Math.floor(config.seekTo) : this.getMediaPosition()}).then((res) => { if (res.code !== 0) { this.$message.warning(res.msg); return } this.currentSegmentId = res.data.segmentId; this.studying = true; this.pendingSeconds = 0; this.lastActiveAt = Date.now(); this.$set(this.course, "viewCount", Number(res.data.viewCount || 0)); this.applyRecordState(res.data); this.startHeartbeatTimer(); if (config.autoPlay !== false && !config.fromMedia) this.playSelectedMedia(config.seekTo); this.$message.success("已开始学习") }).finally(() => { this.startingStudy = false }) },
heartbeat() { if (!this.studying || !this.currentSegmentId || this.pendingSeconds <= 0) return; const seconds = this.pendingSeconds; this.pendingSeconds = 0; this.$axios.post(this.recordApi + "/heartbeat", {segmentId: this.currentSegmentId, activeSeconds: seconds, positionSeconds: this.getMediaPosition()}).then((res) => { if (res.code === 0) { this.applyRecordState(res.data); return } this.stopHeartbeatTimer(); this.studying = false; this.currentSegmentId = ""; this.$message.warning(res.msg) }).finally(() => {}) },
finishStudy(force, options) { const config = options || {}; if (!this.currentSegmentId) return Promise.resolve(); const segmentId = this.currentSegmentId; const seconds = this.pendingSeconds; this.pendingSeconds = 0; this.stopHeartbeatTimer(); this.studying = false; this.currentSegmentId = ""; if (!force) this.pauseSelectedMedia(); if (force && navigator.sendBeacon) { this.saveServerPosition(this.getMediaPosition(), true); const formData = new FormData(); formData.append("segmentId", segmentId); formData.append("activeSeconds", String(seconds)); formData.append("positionSeconds", String(this.getMediaPosition())); navigator.sendBeacon(this.recordApi + "/finish", formData); return Promise.resolve() } return this.$axios.post(this.recordApi + "/finish", {segmentId: segmentId, activeSeconds: seconds, positionSeconds: this.getMediaPosition(), completed: !!config.completed}).then((res) => { if (res.code === 0 && res.data) { this.applyRecordState(res.data); if (!config.silent) this.$message.success("学习已结束") } }).finally(() => {}) },
pauseSelectedMedia() { if (!["video", "audio"].includes(this.selectedResource.resourceType)) return; const player = this.$refs.mediaPlayer; if (player && !player.paused) player.pause() },
startHeartbeatTimer() { this.stopHeartbeatTimer(); this.heartbeatTimer = setInterval(() => { if (!this.studying) return; if (this.isEffectiveLearning()) this.pendingSeconds += 1; if (this.pendingSeconds >= 15) this.heartbeat() }, 1000) },
stopHeartbeatTimer() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null } },
isEffectiveLearning() { if (document.hidden) return false; if (["video", "audio"].includes(this.selectedResource.resourceType)) { const player = this.$refs.mediaPlayer; return !!player && !player.paused && !player.ended } return true },
markActive() { this.lastActiveAt = Date.now() },
applyRecordState(data) { if (data && data.courseProgress) Object.keys(data.courseProgress).forEach(key => this.$set(this.course, key, data.courseProgress[key])); if (!data || data.resourceId !== this.selectedResource.id) return; this.recordProgress = Number(data.progressPercent || 0); this.recordStudyTime = data.studyTimeText || this.formatSeconds(Number(data.studySeconds || 0)); if (this.selectedResource.id && data.lastPositionSeconds !== undefined) this.$set(this.selectedResource, "lastPositionSeconds", Number(data.lastPositionSeconds || 0)); if (this.selectedResource.id && data.maxPositionSeconds !== undefined) { const maxPositionSeconds = Number(data.maxPositionSeconds || 0); this.$set(this.selectedResource, "maxPositionSeconds", maxPositionSeconds); this.lastAllowedPositionSeconds = Math.max(Number(this.lastAllowedPositionSeconds || 0), maxPositionSeconds) } const resource = this.resourceList.find(item => item.id === this.selectedResource.id); if (resource) { this.$set(resource, "progressPercent", this.recordProgress); this.$set(resource, "studySeconds", Number(data.studySeconds || 0)); this.$set(resource, "completeStatus", data.completeStatus || "studying"); if (data.maxPositionSeconds !== undefined) this.$set(resource, "maxPositionSeconds", Number(data.maxPositionSeconds || 0)) } },
getMediaPosition() { if (!["video", "audio"].includes(this.selectedResource.resourceType)) return 0; const player = this.$refs.mediaPlayer; return player ? Math.floor(player.currentTime || 0) : Number(this.selectedResource.lastPositionSeconds || 0) },
formatSeconds(seconds) { const hour = Math.floor(seconds / 3600); const minute = Math.floor(seconds % 3600 / 60); const second = Math.floor(seconds % 60); if (hour > 0) return hour + "小时" + minute + "分" + second + "秒"; if (minute > 0) return minute + "分" + second + "秒"; return second + "秒" },
formatShortDuration(seconds) { const total = Number(seconds) || 0; if (!total) return ""; const minute = Math.floor(total / 60); const second = total % 60; return String(minute).padStart(2, "0") + ":" + String(second).padStart(2, "0") },
formatCourseDuration(seconds) { const total = Number(seconds) || 0; if (total < 3600) return Math.max(1, Math.ceil(total / 60)) + "分钟"; const hour = Math.floor(total / 3600); const minute = Math.ceil(total % 3600 / 60); return minute ? hour + "小时" + minute + "分钟" : hour + "小时" },
nextResource() { if (!this.hasNextResource) return; const next = this.resourceList[this.currentResourceIndex + 1]; const changeResource = () => { this.selectResource(next); this.$nextTick(() => { if (this.$refs.studyTree) this.$refs.studyTree.setCurrentKey(next.id) }) }; if (this.studying) { this.finishStudy(false, {silent: true}).then(changeResource) } else { changeResource() } },
openFile() { if (!this.canPreview) return; const url = this.canInlinePreview(this.selectedResource) ? this.inlinePreviewUrl : this.mediaUrl; if (url) window.open(url) },
downloadFile() { if (this.canDownload && this.downloadUrl) window.open(this.downloadUrl) },
collapseAll() { const nodesMap = this.$refs.studyTree && this.$refs.studyTree.store.nodesMap; Object.keys(nodesMap || {}).forEach(key => { nodesMap[key].expanded = false }) },
goBack() { this.saveServerPosition(this.getMediaPosition()); this.finishStudy(false, {silent: true}).then(() => { window.location.href = this.apiBase }) }
},
created() { this.courseId = this.getQuery("id"); this.preferredResourceId = this.getQuery("resourceId"); if (!this.courseId) { this.pageLoading = false; this.$message.warning("请选择课程"); return } window.addEventListener("mousemove", this.markActive); window.addEventListener("keydown", this.markActive); window.addEventListener("click", this.markActive); window.addEventListener("scroll", this.markActive, true); window.addEventListener("beforeunload", () => { this.finishStudy(true) }); document.addEventListener("visibilitychange", () => { if (document.hidden) this.saveServerPosition(this.getMediaPosition(), true) }); this.loadCourse(); this.loadTree() },
beforeDestroy() { this.finishStudy(true); this.stopHeartbeatTimer(); window.removeEventListener("mousemove", this.markActive); window.removeEventListener("keydown", this.markActive); window.removeEventListener("click", this.markActive); window.removeEventListener("scroll", this.markActive, true) }
})
</script>
<!--#
}
#-->
@@ -0,0 +1,412 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<guava ref="guava">
<el-card shadow="never">
<search :is-search-button="false">
<search-item label="课程名称">
<el-input v-model="pageForm.courseName" clearable placeholder="请输入课程名称" style="width: 100%" @keyup.enter.native="doSearch"></el-input>
</search-item>
<search-item label="课程类型">
<el-select v-model="pageForm.courseTypeId" clearable filterable placeholder="请选择课程类型" style="width: 100%">
<el-option v-for="item in courseTypeOptions" :key="item.id" :label="item.typeName" :value="item.id"></el-option>
</el-select>
</search-item>
<search-item label="开课时间">
<el-date-picker
v-model="pageForm.courseTime"
type="daterange"
start-placeholder="开始时间"
end-placeholder="结束时间"
range-separator="至"
value-format="yyyy-MM-dd HH:mm:ss"
style="width: 100%">
</el-date-picker>
</search-item>
<search-item label="授课讲师">
<el-input v-model="pageForm.lecturerName" clearable placeholder="请输入授课讲师" style="width: 100%" @keyup.enter.native="doSearch"></el-input>
</search-item>
<search-item label="课程状态">
<el-select v-model="pageForm.status" clearable placeholder="请选择课程状态" style="width: 100%">
<el-option v-for="item in statusOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</search-item>
<search-item label="推荐标识">
<el-select v-model="pageForm.recommendFlag" clearable placeholder="请选择推荐标识" style="width: 100%">
<el-option v-for="item in recommendOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</search-item>
<div class="search-query">
<el-button size="medium" type="primary" @click="doSearch">搜索</el-button>
<el-button size="medium" @click="resetSearch">重置</el-button>
</div>
</search>
</el-card>
<el-card class="mt10" shadow="never">
<table-tool :app="this" label="课程列表">
<el-button @click="openAdd" size="medium" type="primary">
<i class="el-icon-plus"></i>
新增课程
</el-button>
</table-tool>
<el-table
v-loading="tableLoading"
:data="tableData"
:size="tableSize"
border
@sort-change="pageOrder">
<el-table-column :index="indexMethod" align="center" header-align="center" label="序号" type="index" width="80"></el-table-column>
<el-table-column align="center" header-align="center" label="课程名称" prop="courseName" sortable="custom" show-overflow-tooltip min-width="180"></el-table-column>
<el-table-column align="center" header-align="center" label="课程类型" prop="courseTypeName" sortable="custom" show-overflow-tooltip width="140"></el-table-column>
<el-table-column align="center" header-align="center" label="授课讲师" prop="lecturerName" sortable="custom" show-overflow-tooltip width="140"></el-table-column>
<el-table-column align="center" header-align="center" label="开课时间" prop="startTime" sortable="custom" width="230">
<template slot-scope="{row}">
<span v-if="row.openType === 'long_term'">长期开放</span>
<span v-else>{{formatTime(row.startTime)}} 至 {{formatTime(row.endTime)}}</span>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="课程状态" prop="status" sortable="custom" width="120">
<template slot-scope="{row}">
<el-tag :type="getStatusType(row.status)" size="mini">{{getStatusName(row.status)}}</el-tag>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="推荐标识" prop="recommendFlags" sortable="custom" show-overflow-tooltip min-width="140">
<template slot-scope="{row}">
<el-tag
v-for="code in splitValue(row.recommendFlags)"
:key="code"
:type="getRecommendType(code)"
size="mini"
style="margin: 2px 3px">
{{getDictName(code, recommendOptions)}}
</el-tag>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="观看次数" prop="viewCount" sortable="custom" width="120"></el-table-column>
<el-table-column align="center" header-align="center" label="排序编码" prop="sortNum" sortable="custom" width="120"></el-table-column>
<el-table-column align="center" header-align="center" label="操作" width="260">
<template slot-scope="{row}">
<el-button @click="openEdit(row)" size="mini" type="primary">编辑</el-button>
<el-button @click="goChapterContent(row)" size="mini" type="success">章节内容</el-button>
<el-button @click="doDelete(row)" size="mini" type="danger">删除</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</guava>
<el-dialog :title="title" :visible.sync="dialogVisible" :close-on-click-modal="false" width="72%">
<el-form :model="formData" ref="courseForm" :rules="formRules" label-width="110px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="课程名称" prop="courseName">
<el-input v-model="formData.courseName" maxlength="100" placeholder="请输入课程名称"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="课程类型" prop="courseTypeId">
<el-select v-model="formData.courseTypeId" filterable placeholder="请选择课程类型" style="width: 100%">
<el-option v-for="item in courseTypeOptions" :key="item.id" :label="item.typeName" :value="item.id"></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="授课讲师" prop="lecturerName">
<el-input v-model="formData.lecturerName" maxlength="100" placeholder="请输入教师姓名"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="课程状态" prop="status">
<el-select v-model="formData.status" placeholder="请选择课程状态" style="width: 100%">
<el-option v-for="item in statusOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="开课方式" prop="openType">
<el-radio-group v-model="formData.openType">
<el-radio label="fixed">固定开课时间</el-radio>
<el-radio label="long_term">长期开放</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="开课时间" prop="openTime" v-if="formData.openType !== 'long_term'">
<el-date-picker
v-model="formData.openTime"
type="datetimerange"
start-placeholder="开始时间"
end-placeholder="结束时间"
range-separator="至"
value-format="yyyy-MM-dd HH:mm:ss"
style="width: 100%">
</el-date-picker>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="推荐标识" prop="recommendFlagList">
<el-select v-model="formData.recommendFlagList" multiple clearable placeholder="请选择推荐标识" style="width: 100%">
<el-option v-for="item in recommendOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="排序编码" prop="sortNum">
<el-input-number v-model="formData.sortNum" :controls="false" :min="0" :precision="0" style="width: 100%" placeholder="请输入排序编码"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="观看次数" prop="viewCount">
<el-input-number v-model="formData.viewCount" :controls="false" :min="0" :precision="0" style="width: 100%" placeholder="请输入观看次数"></el-input-number>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="课程介绍" prop="courseIntro">
<el-input v-model="formData.courseIntro" type="textarea" maxlength="2000" :rows="4" show-word-limit placeholder="请输入课程简介"></el-input>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="适合人群" prop="suitablePeople">
<el-input v-model="formData.suitablePeople" type="textarea" maxlength="500" :rows="3" show-word-limit placeholder="请输入适合人群"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="学习目标" prop="learningGoal">
<el-input v-model="formData.learningGoal" type="textarea" maxlength="500" :rows="3" show-word-limit placeholder="请输入学习目标"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="课程封面" prop="cover">
<file-upload
:upload_number="1"
:upload_size="5242880"
:value.sync="formData.cover"
accept=".jpg,.jpeg,.png"
complete_result
upload_mode="image"
upload_result_category="interval">
</file-upload>
<div class="el-upload__tip">只能上传1个文件;只能上传.jpg,.jpeg,.png文件;单个文件大小不能超过5M。</div>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :disabled="subDis" @click="operation">确定</el-button>
</span>
</el-dialog>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
mixins: [initTableMixins],
data() {
return {
title: "",
dialogVisible: false,
subDis: false,
courseTypeOptions: [],
recommendOptions: [],
statusOptions: [
{ name: "草稿", code: "draft" },
{ name: "待发布", code: "pending" },
{ name: "已发布", code: "published" },
{ name: "已下架", code: "offline" }
],
formData: {},
formRules: {
courseName: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
courseTypeId: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
lecturerName: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
status: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
sortNum: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
viewCount: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
cover: [{ required: true, message: "请上传课程封面", trigger: ["blur", "change"] }]
}
}
},
methods: {
buildPageParams() {
const params = Object.assign({}, this.pageForm)
params.startTime = params.courseTime && params.courseTime.length ? params.courseTime[0] : ""
params.endTime = params.courseTime && params.courseTime.length ? params.courseTime[1] : ""
return params
},
pageData(data = null) {
const address = this.pageDataUrl ? this.pageDataUrl : loc() + "/pageData"
this.tableLoading = true
this.$axios.post(address, data ? data : this.buildPageParams()).then((res) => {
if (res.code === 0) {
this.tableData = res.data.list
this.pageForm.totalCount = res.data.totalCount
}
}).finally(() => {
this.tableLoading = false
})
},
resetSearch() {
this.pageForm.courseName = ""
this.pageForm.courseTypeId = ""
this.pageForm.courseTime = []
this.pageForm.lecturerName = ""
this.pageForm.status = ""
this.pageForm.recommendFlag = ""
this.doSearch()
},
openAdd() {
this.title = "新增课程"
this.formData = {
courseName: "",
courseTypeId: "",
lecturerName: "",
courseIntro: "",
suitablePeople: "",
learningGoal: "",
openType: "fixed",
openTime: [],
status: "draft",
recommendFlagList: [],
sortNum: 0,
viewCount: 0,
cover: ""
}
this.dialogVisible = true
this.$nextTick(() => {
this.$refs.courseForm && this.$refs.courseForm.clearValidate()
})
},
openEdit(row) {
this.title = "编辑课程"
this.formData = Object.assign({}, row, {
openTime: row.startTime && row.endTime ? [this.formatTime(row.startTime), this.formatTime(row.endTime)] : [],
recommendFlagList: this.splitValue(row.recommendFlags),
viewCount: row.viewCount == null ? 0 : row.viewCount
})
this.dialogVisible = true
this.$nextTick(() => {
this.$refs.courseForm && this.$refs.courseForm.clearValidate()
})
},
goChapterContent(row) {
const url = "/platform/learning/chapter/content?courseId=" + encodeURIComponent(row.id || "")
if (window.commonUtil && commonUtil.pjaxPush) {
commonUtil.pjaxPush(url)
} else {
window.location.href = url
}
},
operation() {
this.$refs.courseForm.validate((valid) => {
if (!valid) return
if (this.formData.openType !== "long_term" && (!this.formData.openTime || this.formData.openTime.length !== 2)) {
this.$message.warning("请选择开课时间")
return
}
const data = Object.assign({}, this.formData)
data.startTime = data.openType === "long_term" ? "" : data.openTime[0]
data.endTime = data.openType === "long_term" ? "" : data.openTime[1]
data.recommendFlags = (data.recommendFlagList || []).join(",")
delete data.openTime
delete data.recommendFlagList
const method = data.id ? "/doEdit" : "/doAdd"
this.subDis = true
this.$axios.post(loc() + method, data).then((resp) => {
if (resp.code === 0) {
this.dialogVisible = false
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.warning(resp.msg)
}
}).finally(() => {
this.subDis = false
})
})
},
doDelete(row) {
this.$confirm("确定要删除该课程吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(() => {
this.$axios.post(loc() + "/doDelete", { id: row.id }).then((resp) => {
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.error(resp.msg)
}
})
})
},
getCourseTypes() {
return this.$axios.post(loc() + "/courseTypes").then((resp) => {
if (resp.code === 0) {
this.courseTypeOptions = resp.data
}
})
},
getLearningDict(name) {
return this.$axios.post(loc() + "/learningDictOptions", { name }).then((resp) => {
return resp.code === 0 ? resp.data : []
})
},
splitValue(value) {
return value ? value.split(",").filter(Boolean) : []
},
getStatusName(code) {
const item = this.statusOptions.find(v => v.code === code)
return item ? item.name : ""
},
getStatusType(code) {
const map = {
draft: "info",
pending: "warning",
published: "success",
offline: "danger"
}
return map[code] || ""
},
getDictName(code, options) {
const item = options.find(v => v.code === code)
return item ? item.name : code
},
getDictNames(value, options) {
const values = this.splitValue(value)
return values.map(code => {
const item = options.find(v => v.code === code)
return item ? item.name : code
}).join("、")
},
getRecommendType(code) {
const name = this.getDictName(code, this.recommendOptions)
if (name.includes("热门") || name.includes("推荐")) return "warning"
if (name.includes("新")) return "success"
return ""
},
formatTime(time) {
return time ? this.$moment(time).format("YYYY-MM-DD HH:mm:ss") : ""
}
},
created() {
this.pageForm.courseTime = []
Promise.all([this.getCourseTypes(), this.getLearningDict("推荐标识")]).then((values) => {
this.recommendOptions = values[1]
this.pageData()
})
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,189 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<guava ref="guava">
<el-card shadow="never">
<search :is-search-button="false">
<search-item label="课程类型名称">
<el-input
v-model="pageForm.typeName"
clearable
placeholder="请输入课程类型名称"
style="width: 100%"
@keyup.enter.native="doSearch">
</el-input>
</search-item>
<div class="search-query">
<el-button size="medium" type="primary" @click="doSearch">搜索</el-button>
<el-button size="medium" @click="resetSearch">重置</el-button>
</div>
</search>
</el-card>
<el-card class="mt10" shadow="never">
<table-tool :app="this" label="课程类型列表">
<el-button @click="openAdd" size="medium" type="primary">
<i class="el-icon-plus"></i>
新建
</el-button>
</table-tool>
<el-table
v-loading="tableLoading"
:data="tableData"
:size="tableSize"
border>
<el-table-column :index="indexMethod" align="center" header-align="center" label="序号" type="index" width="80"></el-table-column>
<el-table-column align="center" header-align="center" label="课程类型名称" prop="typeName" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="备注" prop="remark" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="排序编号" prop="sortNum" width="140"></el-table-column>
<el-table-column align="center" header-align="center" label="是否启用" prop="enabled" width="140">
<template slot-scope="{row}">
<el-switch
v-model="row.enabled"
active-color="#13ce66"
inactive-color="#ff4949"
@change="switchChange(row)">
</el-switch>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="操作" width="180">
<template slot-scope="{row}">
<el-button @click="openEdit(row)" size="mini" type="primary">编辑</el-button>
<el-button @click="doDelete(row)" size="mini" type="danger">删除</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</guava>
<el-dialog :title="title" :visible.sync="dialogVisible" :close-on-click-modal="false" width="40%">
<el-form :model="formData" ref="courseTypeForm" :rules="formRules" label-width="120px">
<el-form-item label="课程类型名称" prop="typeName">
<el-input v-model="formData.typeName" maxlength="100" placeholder="请输入课程类型名称"></el-input>
</el-form-item>
<el-form-item label="排序编号" prop="sortNum">
<el-input-number
v-model="formData.sortNum"
:controls="false"
:min="0"
:precision="0"
placeholder="请输入排序编号"
style="width: 100%">
</el-input-number>
</el-form-item>
<el-form-item label="是否启用" prop="enabled">
<el-switch v-model="formData.enabled" active-color="#13ce66" inactive-color="#ff4949"></el-switch>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
maxlength="500"
show-word-limit
:rows="4"
placeholder="请输入备注">
</el-input>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :disabled="subDis" @click="operation">确定</el-button>
</span>
</el-dialog>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
mixins: [initTableMixins],
data() {
return {
title: "",
dialogVisible: false,
subDis: false,
formData: {},
formRules: {
typeName: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
sortNum: [{ required: true, message: "必填", trigger: ["blur", "change"] }]
}
}
},
methods: {
resetSearch() {
this.pageForm.typeName = ""
this.doSearch()
},
openAdd() {
this.title = "新建课程类型"
this.formData = {
typeName: "",
sortNum: 0,
enabled: true,
remark: ""
}
this.dialogVisible = true
this.$nextTick(() => {
this.$refs.courseTypeForm && this.$refs.courseTypeForm.clearValidate()
})
},
openEdit(row) {
this.title = "编辑课程类型"
this.formData = Object.assign({}, row)
this.dialogVisible = true
this.$nextTick(() => {
this.$refs.courseTypeForm && this.$refs.courseTypeForm.clearValidate()
})
},
operation() {
const method = this.formData.id ? "/doEdit" : "/doAdd"
this.$refs.courseTypeForm.validate(async (valid) => {
if (!valid) return
this.subDis = true
const resp = await this.$axios.post(loc() + method, this.formData)
this.subDis = false
if (resp.code === 0) {
this.dialogVisible = false
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.warning(resp.msg)
}
})
},
async switchChange(row) {
const resp = await this.$axios.post(loc() + "/doEdit", row)
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.error(resp.msg)
row.enabled = !row.enabled
}
},
doDelete(row) {
this.$confirm("确定要删除该课程类型吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(async () => {
const resp = await this.$axios.post(loc() + "/doDelete", { id: row.id })
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.error(resp.msg)
}
})
}
},
created() {
this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,158 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<guava ref="guava">
<el-card shadow="never">
<search :is-search-button="false">
<search-item label="课程名称">
<el-select v-model="pageForm.courseId" clearable filterable placeholder="请选择课程" style="width: 100%">
<el-option v-for="item in courseOptions" :key="item.id" :label="item.courseName" :value="item.id"></el-option>
</el-select>
</search-item>
<search-item label="是否完成">
<el-select v-model="pageForm.completeStatus" clearable placeholder="请选择完成状态" style="width: 100%">
<el-option v-for="item in statusOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</search-item>
<div class="search-query">
<el-button size="medium" type="primary" @click="doSearch">搜索</el-button>
<el-button size="medium" @click="resetSearch">重置</el-button>
</div>
</search>
</el-card>
<el-card class="mt10" shadow="never">
<table-tool :app="this" label="学习列表"></table-tool>
<el-table
v-loading="tableLoading"
:data="tableData"
:size="tableSize"
border
@sort-change="pageOrder">
<el-table-column :index="indexMethod" align="center" header-align="center" label="序号" type="index" width="70"></el-table-column>
<el-table-column align="center" header-align="center" label="工号" prop="loginName" sortable="custom" show-overflow-tooltip width="120"></el-table-column>
<el-table-column align="center" header-align="center" label="姓名" prop="userName" sortable="custom" show-overflow-tooltip width="110"></el-table-column>
<el-table-column align="center" header-align="center" label="所属分工会" prop="unionName" sortable="custom" show-overflow-tooltip min-width="150"></el-table-column>
<el-table-column align="center" header-align="center" label="课程名称" prop="courseName" sortable="custom" show-overflow-tooltip min-width="180"></el-table-column>
<el-table-column align="center" header-align="center" label="章节名称" prop="outlineName" sortable="custom" show-overflow-tooltip min-width="160"></el-table-column>
<el-table-column align="center" header-align="center" label="学习开始时间" prop="firstStudyTime" sortable="custom" width="170">
<template slot-scope="{row}">{{formatTime(row.firstStudyTime)}}</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="最近学习时间" prop="latestStudyTime" sortable="custom" width="170">
<template slot-scope="{row}">{{formatTime(row.latestStudyTime)}}</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="学习时长" prop="studySeconds" sortable="custom" width="130">
<template slot-scope="{row}">{{row.studyTimeText}}</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="学习进度" prop="progressPercent" sortable="custom" width="180">
<template slot-scope="{row}">
<el-progress :percentage="Number(row.progressPercent || 0)" :stroke-width="8"></el-progress>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="是否完成" prop="completeStatus" sortable="custom" width="110">
<template slot-scope="{row}">
<el-tag :type="statusType(row.completeStatus)" size="mini">{{statusName(row.completeStatus)}}</el-tag>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="操作" width="100">
<template slot-scope="{row}">
<el-button size="mini" type="danger" icon="el-icon-delete" @click="deleteRecord(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</guava>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
mixins: [initTableMixins],
data() {
return {
apiBase: "/platform/learning/study/record",
courseOptions: [],
statusOptions: [
{ name: "未开始", code: "not_started", type: "info" },
{ name: "学习中", code: "studying", type: "warning" },
{ name: "已完成", code: "completed", type: "success" }
]
}
},
methods: {
pageData() {
this.tableLoading = true
this.$axios.post(this.apiBase + "/pageData", this.pageForm).then(resp => {
this.tableLoading = false
if (resp.code === 0) {
this.tableData = resp.data.list || []
this.pageForm.totalCount = resp.data.totalCount
} else {
this.$message.warning(resp.msg)
}
}).catch(() => {
this.tableLoading = false
})
},
pageOrder(column) {
this.pageForm.pageOrderName = column.prop
this.pageForm.pageOrderBy = column.order
this.pageData()
},
doSearch() {
this.pageForm.pageNumber = 1
this.pageData()
},
resetSearch() {
this.pageForm.courseId = ""
this.pageForm.completeStatus = ""
this.pageForm.pageNumber = 1
this.pageData()
},
async loadOptions() {
const courseResp = await this.$axios.post(this.apiBase + "/courseOptions")
if (courseResp.code === 0) {
this.courseOptions = courseResp.data || []
}
},
statusName(code) {
const item = this.statusOptions.find(v => v.code === code)
return item ? item.name : "未开始"
},
statusType(code) {
const item = this.statusOptions.find(v => v.code === code)
return item ? item.type : "info"
},
formatTime(value) {
return value ? this.$moment(value).format("YYYY-MM-DD HH:mm:ss") : ""
},
deleteRecord(row) {
this.$confirm("确定要删除该学习记录吗?删除后该章节的学习时段也会一并清理。", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(async () => {
const resp = await this.$axios.post(this.apiBase + "/delete", { id: row.id })
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.warning(resp.msg)
}
})
}
},
async created() {
this.$set(this.pageForm, "courseId", "")
this.$set(this.pageForm, "completeStatus", "")
await this.loadOptions()
this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,222 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<guava ref="guava">
<el-card shadow="never">
<div class="search">
<search-item label="课程名称">
<el-select
v-model="pageForm.courseId"
clearable
filterable
remote
:remote-method="queryCourseOptions"
placeholder="请选择课程"
style="width: 100%">
<el-option v-for="item in courseOptions" :key="item.id" :label="item.courseName" :value="item.id"></el-option>
</el-select>
</search-item>
<search-item label="所属分工会" v-if="unionOptions.length">
<el-select v-model="pageForm.unionId" clearable filterable placeholder="请选择所属分工会" style="width: 100%">
<el-option v-for="item in unionOptions" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
</search-item>
<search-item label="工号" v-if="activeTab === 'records'">
<el-input v-model="pageForm.loginName" clearable placeholder="请输入工号" @keyup.enter.native="doSearch"></el-input>
</search-item>
<search-item label="姓名" v-if="activeTab === 'records'">
<el-input v-model="pageForm.userName" clearable placeholder="请输入姓名" @keyup.enter.native="doSearch"></el-input>
</search-item>
<search-item label="是否完成" v-if="activeTab === 'records'">
<el-select v-model="pageForm.completeStatus" clearable placeholder="请选择完成状态" style="width: 100%">
<el-option label="已完成" value="completed"></el-option>
<el-option label="未完成" value="incomplete"></el-option>
</el-select>
</search-item>
<div class="search-query">
<el-button size="medium" type="primary" @click="doSearch">搜索</el-button>
<el-button size="medium" @click="resetSearch">重置</el-button>
</div>
</div>
</el-card>
<el-card class="mt10" shadow="never">
<el-tabs v-model="activeTab" @tab-click="tabChange" style="margin-bottom: 12px">
<el-tab-pane label="课程统计" name="course"></el-tab-pane>
<el-tab-pane label="分工会统计" name="union"></el-tab-pane>
<el-tab-pane label="学习列表" name="records"></el-tab-pane>
</el-tabs>
<table-tool :app="this" :label="activeTab === 'course' ? '课程统计' : activeTab === 'union' ? '分工会统计' : '学习列表'"></table-tool>
<el-table
v-if="activeTab === 'course'"
key="course-statistics"
v-loading="tableLoading"
:data="tableData"
:size="tableSize"
border
@sort-change="pageOrder">
<el-table-column :index="indexMethod" align="center" header-align="center" label="序号" type="index" width="70"></el-table-column>
<el-table-column align="center" header-align="center" label="课程名称" prop="courseName" sortable="custom" show-overflow-tooltip min-width="220" ></el-table-column>
<el-table-column align="center" header-align="center" label="学习人数" prop="learnerCount" sortable="custom" width="130"></el-table-column>
<el-table-column align="center" header-align="center" label="完成人数" prop="completedCount" sortable="custom" width="130"></el-table-column>
<el-table-column align="center" header-align="center" label="完成率" prop="completeRate" sortable="custom" width="130"></el-table-column>
<el-table-column align="center" header-align="center" label="平均学习时长" prop="avgStudySeconds" sortable="custom" width="170">
<template slot-scope="{row}">{{row.avgStudyTimeText}}</template>
</el-table-column>
</el-table>
<el-table
v-else-if="activeTab === 'union'"
key="union-statistics"
v-loading="tableLoading"
:data="tableData"
:size="tableSize"
border
@sort-change="pageOrder">
<el-table-column :index="indexMethod" align="center" header-align="center" label="序号" type="index" width="70"></el-table-column>
<el-table-column align="center" header-align="center" label="课程名称" prop="courseName" sortable="custom" show-overflow-tooltip min-width="220"></el-table-column>
<el-table-column align="center" header-align="center" label="分工会名称" prop="unionName" sortable="custom" show-overflow-tooltip min-width="170"></el-table-column>
<el-table-column align="center" header-align="center" label="学习人数" prop="learnerCount" sortable="custom" width="130"></el-table-column>
<el-table-column align="center" header-align="center" label="完成人数" prop="completedCount" sortable="custom" width="130"></el-table-column>
<el-table-column align="center" header-align="center" label="学习时长" prop="studySeconds" sortable="custom" width="160">
<template slot-scope="{row}">{{row.studyTimeText}}</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="完成率" prop="completeRate" sortable="custom" width="130"></el-table-column>
</el-table>
<el-table v-else key="learning-records" v-loading="tableLoading" :data="tableData" :size="tableSize" border @sort-change="pageOrder">
<el-table-column :index="indexMethod" align="center" label="序号" type="index" width="70"></el-table-column>
<el-table-column align="center" label="工号" prop="loginName" sortable="custom" show-overflow-tooltip width="120"></el-table-column>
<el-table-column align="center" label="姓名" prop="userName" sortable="custom" show-overflow-tooltip width="110"></el-table-column>
<el-table-column align="center" label="所属分工会" prop="unionName" sortable="custom" show-overflow-tooltip min-width="150"></el-table-column>
<el-table-column align="center" label="课程名称" prop="courseName" sortable="custom" show-overflow-tooltip min-width="180"></el-table-column>
<el-table-column align="center" label="章节名称" prop="outlineName" sortable="custom" show-overflow-tooltip min-width="160"></el-table-column>
<el-table-column align="center" label="学习开始时间" prop="firstStudyTime" sortable="custom" width="170">
<template slot-scope="{row}">{{formatTime(row.firstStudyTime)}}</template>
</el-table-column>
<el-table-column align="center" label="最近学习时间" prop="latestStudyTime" sortable="custom" width="170">
<template slot-scope="{row}">{{formatTime(row.latestStudyTime)}}</template>
</el-table-column>
<el-table-column align="center" label="学习时长" prop="studySeconds" sortable="custom" width="130">
<template slot-scope="{row}">{{row.studyTimeText}}</template>
</el-table-column>
<el-table-column align="center" label="学习进度" prop="progressPercent" sortable="custom" width="180">
<template slot-scope="{row}"><el-progress :percentage="Number(row.progressPercent || 0)" :stroke-width="8"></el-progress></template>
</el-table-column>
<el-table-column align="center" label="是否完成" prop="completeStatus" sortable="custom" width="110">
<template slot-scope="{row}"><el-tag :type="row.completeStatus === 'completed' ? 'success' : 'warning'" size="mini">{{row.completeStatus === 'completed' ? '已完成' : '未完成'}}</el-tag></template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</guava>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
mixins: [initTableMixins],
data() {
return {
apiBase: "/platform/learning/statistics",
activeTab: "course",
courseOptions: [],
unionOptions: [],
requestSeq: 0
}
},
methods: {
pageData() {
this.tableLoading = true
const requestSeq = ++this.requestSeq
const urls = { course: "/coursePageData", union: "/unionPageData", records: "/recordPageData" }
this.$axios.post(this.apiBase + urls[this.activeTab], this.pageForm).then(resp => {
if (requestSeq !== this.requestSeq) return
if (resp.code === 0) {
this.tableData = resp.data.list || []
this.pageForm.totalCount = resp.data.totalCount
} else {
this.$message.warning(resp.msg)
}
}).catch(() => {
if (requestSeq === this.requestSeq) this.$message.warning("查询失败")
}).finally(() => {
if (requestSeq === this.requestSeq) this.tableLoading = false
})
},
formatTime(value) {
return value ? this.$moment(value).format("YYYY-MM-DD HH:mm:ss") : ""
},
pageOrder(column) {
this.pageForm.pageOrderName = column.prop
this.pageForm.pageOrderBy = column.order
this.pageData()
},
async doSearch() {
this.pageForm.pageNumber = 1
await this.ensureUnionDefaultCourse()
this.pageData()
},
async resetSearch() {
this.pageForm.courseId = ""
this.pageForm.unionId = ""
this.pageForm.loginName = ""
this.pageForm.userName = ""
this.pageForm.completeStatus = ""
this.pageForm.pageOrderName = ""
this.pageForm.pageOrderBy = ""
this.pageForm.pageNumber = 1
await this.queryCourseOptions("")
await this.ensureUnionDefaultCourse()
this.pageData()
},
async tabChange() {
this.requestSeq += 1
this.tableData = []
this.pageForm.totalCount = 0
this.pageForm.pageNumber = 1
this.pageForm.pageOrderName = ""
this.pageForm.pageOrderBy = ""
await this.ensureUnionDefaultCourse()
this.pageData()
},
async queryCourseOptions(query) {
const resp = await this.$axios.post(this.apiBase + "/courseOptions", { courseName: query || "" })
if (resp.code === 0) {
this.courseOptions = resp.data || []
}
},
async ensureUnionDefaultCourse() {
if (this.activeTab !== "union" || this.pageForm.courseId) {
return
}
if (!this.courseOptions.length) {
await this.queryCourseOptions("")
}
if (this.courseOptions.length) {
this.pageForm.courseId = this.courseOptions[0].id
}
},
async loadUnionOptions() {
const resp = await this.$axios.post(this.apiBase + "/unionOptions")
if (resp.code === 0) {
this.unionOptions = resp.data || []
}
}
},
async created() {
this.$set(this.pageForm, "courseId", "")
this.$set(this.pageForm, "unionId", "")
this.$set(this.pageForm, "loginName", "")
this.$set(this.pageForm, "userName", "")
this.$set(this.pageForm, "completeStatus", "")
await this.queryCourseOptions("")
await this.loadUnionOptions()
this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -74,10 +74,21 @@ const courseList = {
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="100"> <el-table-column label="操作" width="200">
<template v-slot="{row}"> <template v-slot="{row}">
<el-button v-if="!row.isSign" type="primary" size="mini" @click="onSign(row)">我要报名</el-button> <template v-if="!row.isSign">
<el-button v-else type="danger" size="mini" @click="onCancel(row)">取消报名</el-button> <el-button v-if="isBeforeSignUpTime" type="primary" size="mini" @click="onPrefill(row)">
{{row.hasPrefill ? '修改预填信息' : '预填报名信息'}}
</el-button>
<el-button v-else-if="isInSignUpTime && row.hasPrefill" type="success" size="mini" @click="onQuickSignUp(row)">
立即报名
</el-button>
<el-button v-else-if="isInSignUpTime" type="primary" size="mini" @click="onSign(row)">我要报名</el-button>
</template>
<template v-else>
<el-button v-if="isInSignUpTime" type="primary" size="mini" @click="onEdit(row)">修改报名</el-button>
<el-button type="danger" size="mini" @click="onCancel(row)">取消报名</el-button>
</template>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -150,10 +161,63 @@ const courseList = {
pdf: false, pdf: false,
infoVisible: false, infoVisible: false,
introduce: '', introduce: '',
nowTime: Date.now(),
signTimeTimer: null,
} }
}, },
computed: {
// 只有严格早于报名开始时间时才允许维护预填数据。
isBeforeSignUpTime() {
return this.activity.activitySignUpStartTime
&& this.$moment(this.nowTime).isBefore(this.$moment(this.activity.activitySignUpStartTime))
},
// 修改报名与我要报名共用同一时间范围:开始时间已到且尚未达到结束时间。
isInSignUpTime() {
const now = this.$moment(this.nowTime)
return this.activity.activitySignUpStartTime
&& this.activity.activitySignUpEndTime
&& !now.isBefore(this.$moment(this.activity.activitySignUpStartTime))
&& now.isBefore(this.$moment(this.activity.activitySignUpEndTime))
},
},
methods: { methods: {
async onPreview(introduce) { onPrefill(row) {
const courseType = this.courseTypeList.find((item) => item.id === row.courseType)
this.tableLoading = true
this.$axios.post("/platform/family/apply/getPrefill", {courseId: row.id})
.then((resp) => {
if (resp.code !== 0) {
this.$message.warning(resp.msg)
return
}
this.$refs.signFormRef.onOpen(row, courseType, this.activity, resp.data, "prefill")
})
.finally(() => {
this.tableLoading = false
})
},
onQuickSignUp(row) {
this.$confirm("将使用报名开始前保存的预填信息直接报名,是否继续?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(() => {
this.tableLoading = true
this.$axios.post("/platform/family/apply/quickSignUp", {courseId: row.id})
.then((resp) => {
if (resp.code === 0) {
this.$alert(resp.msg, "提示", {confirmButtonText: "确定", type: "success"})
this.pageData()
} else {
this.$message.warning(resp.msg)
}
})
.finally(() => {
this.tableLoading = false
})
}).catch(() => {})
},
onPreview(introduce) {
try { try {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(introduce, 'text/html'); const doc = parser.parseFromString(introduce, 'text/html');
@@ -161,10 +225,16 @@ const courseList = {
const href = link.getAttribute('href'); const href = link.getAttribute('href');
const id = href.substring(href.indexOf("=") + 1) const id = href.substring(href.indexOf("=") + 1)
const res = await this.$axios.post("/platform/sys/file/previewFileData", { ids: JSON.stringify([id]) }) this.$axios.post("/platform/sys/file/previewFileData", {ids: JSON.stringify([id])})
.then((res) => {
this.pdf = true this.pdf = true
window.open("/assets/platform/plugins/pdfJs/web/viewer.html?file=" + encodeURIComponent(res.data[0].downloadPath), res.data[0].name) window.open("/assets/platform/plugins/pdfJs/web/viewer.html?file=" + encodeURIComponent(res.data[0].downloadPath), res.data[0].name)
})
.catch(() => {
this.introduce = introduce
this.pdf = false
this.infoVisible = true
})
} catch (e) { } catch (e) {
this.introduce = introduce this.introduce = introduce
this.pdf = false this.pdf = false
@@ -180,12 +250,14 @@ const courseList = {
} }
this.doSearch() this.doSearch()
}, },
async onOpen(row) { onOpen(row) {
this.activity = row this.activity = row
this.$set(this.pageForm, 'activityId', row.id) this.$set(this.pageForm, 'activityId', row.id)
await this.pageData() this.pageData()
await this.getCourseTypeList() .then(() => this.getCourseTypeList())
this.queryCourseAssort() .then(() => {
this.queryCourseAssort()
})
}, },
onSign(row) { onSign(row) {
const courseType = this.courseTypeList.find((v) => v.id === row.courseType) const courseType = this.courseTypeList.find((v) => v.id === row.courseType)
@@ -208,34 +280,64 @@ const courseList = {
} }
}) })
}, },
onEdit(row) {
const courseType = this.courseTypeList.find((item) => item.id === row.courseType)
this.tableLoading = true
this.$axios.post("/platform/family/apply/validateUpdateSignUp", {courseId: row.id})
.then((validateRes) => {
if (validateRes.code !== 0) {
this.$message.warning(validateRes.msg)
return null
}
return this.$axios.post("/platform/family/apply/getMySignUp", {courseId: row.id})
})
.then((signUpRes) => {
if (!signUpRes) return
if (signUpRes.code !== 0) {
this.$message.warning(signUpRes.msg)
return
}
this.$refs.signFormRef.onOpen(row, courseType, this.activity, signUpRes.data)
})
.finally(() => {
this.tableLoading = false
})
},
onCancel(row) { onCancel(row) {
this.$confirm("您确定要取消吗?", "提示", { this.$confirm("您确定要取消吗?", "提示", {
confirmButtonText: "确定", confirmButtonText: "确定",
cancelButtonText: "取消", cancelButtonText: "取消",
type: "info" type: "info"
}).then(async () => { }).then(() => {
const resp = await this.$axios.post("/platform/family/apply/cancelSignUp", { this.$axios.post("/platform/family/apply/cancelSignUp", {
activityId: row.activityId, activityId: row.activityId,
courseId: row.id courseId: row.id
}) })
if (resp.code === 0) { .then((resp) => {
await this.pageData() if (resp.code !== 0) {
this.$message.success(resp.msg) this.$message.warning(resp.msg)
} else { return
this.$message.warning(resp.msg) }
} this.$message.success(resp.msg)
return this.pageData()
})
}) })
}, },
async getCourseTypeList() { getCourseTypeList() {
const resp = await this.$axios.post("/platform/family/type/getAllType") return this.$axios.post("/platform/family/type/getAllType")
if (resp.code === 0) { .then((resp) => {
this.courseTypeList = resp.data if (resp.code === 0) {
} this.courseTypeList = resp.data
}
return resp
})
}, },
async openViewCourseTime(id) { openViewCourseTime(id) {
const resp = await this.$axios.post(loc() + "/getCourseTime", { id: id }) this.$axios.post(loc() + "/getCourseTime", {id: id})
this.courseTimeList = resp.data .then((resp) => {
this.courseTimeListDialog = true this.courseTimeList = resp.data
this.courseTimeListDialog = true
})
}, },
openViewMap(point) { openViewMap(point) {
if (!Array.isArray(point)) { if (!Array.isArray(point)) {
@@ -270,16 +372,19 @@ const courseList = {
return "<span style='color: red'>余" + lave +"</span>/" + o.coursePeopleNumber + "人" return "<span style='color: red'>余" + lave +"</span>/" + o.coursePeopleNumber + "人"
} }
}, },
async pageData() { pageData() {
const pageForm = clone({...this.pageForm}) const pageForm = clone({...this.pageForm})
pageForm.assortTypes = JSON.stringify(pageForm.assortTypes) pageForm.assortTypes = JSON.stringify(pageForm.assortTypes)
const resp = await this.$axios.post(loc() + "/pageData", pageForm) return this.$axios.post(loc() + "/pageData", pageForm)
if (resp.code === 0) { .then((resp) => {
this.tableData = resp.data.list if (resp.code === 0) {
this.pageForm.totalCount = resp.data.totalCount this.tableData = resp.data.list
} else { this.pageForm.totalCount = resp.data.totalCount
this.$message.warning(resp.msg) } else {
} this.$message.warning(resp.msg)
}
return resp
})
}, },
queryCourseAssort() { queryCourseAssort() {
this.$axios.post(loc() + "/queryCourseAssort", {activityId: this.activity.id}) this.$axios.post(loc() + "/queryCourseAssort", {activityId: this.activity.id})
@@ -289,7 +394,13 @@ const courseList = {
}, },
}, },
created() { created() {
// 页面停留到报名开始时间时自动切换为正式报名按钮,无需用户手工刷新。
this.signTimeTimer = window.setInterval(() => {
this.nowTime = Date.now()
}, 1000)
},
beforeDestroy() {
if (this.signTimeTimer) window.clearInterval(this.signTimeTimer)
}, },
style: /*language=CSS*/ ` style: /*language=CSS*/ `
.glow-box { .glow-box {
@@ -170,10 +170,7 @@ layout("/layouts/platform.html"){
}) })
}, },
onOpen(row) { onOpen(row) {
if(this.$store.state.user.loginname !== '45066' && this.$moment().isBefore(this.$moment(row.activitySignUpStartTime))) { // 报名前允许进入课程列表维护预填信息,正式报名仍由后端报名时间校验控制。
this.onView(row)
return
}
this.infoVisible = false this.infoVisible = false
this.$refs.guava.view(() => { this.$refs.guava.view(() => {
this.$refs.courseListRef.onOpen(row) this.$refs.courseListRef.onOpen(row)
@@ -188,7 +185,7 @@ layout("/layouts/platform.html"){
}) })
}, },
}, },
async created() { created() {
this.pageData() this.pageData()
} }
}) })
@@ -1,7 +1,8 @@
const signForm = { const signForm = {
template: /*language=HTML*/ ` template: /*language=HTML*/ `
<div> <div>
<el-dialog :close-on-click-modal="false" :visible.sync="signDialog" title="信息填写" width="50%" <el-dialog :close-on-click-modal="false" :visible.sync="signDialog"
:title="prefillMode ? (formData.id ? '修改预填信息' : '预填报名信息') : (editMode ? '修改报名' : '信息填写')" width="50%"
append-to-body> append-to-body>
<el-form :model="formData" ref="form" label-width="120px"> <el-form :model="formData" ref="form" label-width="120px">
<div class="left-span-label">个人信息</div> <div class="left-span-label">个人信息</div>
@@ -36,7 +37,8 @@ const signForm = {
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="24"> <el-col :span="24">
<el-form-item label="联系方式" prop="mobile"> <el-form-item label="联系方式" prop="mobile"
:rules="{ required: true, message: '请填写联系方式', trigger: 'blur'}">
<el-input v-model="formData.mobile"></el-input> <el-input v-model="formData.mobile"></el-input>
</el-form-item> </el-form-item>
</el-col> </el-col>
@@ -137,7 +139,7 @@ const signForm = {
<el-row class="mt20" justify="end" type="flex"> <el-row class="mt20" justify="end" type="flex">
<el-button @click="signDialog = false">取 消</el-button> <el-button @click="signDialog = false">取 消</el-button>
<el-button @click="onSubmit" type="primary">提 交</el-button> <el-button :loading="formLoading" @click="onSubmit" type="primary">{{prefillMode ? '保存预填' : '提 交'}}</el-button>
</el-row> </el-row>
</el-dialog> </el-dialog>
</div> </div>
@@ -147,6 +149,9 @@ const signForm = {
data() { data() {
return { return {
signDialog: false, signDialog: false,
editMode: false,
prefillMode: false,
formLoading: false,
formData: { formData: {
mobileColumnsValue: [], mobileColumnsValue: [],
}, },
@@ -160,8 +165,12 @@ const signForm = {
}, },
methods: { methods: {
addFamily() { addFamily() {
if(this.formData.mobileColumnsValue.length >= this.activity.familyMaxCount) { if(this.courseTypeRow.familyMaxCount === null || this.courseTypeRow.familyMaxCount === undefined || this.courseTypeRow.familyMaxCount === '') {
this.$message.warning('家属最多人数为' + this.activity.familyMaxCount) this.$message.warning('当前子活动类型未配置家属最多人数')
return
}
if(this.formData.mobileColumnsValue.length >= Number(this.courseTypeRow.familyMaxCount)) {
this.$message.warning('家属最多人数为' + this.courseTypeRow.familyMaxCount)
return return
} }
const list = clone(this.courseTypeRow.familyMobileSignColumnList) const list = clone(this.courseTypeRow.familyMobileSignColumnList)
@@ -172,58 +181,79 @@ const signForm = {
const ac = (Number(this.active) - 1) const ac = (Number(this.active) - 1)
this.active = '' + (ac >= 0 ? ac : 0) this.active = '' + (ac >= 0 ? ac : 0)
}, },
async validSignUp() { validSignUp() {
//获取家属是多少人 //获取家属是多少人
const res = await this.$axios.post("/platform/family/apply/validateSignUp", { return this.$axios.post("/platform/family/apply/validateSignUp", {
courseId: this.formData.courseId, courseId: this.formData.courseId,
currentFamilyNumber: this.formData.mobileColumnsValue.length || 0 currentFamilyNumber: this.formData.mobileColumnsValue.length || 0
}) })
if (res.code !== 0) { .then((res) => {
this.$alert(res.msg, "提示", { if (res.code !== 0) {
confirmButtonText: "确定", this.$alert(res.msg, "提示", {
type: "warning" confirmButtonText: "确定",
type: "warning"
})
return false
}
return true
}) })
return false
}
return true
}, },
async validateSourceSignUp() { validateSourceSignUp() {
if (this.courseRow.courseIsLimitApply) { if (!this.courseRow.courseIsLimitApply) {
const resp = await $.post('/platform/family/apply/validateSourceSignUp', { return Promise.resolve(true)
}
return $.post('/platform/family/apply/validateSourceSignUp', {
activityCourseId: this.formData.activityCourseId, activityCourseId: this.formData.activityCourseId,
courseId: this.courseRow.id courseId: this.courseRow.id
}) })
if (resp.code !== 0) { .then((resp) => {
this.$alert(resp.msg, "提示", { if (resp.code !== 0) {
confirmButtonText: "确定", this.$alert(resp.msg, "提示", {
type: "warning" confirmButtonText: "确定",
}) type: "warning"
return false })
} return false
} }
return true return true
},
async getCourseTimeSelectList(o) {
const resp = await $.post('/platform/family/apply/getCourseTimeSelectList', {courseId: o.id})
if (resp.code === 0) {
this.courseTimeSelectList = resp.data
} else {
this.$message.warning('获取时段信息失败,请联系管理员')
}
},
async onOpen(row, courseType, activity) {
this.initData(row, courseType, activity)
if (row.courseIsLimitApply) {
await this.getCourseTimeSelectList(row)
}
if (courseType && courseType.familyMobileSignColumnList.length > 0) {
courseType.familyMobileSignColumnList.forEach(item => {
item.columnValue = ''
}) })
.always(() => {})
},
getCourseTimeSelectList(o, familyUserId) {
const url = familyUserId
? "/platform/family/apply/getEditCourseTimeSelectList"
: "/platform/family/apply/getCourseTimeSelectList"
this.formLoading = true
return $.post(url, {courseId: o.id, familyUserId: familyUserId})
.then((resp) => {
if (resp.code === 0) {
this.$set(this, "courseTimeSelectList", resp.data)
} else {
this.$message.warning(resp.msg || "获取时段信息失败,请联系管理员")
}
return resp
})
.always(() => {
this.formLoading = false
})
},
onOpen(row, courseType, activity, signUpData, mode) {
this.prefillMode = mode === "prefill"
this.initData(row, courseType, activity, signUpData)
this.$set(this, "courseRow", row)
this.$set(this, "courseTypeRow", courseType)
const openDialog = () => {
this.$set(this, "signDialog", true)
} }
this.courseRow = row if (row.courseIsLimitApply) {
this.courseTypeRow = courseType this.getCourseTimeSelectList(row, this.editMode && signUpData ? signUpData.id : "")
this.signDialog = true .then((resp) => {
if (resp.code === 0) {
openDialog()
}
})
return
}
openDialog()
}, },
validateIdCard(idCard) { validateIdCard(idCard) {
if (!idCard) { if (!idCard) {
@@ -354,60 +384,100 @@ const signForm = {
return { age, sex, }; return { age, sex, };
}, },
async onSubmit() { onSubmit() {
//验证家属表单 //验证家属表单
if (!this.validFamilyForm()) return if (!this.validFamilyForm()) return
if (!await this.validSignUp()) return const signUpValidation = (this.editMode || this.prefillMode) ? Promise.resolve(true) : this.validSignUp()
if (!await this.validateSourceSignUp()) return signUpValidation.then((signUpValid) => {
this.$refs["form"].validate((valid) => { if (!signUpValid) return
if (valid) { const sourceValidation = (this.editMode || this.prefillMode) ? Promise.resolve(true) : this.validateSourceSignUp()
this.$confirm("您确定要提交吗?", "提示", { sourceValidation.then((sourceValid) => {
confirmButtonText: "确定", if (!sourceValid) return
cancelButtonText: "取消", this.$refs["form"].validate((valid) => {
type: "warning" if (!valid) return
}).then(async () => { this.$confirm("您确定要提交吗?", "提示", {
const formData = clone(this.formData) confirmButtonText: "确定",
let array = [] cancelButtonText: "取消",
formData.mobileColumnsValue.forEach(item => { type: "warning"
const o = item.map((v) => { }).then(() => {
return { const formData = clone(this.formData)
columnName: v.columnName, const array = formData.mobileColumnsValue.map((item) => {
columnValue: v.columnValue, return item.map((column) => {
columnCode: v.columnCode, return {
columnFormType: v.columnFormType columnName: column.columnName,
} columnValue: column.columnValue,
columnCode: column.columnCode,
columnFormType: column.columnFormType
}
})
}) })
array.push(o) formData.mobileColumnsValue = JSON.stringify(clone(array))
const url = this.prefillMode
? "/platform/family/apply/savePrefill"
: (this.editMode
? "/platform/family/apply/updateSignUp"
: "/platform/family/apply/doSignUp")
this.formLoading = true
this.$axios.post(url, formData)
.then((resp) => {
if (resp.code === 0) {
this.$alert(resp.msg, "提示", {
confirmButtonText: "确定",
type: "warning"
})
this.signDialog = false
this.$emit("refresh")
} else {
this.$message.warning(resp.msg)
}
})
.finally(() => {
this.formLoading = false
})
}) })
formData.mobileColumnsValue = JSON.stringify(clone(array))
const resp = await this.$axios.post("/platform/family/apply/doSignUp", formData)
if (resp.code === 0) {
this.$alert(resp.msg, "提示", {
confirmButtonText: "确定",
type: "warning"
})
this.signDialog = false
this.$emit('refresh')
} else {
this.$message.warning(resp.msg)
}
}) })
} })
}) })
}, },
initData(row, courseType, activity) { // 修改时按 columnCode 将历史值合并进当前字段配置,保留最新必填和格式校验规则。
this.activity = activity mergeMobileColumnsValue(courseType, mobileColumnsValue) {
this.formData = { const columnConfig = courseType && Array.isArray(courseType.familyMobileSignColumnList)
? courseType.familyMobileSignColumnList : []
return (mobileColumnsValue || []).map((family) => {
if (columnConfig.length === 0) {
return clone(family)
}
return clone(columnConfig).map((column) => {
const oldColumn = family.find((item) => item.columnCode === column.columnCode)
this.$set(column, "columnValue", oldColumn ? oldColumn.columnValue : "")
return column
})
})
},
initData(row, courseType, activity, signUpData) {
this.$set(this, "activity", activity)
this.editMode = !this.prefillMode && Boolean(signUpData && signUpData.id)
// 部分历史账号未关联单位或分工会,初始化报名表单时需兼容关联对象为空。
const currentUser = this.$store.state.user || {}
const formData = {
activityId: row.activityId, activityId: row.activityId,
courseId: row.id, courseId: row.id,
username: this.$store.state.user.username, username: currentUser.username,
loginname: this.$store.state.user.loginname, loginname: currentUser.loginname,
unionName: this.$store.state.user.union.name, unionName: currentUser.union ? currentUser.union.name : "",
unitName: this.$store.state.user.unit.name, unitName: currentUser.unit ? currentUser.unit.name : "",
sex: this.$store.state.user.sex, sex: currentUser.sex,
mobile: this.$store.state.user.mobile, mobile: currentUser.mobile,
mobileColumnsValue: [], mobileColumnsValue: [],
} }
if ((this.editMode || this.prefillMode) && signUpData) {
this.$set(formData, "id", signUpData.id)
this.$set(formData, "mobile", signUpData.mobile)
this.$set(formData, "activityCourseId", signUpData.activityCourseId)
this.$set(formData, "mobileColumnsValue",
this.mergeMobileColumnsValue(courseType, signUpData.mobileColumnsValue))
}
this.$set(this, "formData", formData)
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.form.clearValidate() this.$refs.form.clearValidate()
}) })
@@ -503,6 +503,23 @@ const basicForm = {
this.formData = resp.data this.formData = resp.data
this.typeChange(this.formData.trainType) this.typeChange(this.formData.trainType)
this.formData.id = "" this.formData.id = ""
this.formData.courseList.forEach((course) => {
course.id = ""
course.activityId = ""
if (course.courseTimeList) {
course.courseTimeList.forEach((courseTime) => {
courseTime.id = ""
courseTime.activityId = ""
courseTime.courseId = ""
})
}
})
if (this.formData.typeLimits) {
this.formData.typeLimits.forEach((typeLimit) => {
typeLimit.id = ""
typeLimit.activityId = ""
})
}
} }
}, },
typeChange(val) { typeChange(val) {
@@ -46,14 +46,14 @@ layout("/layouts/platform.html"){
<el-radio-button :label="false">未审核</el-radio-button> <el-radio-button :label="false">未审核</el-radio-button>
</el-radio-group> </el-radio-group>
</table-tool> </table-tool>
<el-table :data="tableData" @sort-change="pageOrder"> <el-table v-loading="tableLoading" element-loading-text="加载中..." :data="tableData" @sort-change="pageOrder">
<el-table-column type="index" width="50px" label="序号" :index="indexMethod"></el-table-column> <el-table-column type="index" width="50px" label="序号" :index="indexMethod"></el-table-column>
<el-table-column prop="userName" label="姓名"></el-table-column> <el-table-column prop="userName" label="姓名"></el-table-column>
<el-table-column prop="loginName" label="工号"></el-table-column> <el-table-column prop="loginName" label="工号"></el-table-column>
<el-table-column prop="sex" label="性别"></el-table-column> <el-table-column prop="sex" label="性别"></el-table-column>
<el-table-column prop="unitName" label="单位" show-overflow-tooltip></el-table-column> <el-table-column prop="unitName" label="单位" show-overflow-tooltip></el-table-column>
<el-table-column prop="unionName" label="分工会" show-overflow-tooltip></el-table-column> <el-table-column prop="unionName" label="分工会" show-overflow-tooltip></el-table-column>
<el-table-column prop="clubName" label="申请协会" show-overflow-tooltip></el-table-column> <el-table-column prop="clubName" label="申请协会" min-width="180px" show-overflow-tooltip></el-table-column>
<el-table-column prop="mode" label="申请模式"> <el-table-column prop="mode" label="申请模式">
<template slot-scope="scope"> <template slot-scope="scope">
<el-tag size="mini" v-if="scope.row.mode" type="success">加入</el-tag> <el-tag size="mini" v-if="scope.row.mode" type="success">加入</el-tag>
@@ -62,7 +62,7 @@ layout("/layouts/platform.html"){
</el-table-column> </el-table-column>
<el-table-column prop="applyDate" label="申请时间" show-overflow-tooltip></el-table-column> <el-table-column prop="applyDate" label="申请时间" show-overflow-tooltip></el-table-column>
<el-table-column label="当前节点" prop="processInstanceNodeName" show-overflow-tooltip></el-table-column> <el-table-column label="当前节点" prop="processInstanceNodeName" show-overflow-tooltip></el-table-column>
<el-table-column label="操作" width="300px"> <el-table-column label="操作" width="160px">
<template slot-scope="{row}"> <template slot-scope="{row}">
<el-button @click="openView(row)" size="mini" type="primary">查看</el-button> <el-button @click="openView(row)" size="mini" type="primary">查看</el-button>
<el-button v-if="row.processInstanceTaskStatus==='ACTIVE'" @click="openApproval(row)" size="mini" type="primary"> <el-button v-if="row.processInstanceTaskStatus==='ACTIVE'" @click="openApproval(row)" size="mini" type="primary">
@@ -131,6 +131,21 @@ layout("/layouts/platform.html"){
} }
}, },
methods: { methods: {
pageData(url = null, data = null) {
this.tableLoading = true
return this.$axios.post(url || loc() + "/pageData", data || this.pageForm).then((res) => {
if (res.code === 0) {
this.tableData = res.data.list
this.pageForm.totalCount = res.data.totalCount
} else {
this.$message.error(res.msg)
}
}).catch(() => {
this.$message.error("审核列表加载失败")
}).finally(() => {
this.tableLoading = false
})
},
openView(row) { openView(row) {
this.$refs.guava.public(() => { this.$refs.guava.public(() => {
this.$refs.infoRef.onOpen(row.id) this.$refs.infoRef.onOpen(row.id)
@@ -178,10 +193,15 @@ layout("/layouts/platform.html"){
} }
}, },
async created() { async created() {
this.unitList = await this.$businessTool.listUnit() this.tableLoading = true
this.unionList = await this.$businessTool.listUnion() try {
this.clubList = await this.$businessTool.listClub() this.unitList = await this.$businessTool.listUnit()
this.pageData() this.unionList = await this.$businessTool.listUnion()
this.clubList = await this.$businessTool.listClub()
await this.pageData()
} finally {
this.tableLoading = false
}
} }
}) })
</script> </script>
@@ -41,6 +41,7 @@ layout("/layouts/platform.html"){
<el-card shadow="never"> <el-card shadow="never">
<table-tool label="人员列表"> <table-tool label="人员列表">
<el-button size="small" type="primary" @click="personnelAdd">新增人员</el-button> <el-button size="small" type="primary" @click="personnelAdd">新增人员</el-button>
<el-button size="small" type="primary" icon="el-icon-download" @click="doExport">导出</el-button>
</table-tool> </table-tool>
<el-table ref="table" :data="tableData" :size="tableSize" row-key="id" <el-table ref="table" :data="tableData" :size="tableSize" row-key="id"
style="width: 100%" @sort-change="pageOrder"> style="width: 100%" @sort-change="pageOrder">
@@ -157,6 +158,9 @@ layout("/layouts/platform.html"){
this.$refs.addOrModifySpecialStaffRef.getUserInfo() this.$refs.addOrModifySpecialStaffRef.getUserInfo()
}) })
}, },
doExport() {
this.$downLoad(loc() + "/doExport", this.pageForm)
},
flushUnits() { flushUnits() {
this.$set(this.pageForm, "unitId", null) this.$set(this.pageForm, "unitId", null)
this.units = [] this.units = []
@@ -36,8 +36,8 @@ const selectView = {
</div> </div>
<div class="project-info-content"> <div class="project-info-content">
<div class="info-item"> <div class="info-item">
<div class="info-label">联系电话</div> <div class="info-label">福利电话</div>
<div class="info-value">{{ mergedSelections[0]?.mobile || '暂无' }}</div> <div class="info-value">{{ mergedSelections[0]?.welfareMobile || '暂无' }}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -135,14 +135,14 @@ const optionSelect = {
append-to-body append-to-body
custom-class="welfare-confirm-dialog"> custom-class="welfare-confirm-dialog">
<div class="confirm-content"> <div class="confirm-content">
<!-- 联系电话输入 --> <!-- 福利电话输入:统一读写用户资料中的 welfareMobile -->
<div class="confirm-mobile-section"> <div class="confirm-mobile-section">
<div class="confirm-section-title">联系信息</div> <div class="confirm-section-title">联系信息</div>
<el-form :model="contactForm" ref="contactForm" :rules="contactRules" label-width="80px"> <el-form :model="contactForm" ref="contactForm" :rules="contactRules" label-width="80px">
<el-form-item prop="mobile" label="联系电话"> <el-form-item prop="welfareMobile" label="福利电话">
<el-input <el-input
v-model="contactForm.mobile" v-model="contactForm.welfareMobile"
placeholder="请输入手机号码" placeholder="请输入福利电话"
maxlength="11" maxlength="11"
clearable> clearable>
</el-input> </el-input>
@@ -230,15 +230,15 @@ const optionSelect = {
showOptionDetailDialog: false, // 选项详情弹窗 showOptionDetailDialog: false, // 选项详情弹窗
selectedOption: null, // 当前选中的选项 selectedOption: null, // 当前选中的选项
contactForm: { contactForm: {
mobile: "", // 联系电话, welfareMobile: "", // 福利电话
receiveAddress: "" receiveAddress: ""
}, },
contactRules: { contactRules: {
mobile: [ welfareMobile: [
{ required: true, message: "请输入联系电话", trigger: "blur" }, { required: true, message: "请输入福利电话", trigger: "blur" },
{ {
pattern: /^1[3456789]\d{9}$/, pattern: /^1[3456789]\d{9}$/,
message: "请输入正确的手机号码", message: "请输入正确的福利电话",
trigger: "blur" trigger: "blur"
} }
] ]
@@ -325,7 +325,7 @@ const optionSelect = {
this.selectedRadioId = null this.selectedRadioId = null
this.contactForm = { this.contactForm = {
mobile: "", welfareMobile: "",
receiveAddress: "" receiveAddress: ""
} }
@@ -376,18 +376,8 @@ const optionSelect = {
this.userSelection = resp.data this.userSelection = resp.data
this.hasSubmittedBefore = this.userSelection.length > 0 this.hasSubmittedBefore = this.userSelection.length > 0
if (this.userSelection && this.userSelection.length > 0) { // 福利电话始终从用户资料回显,不再读取选择记录中的 mobile。
this.contactForm.mobile = this.userSelection[0]?.mobile this.getWelfareMobile()
} else {
if (this.isProxySelect) {
// 否则使用默认手机号
$.post("/platform/welfare/selection/situation/getMobileByUserId", { userId: this.userId }).then((res) => {
if (res.code === 0) {
this.contactForm.mobile = res.data
}
})
}
}
// 地址回显 // 地址回显
if (this.projectInfo.provideMode === 3) { if (this.projectInfo.provideMode === 3) {
@@ -422,9 +412,22 @@ const optionSelect = {
}) })
}, },
// 获取本人或被代选用户的福利电话,返回值为字符串或空值。
getWelfareMobile() {
const url = this.isProxySelect
? "/platform/welfare/selection/situation/getWelfareMobileByUserId"
: "/platform/welfare/userSelect/getWelfareMobile"
const formData = this.isProxySelect ? { userId: this.userId } : {}
this.$axios.post(url, formData).then((res) => {
if (res.code === 0) {
this.$set(this.contactForm, "welfareMobile", res.data || "")
}
})
},
// 执行提交 // 执行提交
doSubmit() { doSubmit() {
// 验证手机号 // 校验福利电话和按配送方式动态要求的收货地址。
this.$refs.contactForm.validate((valid) => { this.$refs.contactForm.validate((valid) => {
if (!valid) { if (!valid) {
return return
@@ -442,7 +445,6 @@ const optionSelect = {
{ {
selectOptionId: this.selectedRadioId, selectOptionId: this.selectedRadioId,
selectNum: 1, selectNum: 1,
mobile: this.contactForm.mobile,
receiveAddress: this.contactForm.receiveAddress receiveAddress: this.contactForm.receiveAddress
} }
] ]
@@ -453,7 +455,6 @@ const optionSelect = {
selections = this.selectedOptions.map((option) => ({ selections = this.selectedOptions.map((option) => ({
selectOptionId: option.id, selectOptionId: option.id,
selectNum: option.selectNum, selectNum: option.selectNum,
mobile: this.contactForm.mobile,
receiveAddress: this.contactForm.receiveAddress receiveAddress: this.contactForm.receiveAddress
})) }))
} }
@@ -461,6 +462,7 @@ const optionSelect = {
let url = "/platform/welfare/userSelect/confirmSelect" let url = "/platform/welfare/userSelect/confirmSelect"
const formData = { const formData = {
projectId: this.projectId, projectId: this.projectId,
welfareMobile: this.contactForm.welfareMobile,
selections: JSON.stringify(selections) selections: JSON.stringify(selections)
} }
if (this.isProxySelect) { if (this.isProxySelect) {
@@ -471,8 +473,6 @@ const optionSelect = {
this.$axios this.$axios
.post(url, formData) .post(url, formData)
.then((res) => { .then((res) => {
this.isSubmitting = false
if (res.code === 0) { if (res.code === 0) {
this.showConfirmDialog = false this.showConfirmDialog = false
this.$message.success("选择成功") this.$message.success("选择成功")
@@ -482,8 +482,10 @@ const optionSelect = {
} }
}) })
.catch(() => { .catch(() => {
// 网络异常由全局请求处理器提示。
})
.finally(() => {
this.isSubmitting = false this.isSubmitting = false
// this.$message.error("网络错误,请重试")
}) })
}) })
}, },
@@ -61,6 +61,13 @@ layout("/layouts/platform.html"){
<el-option :key="item.id" :label="item.name" :value="item.id" v-for="item in unitOptions"></el-option> <el-option :key="item.id" :label="item.name" :value="item.id" v-for="item in unitOptions"></el-option>
</el-select> </el-select>
</search-item> </search-item>
<search-item label="管理员代选">
<el-select clearable placeholder="请选择是否管理员代选" style="width: 100%" v-model="pageForm.isSelectByAdmin">
<el-option :value="true" label="是"></el-option>
<el-option :value="false" label="否"></el-option>
</el-select>
</search-item>
</search> </search>
</el-card> </el-card>
@@ -75,6 +82,16 @@ layout("/layouts/platform.html"){
<el-button type="primary" size="small" icon="el-icon-download" style="margin-left: 10px" @click="exportXlsx"> <el-button type="primary" size="small" icon="el-icon-download" style="margin-left: 10px" @click="exportXlsx">
导出选择情况表 导出选择情况表
</el-button> </el-button>
<el-button
type="primary"
size="small"
icon="el-icon-edit"
style="margin-left: 10px"
@click="selectOptionByAdmin"
v-if="$auth.hasRoleOr(['SYSADMIN','SCHOOL_UNION_ADMIN','SCHOOL_UNION_WELFARE_ADMIN'])"
>
管理员代选择
</el-button>
</table-tool> </table-tool>
<el-table <el-table
@@ -114,6 +131,34 @@ layout("/layouts/platform.html"){
<el-dialog title="代选" :visible.sync="optionSelectVisible" width="60%"> <el-dialog title="代选" :visible.sync="optionSelectVisible" width="60%">
<option-select ref="optionSelectRef" @refresh="optionSelectVisible=false;doSearch();"></option-select> <option-select ref="optionSelectRef" @refresh="optionSelectVisible=false;doSearch();"></option-select>
</el-dialog> </el-dialog>
<el-dialog :visible.sync="selectByAdminDialogVisible" title="管理员统一选择福利" width="35%">
<el-form label-position="right" label-width="120px">
<el-form-item label="福利选项">
<el-row :gutter="10" :key="item.id" style="margin: 10px" v-for="(item, oidx) in welfareAdminOptions">
<el-col :span="6">
<el-tag style="height:40px;line-height:40px;width: 100%;">
<span style="display: flex; justify-content: center;">{{item.optionName}}</span>
</el-tag>
</el-col>
<el-col :span="18">
<el-input-number
:max="optionMax(item)"
:min="0"
@change="numChange($event, oidx)"
style="width: 50%"
v-model="item.selectNum"
></el-input-number>
</el-col>
</el-row>
</el-form-item>
</el-form>
<el-alert class="text-primary mb10">给没有选择的福利人员统一代选择</el-alert>
<el-row justify="end" type="flex">
<el-button @click="selectByAdminDialogVisible=false">取消</el-button>
<el-button :loading="doSelectByAdminLoading" @click="doSelectByAdmin" type="primary">提交</el-button>
</el-row>
</el-dialog>
</guava> </guava>
</div> </div>
@@ -144,20 +189,30 @@ layout("/layouts/platform.html"){
{ prop: "welfareUnionName", label: "所属工会", sortable: true }, { prop: "welfareUnionName", label: "所属工会", sortable: true },
{ prop: "welfareUnitName", label: "所属单位", sortable: true }, { prop: "welfareUnitName", label: "所属单位", sortable: true },
{ prop: "selectedOptions", label: "所选福利", sortable: true }, { prop: "selectedOptions", label: "所选福利", sortable: true },
{ prop: "welfareMobile", label: "福利号码", sortable: true } { prop: "selectByAdminName", label: "是否管理员代选", sortable: true },
{ prop: "welfareMobile", label: "福利电话", sortable: true }
], ],
optionSelectVisible: false optionSelectVisible: false,
selectByAdminDialogVisible: false,
doSelectByAdminLoading: false,
welfareAdminOptions: []
} }
}, },
computed: { computed: {
currentProject() {
if (this.pageForm.projectId) {
return this.projectOptions.find((item) => item.id === this.pageForm.projectId) || {}
}
return {}
},
welfareOptions() { welfareOptions() {
if (this.pageForm.projectId) { if (this.pageForm.projectId) {
return this.projectOptions.find((item) => item.id === this.pageForm.projectId).options return this.currentProject.options || []
} }
return [] return []
}, },
provideMode() { provideMode() {
return this.projectOptions.find((item) => item.id === this.pageForm.projectId).provideMode return this.currentProject.provideMode
} }
}, },
methods: { methods: {
@@ -177,8 +232,12 @@ layout("/layouts/platform.html"){
// 获取数据 // 获取数据
pageData() { pageData() {
this.tableLoading = true this.tableLoading = true
const pageForm = Object.assign({}, this.pageForm)
if (pageForm.isSelectByAdmin === "" || pageForm.isSelectByAdmin === null || pageForm.isSelectByAdmin === undefined) {
delete pageForm.isSelectByAdmin
}
this.$axios this.$axios
.post("/platform/welfare/selection/situation/pageData", { pageForm: JSON.stringify(this.pageForm) }) .post("/platform/welfare/selection/situation/pageData", { pageForm: JSON.stringify(pageForm) })
.then((res) => { .then((res) => {
if (res.code === 0) { if (res.code === 0) {
this.tableData = res.data.list this.tableData = res.data.list
@@ -192,10 +251,95 @@ layout("/layouts/platform.html"){
// 导出选择情况表 // 导出选择情况表
exportXlsx() { exportXlsx() {
this.$downLoad("/platform/welfare/selection/situation/exportXlsx", { pageForm: JSON.stringify(this.pageForm) }) const pageForm = Object.assign({}, this.pageForm)
if (pageForm.isSelectByAdmin === "" || pageForm.isSelectByAdmin === null || pageForm.isSelectByAdmin === undefined) {
delete pageForm.isSelectByAdmin
}
this.$downLoad("/platform/welfare/selection/situation/exportXlsx", { pageForm: JSON.stringify(pageForm) })
}, },
// 管理员待选 optionMax(option) {
if (option.maxNum !== undefined && option.maxNum !== null) {
return option.maxNum
}
if (this.currentProject.isCheckBox === "checkBox" && this.currentProject.singleOptionSupportMultipleSelection) {
return this.currentProject.multiSelectNum || 99
}
return 1
},
numChange(value, optionIndex) {
const currentValue = Number(value) || 0
this.$set(this.welfareAdminOptions[optionIndex], "selectNum", currentValue)
if (this.currentProject.isCheckBox !== "checkBox") {
this.welfareAdminOptions.forEach((option, index) => {
this.$set(option, "selectNum", index === optionIndex && currentValue > 0 ? 1 : 0)
})
return
}
if (!this.currentProject.singleOptionSupportMultipleSelection && currentValue > 1) {
this.$set(this.welfareAdminOptions[optionIndex], "selectNum", 1)
this.$message.warning("此套餐最多只能选择1份")
return
}
const sum = this.welfareAdminOptions.reduce((total, option) => total + (option.selectNum || 0), 0)
const maxSelectNum = this.currentProject.isCheckBox === "checkBox" ? (this.currentProject.multiSelectNum || 1) : 1
if (sum > maxSelectNum) {
this.$set(this.welfareAdminOptions[optionIndex], "selectNum", Math.max(0, currentValue - (sum - maxSelectNum)))
this.$message.warning("此次福利最多只能选择" + maxSelectNum + "份")
}
},
selectOptionByAdmin() {
if (!this.pageForm.projectId) {
this.$message.warning("请先选择福利项目")
return
}
this.welfareAdminOptions = this.welfareOptions.map((item) => Object.assign({}, item, { selectNum: 0 }))
this.selectByAdminDialogVisible = true
},
doSelectByAdmin() {
const sum = this.welfareAdminOptions.reduce((total, option) => total + (option.selectNum || 0), 0)
if (sum === 0) {
this.$message.warning("请选择福利")
return
}
this.$confirm("请确定是否给未选择的教职工一键选择当前福利?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(async () => {
this.doSelectByAdminLoading = true
try {
const welfareOptions = this.welfareAdminOptions.map((w) => {
return {
id: w.id,
subjectId: w.subjectId,
selectNum: w.selectNum
}
})
const resp = await this.$axios.post("/platform/welfare/selection/situation/doSelectByAdmin", {
welfareOptions: JSON.stringify(welfareOptions),
projectId: this.pageForm.projectId
})
if (resp.code === 0) {
this.pageData()
this.selectByAdminDialogVisible = false
this.$message.success(resp.msg || "操作成功")
} else {
this.$message.warning(resp.msg || "操作失败")
}
} finally {
this.doSelectByAdminLoading = false
}
}).catch(() => {})
},
// 管理员代选
proxySelect(row) { proxySelect(row) {
this.optionSelectVisible = true this.optionSelectVisible = true
this.$nextTick(() => { this.$nextTick(() => {
@@ -28,6 +28,12 @@ layout("/layouts/platform.html"){
<el-option :key="item.id" :label="item.name" :value="item.id" v-for="item in unionOptions"></el-option> <el-option :key="item.id" :label="item.name" :value="item.id" v-for="item in unionOptions"></el-option>
</el-select> </el-select>
</search-item> </search-item>
<search-item label="选择方式">
<el-select clearable placeholder="选择方式" style="width: 100%" v-model="pageForm.isSelectByAdmin">
<el-option :value="true" label="管理员代选"></el-option>
<el-option :value="false" label="个人选择"></el-option>
</el-select>
</search-item>
</search> </search>
</el-card> </el-card>
@@ -38,6 +44,9 @@ layout("/layouts/platform.html"){
<el-button :disabled="!pageForm.projectId" @click="exportSummary" icon="el-icon-printer" size="small" type="primary"> <el-button :disabled="!pageForm.projectId" @click="exportSummary" icon="el-icon-printer" size="small" type="primary">
导出汇总表 导出汇总表
</el-button> </el-button>
<el-button :disabled="!pageForm.projectId" @click="selectOptionByAdmin" icon="el-icon-edit" size="small" type="primary">
管理员代选择
</el-button>
</template> </template>
</table-tool> </table-tool>
<el-table <el-table
@@ -76,6 +85,34 @@ layout("/layouts/platform.html"){
</template> </template>
</guava> </guava>
<el-dialog :visible.sync="selectByAdminDialogVisible" title="管理员统一选择福利" width="35%">
<el-form label-position="right" label-width="120px">
<el-form-item label="福利选项">
<el-row :gutter="10" :key="item.id" style="margin: 10px" v-for="(item, oidx) in welfareOptions">
<el-col :span="6">
<el-tag style="height:40px;line-height:40px;width: 100%;">
<span style="display: flex; justify-content: center;">{{item.optionName}}</span>
</el-tag>
</el-col>
<el-col :span="18">
<el-input-number
:max="optionMax(item)"
:min="0"
@change="numChange($event, oidx)"
style="width: 50%"
v-model="item.selectNum"
></el-input-number>
</el-col>
</el-row>
</el-form-item>
</el-form>
<el-alert class="text-primary mb10">给没有选择的福利人员统一代选择</el-alert>
<el-row justify="end" type="flex">
<el-button @click="selectByAdminDialogVisible=false">取消</el-button>
<el-button :loading="doSelectByAdminLoading" @click="doSelectByAdmin" type="primary">提交</el-button>
</el-row>
</el-dialog>
<selected-user ref="selectedUserRef"></selected-user> <selected-user ref="selectedUserRef"></selected-user>
<unselected-user ref="unSelectedUserRef"></unselected-user> <unselected-user ref="unSelectedUserRef"></unselected-user>
</div> </div>
@@ -102,7 +139,9 @@ layout("/layouts/platform.html"){
welfareOptions: [], welfareOptions: [],
projectInfo: {}, projectInfo: {},
clearable: false, clearable: false,
tableColumns: [] tableColumns: [],
selectByAdminDialogVisible: false,
doSelectByAdminLoading: false
} }
}, },
methods: { methods: {
@@ -130,9 +169,95 @@ layout("/layouts/platform.html"){
}) })
}, },
optionMax(option) {
if (option.maxNum !== undefined && option.maxNum !== null) {
return option.maxNum
}
if (this.projectInfo.isCheckBox === "checkBox" && this.projectInfo.singleOptionSupportMultipleSelection) {
return this.projectInfo.multiSelectNum || 99
}
return 1
},
numChange(value, optionIndex) {
const currentValue = Number(value) || 0
this.$set(this.welfareOptions[optionIndex], "selectNum", currentValue)
if (this.projectInfo.isCheckBox !== "checkBox") {
this.welfareOptions.forEach((option, index) => {
this.$set(option, "selectNum", index === optionIndex && currentValue > 0 ? 1 : 0)
})
return
}
if (!this.projectInfo.singleOptionSupportMultipleSelection && currentValue > 1) {
this.$set(this.welfareOptions[optionIndex], "selectNum", 1)
this.$message.warning("此套餐最多只能选择1份")
return
}
const sum = this.welfareOptions.reduce((total, option) => total + (option.selectNum || 0), 0)
const maxSelectNum = this.projectInfo.isCheckBox === "checkBox" ? (this.projectInfo.multiSelectNum || 1) : 1
if (sum > maxSelectNum) {
this.$set(this.welfareOptions[optionIndex], "selectNum", Math.max(0, currentValue - (sum - maxSelectNum)))
this.$message.warning("此次福利最多只能选择" + maxSelectNum + "份")
}
},
selectOptionByAdmin() {
if (!this.pageForm.projectId) {
this.$message.warning("请先选择福利项目")
return
}
this.getWelfareOptions()
this.selectByAdminDialogVisible = true
},
doSelectByAdmin() {
const selectNums = this.welfareOptions.map((v) => v.selectNum || 0)
const sum = selectNums.reduce((total, current) => total + current, 0)
if (sum === 0) {
this.$message.warning("请选择福利")
return
}
this.$confirm("请确定是否给未选择的教职工一键选择当前福利?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(async () => {
this.doSelectByAdminLoading = true
try {
const welfareOptions = this.welfareOptions.map((w) => {
return {
id: w.id,
subjectId: w.subjectId,
selectNum: w.selectNum
}
})
const resp = await this.$axios.post("/platform/welfare/statistics/doSelectByAdmin", {
welfareOptions: JSON.stringify(welfareOptions),
projectId: this.pageForm.projectId
})
if (resp.code === 0) {
this.pageData()
this.selectByAdminDialogVisible = false
this.$message.success(resp.msg || "操作成功")
} else {
this.$message.warning(resp.msg || "操作失败")
}
} finally {
this.doSelectByAdminLoading = false
}
}).catch(() => {})
},
async pageData() { async pageData() {
this.tableLoading = true this.tableLoading = true
const resp = await this.$axios.post("/platform/welfare/statistics/pageData", this.pageForm) const pageForm = Object.assign({}, this.pageForm)
if (pageForm.isSelectByAdmin === "" || pageForm.isSelectByAdmin === null || pageForm.isSelectByAdmin === undefined) {
delete pageForm.isSelectByAdmin
}
const resp = await this.$axios.post("/platform/welfare/statistics/pageData", pageForm)
if (resp.code === 0) { if (resp.code === 0) {
this.tableColumns = resp.data.tableColumn this.tableColumns = resp.data.tableColumn
this.tableData = resp.data.tableList this.tableData = resp.data.tableList
@@ -151,8 +276,8 @@ layout("/layouts/platform.html"){
}, },
getWelfareOptions() { getWelfareOptions() {
const data = this.welfareProjectList.find((p) => p.id === this.pageForm.projectId) const data = this.welfareProjectList.find((p) => p.id === this.pageForm.projectId)
this.projectInfo = data this.projectInfo = data || {}
this.welfareOptions = data.options this.welfareOptions = data && data.options ? data.options.map((item) => Object.assign({}, item, { selectNum: 0 })) : []
}, },
async init() { async init() {
if ( if (
@@ -3,6 +3,17 @@ const selectedUser = {
<el-dialog :title="unionName+'已选择用户'" :visible.sync="visible" width="70%"> <el-dialog :title="unionName+'已选择用户'" :visible.sync="visible" width="70%">
<div style="display: flex; justify-content: space-between; margin-bottom: 10px;"> <div style="display: flex; justify-content: space-between; margin-bottom: 10px;">
<div> <div>
<el-select
v-model="pageForm.isSelectByAdmin"
placeholder="选择方式"
style="width: 140px;"
size="small"
clearable
@change="doSearch"
>
<el-option :value="true" label="管理员代选"></el-option>
<el-option :value="false" label="个人选择"></el-option>
</el-select>
<el-input <el-input
v-model="pageForm.searchKeyword" v-model="pageForm.searchKeyword"
placeholder="请输入姓名或工号" placeholder="请输入姓名或工号"
@@ -54,7 +65,8 @@ const selectedUser = {
{ prop: "preparedBy", label: "聘用方式", width: 120, sortable: true }, { prop: "preparedBy", label: "聘用方式", width: 120, sortable: true },
{ prop: "postDoctoralJoinDate", label: "进站时间", sortable: true }, { prop: "postDoctoralJoinDate", label: "进站时间", sortable: true },
{ label: "单位", prop: "welfareUnitName" }, { label: "单位", prop: "welfareUnitName" },
{ label: "手机号", prop: "mobile" }, { label: "福利电话", prop: "welfareMobile" },
{ label: "选择方式", prop: "selectByAdminName" },
{ label: "所选福利", prop: "selectOptionName" } { label: "所选福利", prop: "selectOptionName" }
] ]
} }
@@ -65,16 +77,20 @@ const selectedUser = {
this.projectId = projectId this.projectId = projectId
this.unionId = id this.unionId = id
this.unionName = name this.unionName = name
this.searchKeyword = "" this.pageForm.searchKeyword = ""
this.$set(this.pageForm, "isSelectByAdmin", null)
this.pageData() this.pageData()
}, },
pageData() { pageData() {
const pageForm = Object.assign({}, this.pageForm)
if (pageForm.isSelectByAdmin === "" || pageForm.isSelectByAdmin === null || pageForm.isSelectByAdmin === undefined) {
delete pageForm.isSelectByAdmin
}
this.$axios this.$axios
.post("/platform/welfare/statistics/selectedUnionUserPageData", { .post("/platform/welfare/statistics/selectedUnionUserPageData", {
...this.pageForm, ...pageForm,
projectId: this.projectId, projectId: this.projectId,
unionId: this.unionId, unionId: this.unionId
keyword: this.searchKeyword
}) })
.then((res) => { .then((res) => {
if (res.code === 0) { if (res.code === 0) {
@@ -84,14 +100,18 @@ const selectedUser = {
}) })
}, },
handleSearch() { handleSearch() {
this.pageForm.pageNum = 1 this.pageForm.pageNumber = 1
this.pageData() this.pageData()
}, },
handleExport() { handleExport() {
this.$downLoad("/platform/welfare/statistics/exportSelectedUnionUser", { const params = {
projectId: this.projectId, projectId: this.projectId,
unionId: this.unionId unionId: this.unionId
}) }
if (this.pageForm.isSelectByAdmin !== null && this.pageForm.isSelectByAdmin !== undefined && this.pageForm.isSelectByAdmin !== "") {
params.isSelectByAdmin = this.pageForm.isSelectByAdmin
}
this.$downLoad("/platform/welfare/statistics/exportSelectedUnionUser", params)
} }
} }
} }
@@ -136,6 +136,16 @@ layout("/layouts/platform.html"){
<el-button :disabled="!pageForm.projectId" @click="openExport" icon="el-icon-download" size="small" type="primary"> <el-button :disabled="!pageForm.projectId" @click="openExport" icon="el-icon-download" size="small" type="primary">
导出名单 导出名单
</el-button> </el-button>
<el-button
v-if="$auth.hasRole('SYSADMIN')"
:disabled="!pageForm.projectId"
@click="exportRepeatIdCard"
icon="el-icon-download"
size="small"
type="primary"
>
导出重复身份证号人员
</el-button>
<el-button :disabled="!pageForm.projectId" @click="openImport" icon="el-icon-document-add" size="small" type="primary"> <el-button :disabled="!pageForm.projectId" @click="openImport" icon="el-icon-document-add" size="small" type="primary">
导入名单 导入名单
</el-button> </el-button>
@@ -329,6 +339,10 @@ layout("/layouts/platform.html"){
exportAddress() { exportAddress() {
this.$downLoad("/platform/welfare/list/mange/exportAddress", this.pageForm) this.$downLoad("/platform/welfare/list/mange/exportAddress", this.pageForm)
}, },
// 导出重复身份证号人员
exportRepeatIdCard() {
this.$downLoad("/platform/welfare/list/mange/exportRepeatIdCard", this.pageForm)
},
// 导出名单 // 导出名单
openExport() { openExport() {
const pageForm = clone(this.pageForm) const pageForm = clone(this.pageForm)
@@ -145,10 +145,7 @@ layout("/layouts/platform_h5.html"){
this.infoVisible = true this.infoVisible = true
}, },
onApply(row) { onApply(row) {
if(this.$store.state.user.loginname !== '45066' && this.$moment().isBefore(this.$moment(row.activitySignUpStartTime))) { // 报名前允许进入课程列表维护预填信息,预填不会提前占用名额。
this.onView(row)
return
}
this.$pjaxReplace('/platform/family/apply/list/h5?id=' + row.id) this.$pjaxReplace('/platform/family/apply/list/h5?id=' + row.id)
}, },
onReady() { onReady() {
@@ -3,7 +3,9 @@ const applyForm = {
/*language=HTML*/ /*language=HTML*/
` `
<div> <div>
<van-action-sheet :close-on-click-overlay="false" title="报名信息" v-model="visible"> <van-action-sheet :close-on-click-overlay="false"
:title="prefillMode ? (formData.id ? '修改预填信息' : '预填报名信息') : (editMode ? '修改报名' : '报名信息')"
v-model="visible" @close="onFormClosed">
<van-form ref="formRef" class="form-container"> <van-form ref="formRef" class="form-container">
<van-cell-group title="活动信息" class="form-section"> <van-cell-group title="活动信息" class="form-section">
<van-field label="活动名称" readonly v-model="row.courseName"></van-field> <van-field label="活动名称" readonly v-model="row.courseName"></van-field>
@@ -16,22 +18,25 @@ const applyForm = {
<van-field label="所在单位" readonly v-model="formData.unitName"></van-field> <van-field label="所在单位" readonly v-model="formData.unitName"></van-field>
<van-field label="所属工会" readonly v-model="formData.unionName"></van-field> <van-field label="所属工会" readonly v-model="formData.unionName"></van-field>
<van-field label="性别" readonly v-model="formData.sex"></van-field> <van-field label="性别" readonly v-model="formData.sex"></van-field>
<van-field label="联系方式" required name="mobile" v-model="formData.mobile"
:rules="[{ required: true, message: '请填写联系方式' }]"
placeholder="请填写联系方式"></van-field>
<template v-if="row.courseIsLimitApply"> <template v-if="row.courseIsLimitApply">
<van-field label="报名时段" <van-field label="报名时段"
required required
:rules="[{ required: true, message: '请选择报名时段' }]" :rules="[{ required: true, message: '请选择报名时段' }]"
readonly readonly
@click="showCoursePicker = true" @click="openCoursePicker"
placeholder="请选择报名时段" placeholder="请选择报名时段"
name="courseTimeName" name="courseTimeName"
v-model="formData.courseTimeName"> v-model="formData.courseTimeName">
</van-field> </van-field>
<van-popup v-model="showCoursePicker" position="bottom"> <van-popup v-model="showCoursePicker" position="bottom" @close="onCoursePickerClosed">
<van-picker <van-picker
show-toolbar show-toolbar
:columns="courseTimeSelectList" :columns="courseTimeSelectList"
@confirm="onCourseConfirm" @confirm="onCourseConfirm"
@cancel="showCoursePicker=false" @cancel="closeCoursePicker"
></van-picker> ></van-picker>
</van-popup> </van-popup>
</template> </template>
@@ -65,7 +70,7 @@ const applyForm = {
</van-cell-group> </van-cell-group>
<div class="button"> <div class="button">
<van-button @click="onSubmit" type="info" block>提交</van-button> <van-button :loading="formLoading" @click="onSubmit" type="info" block>{{prefillMode ? '保存预填' : '提交'}}</van-button>
</div> </div>
</van-form> </van-form>
</van-action-sheet> </van-action-sheet>
@@ -77,19 +82,33 @@ const applyForm = {
row: {}, row: {},
activity: {}, activity: {},
visible: false, visible: false,
editMode: false,
prefillMode: false,
formLoading: false,
formData: {}, formData: {},
showCoursePicker: false, showCoursePicker: false,
courseTimeSelectList: [], courseTimeSelectList: [],
courseType: {}, courseType: {},
historyOwner: '',
} }
}, },
components: { components: {
"train-dynamic-form": httpVueLoader("/components/module/trainSignUp/TrainDynamicForm.vue") "train-dynamic-form": httpVueLoader("/components/module/trainSignUp/TrainDynamicForm.vue")
}, },
mounted() {
this.historyOwner = "family-apply-form-" + this._uid
},
beforeDestroy() {
h5PopupHistory.unregisterOwner(this.historyOwner)
},
methods: { methods: {
addFamily() { addFamily() {
if(this.formData.mobileColumnsValue.length >= this.activity.familyMaxCount) { if(this.courseType.familyMaxCount === null || this.courseType.familyMaxCount === undefined || this.courseType.familyMaxCount === '') {
this.$toast(this.activity.keyWord + '最多人数为' + this.activity.familyMaxCount) this.$toast('当前子活动类型未配置家属最多人数')
return
}
if(this.formData.mobileColumnsValue.length >= Number(this.courseType.familyMaxCount)) {
this.$toast(this.activity.keyWord + '最多人数为' + this.courseType.familyMaxCount)
return return
} }
const list = clone(this.courseType.familyMobileSignColumnList) const list = clone(this.courseType.familyMobileSignColumnList)
@@ -100,40 +119,110 @@ const applyForm = {
const ac = (Number(this.familyActive) - 1) const ac = (Number(this.familyActive) - 1)
this.familyActive = '' + (ac >= 0 ? ac : 0) this.familyActive = '' + (ac >= 0 ? ac : 0)
}, },
async onOpen(row, courseType, activity) { onOpen(row, courseType, activity, signUpData, mode) {
this.row = row this.prefillMode = mode === "prefill"
this.courseType = courseType this.$set(this, "row", row)
this.activity = activity this.$set(this, "courseType", courseType)
this.$set(this, "activity", activity)
this.init(row, courseType) this.init(row, courseType, signUpData)
if(row.courseIsLimitApply) { const openForm = () => {
await this.getCourseTimeSelectList(row) h5PopupHistory.open(this.historyOwner + "-form", this.historyOwner, () => {
this.$set(this, "visible", false)
})
this.$set(this, "visible", true)
} }
this.visible = true if(row.courseIsLimitApply) {
this.getCourseTimeSelectList(row, this.editMode && signUpData ? signUpData.id : "")
.then((resp) => {
if (resp.code !== 0) return
this.setCourseTimeName(signUpData ? signUpData.activityCourseId : "")
openForm()
})
return
}
openForm()
}, },
init(row, courseType) { init(row, courseType, signUpData) {
this.$set(this.formData, 'username', this.$store.state.user.username) this.editMode = !this.prefillMode && Boolean(signUpData && signUpData.id)
this.$set(this.formData, 'loginname', this.$store.state.user.loginname) this.$set(this, "familyActive", "")
this.$set(this.formData, 'unionName', this.$store.state.user.union.name) this.$set(this, "courseTimeSelectList", [])
this.$set(this.formData, 'unitName', this.$store.state.user.unit.name) this.$set(this, "showCoursePicker", false)
this.$set(this.formData, 'sex', this.$store.state.user.sex) const formData = {
this.$set(this.formData, 'activityId', row.activityId) username: this.$store.state.user.username,
this.$set(this.formData, 'courseId', row.id) loginname: this.$store.state.user.loginname,
this.$set(this.formData, 'mobileColumnsValue', []) unionName: this.$store.state.user.union.name,
unitName: this.$store.state.user.unit.name,
sex: this.$store.state.user.sex,
mobile: this.$store.state.user.mobile,
activityId: row.activityId,
courseId: row.id,
mobileColumnsValue: []
}
if ((this.editMode || this.prefillMode) && signUpData) {
this.$set(formData, "id", signUpData.id)
this.$set(formData, "mobile", signUpData.mobile)
this.$set(formData, "activityCourseId", signUpData.activityCourseId)
this.$set(formData, "mobileColumnsValue",
this.mergeMobileColumnsValue(courseType, signUpData.mobileColumnsValue))
}
this.$set(this, "formData", formData)
}, },
onCourseConfirm(val){ onCourseConfirm(val){
this.formData.activityCourseId = val.value
this.$set(this.formData, "activityCourseId", val.value) this.$set(this.formData, "activityCourseId", val.value)
this.$set(this.formData, "courseTimeName", val.text.substring(0, 12)) this.$set(this.formData, "courseTimeName", val.text.substring(0, 12))
this.showCoursePicker = false this.closeCoursePicker()
}, },
async getCourseTimeSelectList(o) { getCourseTimeSelectList(o, familyUserId) {
const resp = await this.$axios.post('/platform/family/apply/getCourseTimeSelectList',{courseId: o.id}) const url = familyUserId
if (resp.code === 0) { ? "/platform/family/apply/getEditCourseTimeSelectList"
this.courseTimeSelectList = resp.data : "/platform/family/apply/getCourseTimeSelectList"
} else { this.formLoading = true
this.$toast.fail("获取时段信息失败,请联系管理员") return this.$axios.post(url, {courseId: o.id, familyUserId: familyUserId})
} .then((resp) => {
if (resp.code === 0) {
this.$set(this, "courseTimeSelectList", resp.data)
} else {
this.$toast.fail(resp.msg || "获取时段信息失败,请联系管理员")
}
return resp
})
.finally(() => {
this.formLoading = false
})
},
// 打开时段选择器时增加一层历史,返回键只关闭当前选择器。
openCoursePicker() {
h5PopupHistory.open(this.historyOwner + "-course-picker", this.historyOwner, () => {
this.$set(this, "showCoursePicker", false)
})
this.$set(this, "showCoursePicker", true)
},
closeCoursePicker() {
h5PopupHistory.close(this.historyOwner + "-course-picker")
},
onCoursePickerClosed() {
h5PopupHistory.close(this.historyOwner + "-course-picker")
},
onFormClosed() {
h5PopupHistory.close(this.historyOwner + "-form")
},
setCourseTimeName(activityCourseId) {
if (!activityCourseId) return
const selected = this.courseTimeSelectList.find((item) => item.value === activityCourseId)
if (selected) this.$set(this.formData, "courseTimeName", selected.text.substring(0, 12))
},
// 修改时按字段编码合并历史值,继续使用活动当前配置的必填和格式规则。
mergeMobileColumnsValue(courseType, mobileColumnsValue) {
const columnConfig = courseType && Array.isArray(courseType.familyMobileSignColumnList)
? courseType.familyMobileSignColumnList : []
return (mobileColumnsValue || []).map((family) => {
if (columnConfig.length === 0) return clone(family)
return clone(columnConfig).map((column) => {
const oldColumn = family.find((item) => item.columnCode === column.columnCode)
this.$set(column, "columnValue", oldColumn ? oldColumn.columnValue : "")
return column
})
})
}, },
validateIdCard(idCard) { validateIdCard(idCard) {
if (!idCard) { if (!idCard) {
@@ -266,62 +355,88 @@ const applyForm = {
return { age, sex, }; return { age, sex, };
}, },
async validateSignUp() { validateSignUp() {
// 获取家属人数 // 获取家属人数
const res = await this.$axios.post("/platform/family/apply/validateSignUp", { return this.$axios.post("/platform/family/apply/validateSignUp", {
courseId: this.formData.courseId, courseId: this.formData.courseId,
currentFamilyNumber: this.formData.mobileColumnsValue.length || 0 currentFamilyNumber: this.formData.mobileColumnsValue.length || 0
}) })
if(res.code !== 0) { .then((res) => {
this.$dialog.alert({title: '温馨提示', message: res.msg}) if(res.code !== 0) {
} this.$dialog.alert({title: '温馨提示', message: res.msg})
return res.code === 0 }
return res.code === 0
})
}, },
async validateCourseTime() { validateUpdateSignUp() {
const res = await this.$axios.post('/platform/family/apply/validateSourceSignUp', { return this.$axios.post("/platform/family/apply/validateUpdateSignUp", {
courseId: this.formData.courseId
}).then((res) => {
if(res.code !== 0) {
this.$toast.fail(res.msg)
}
return res.code === 0
})
},
validateCourseTime() {
return this.$axios.post('/platform/family/apply/validateSourceSignUp', {
activityCourseId: this.formData.activityCourseId, activityCourseId: this.formData.activityCourseId,
courseId: this.row.id courseId: this.row.id
}) })
if(res.code !== 0) { .then((res) => {
this.$dialog.alert({title: '温馨提示', message: res.msg}) if(res.code !== 0) {
}
return res.code === 0
},
async onSubmit() {
if (!this.validFamilyForm()) return
if (!await this.validateSignUp()) return
this.$refs.formRef.validate().then(() => {
this.$dialog.confirm({
title: "提示",
message: "您确定要提交吗?"
}).then(async () => {
if (this.row.courseIsLimitApply) {
if(!await this.validateCourseTime()) return
}
const formData = clone(this.formData)
let array = []
formData.mobileColumnsValue.forEach(item => {
const o = item.map((v) => {
return {
columnName: v.columnName,
columnValue: v.columnValue,
columnCode: v.columnCode,
columnFormType: v.columnFormType
}
})
array.push(o)
})
formData.mobileColumnsValue = JSON.stringify(clone(array))
this.$axios.post("/platform/family/apply/doSignUp", formData).then(res => {
this.$dialog.alert({title: '温馨提示', message: res.msg}) this.$dialog.alert({title: '温馨提示', message: res.msg})
.then(() => { }
if (res.code === 0) { return res.code === 0
this.visible = false })
this.$emit('refresh') },
} onSubmit() {
if (!this.validFamilyForm()) return
const eligibility = this.prefillMode
? Promise.resolve(true)
: (this.editMode ? this.validateUpdateSignUp() : this.validateSignUp())
eligibility.then((eligible) => {
if (!eligible) return
this.$refs.formRef.validate().then(() => {
this.$dialog.confirm({
title: "提示",
message: "您确定要提交吗?"
}).then(() => {
const courseTimeValidation = !this.editMode && !this.prefillMode && this.row.courseIsLimitApply
? this.validateCourseTime() : Promise.resolve(true)
courseTimeValidation.then((courseTimeValid) => {
if (!courseTimeValid) return
const formData = clone(this.formData)
const array = formData.mobileColumnsValue.map((item) => {
return item.map((column) => {
return {
columnName: column.columnName,
columnValue: column.columnValue,
columnCode: column.columnCode,
columnFormType: column.columnFormType
}
})
}) })
formData.mobileColumnsValue = JSON.stringify(clone(array))
const url = this.prefillMode
? "/platform/family/apply/savePrefill"
: (this.editMode
? "/platform/family/apply/updateSignUp"
: "/platform/family/apply/doSignUp")
this.formLoading = true
this.$axios.post(url, formData)
.then((res) => {
this.$dialog.alert({title: "温馨提示", message: res.msg})
.then(() => {
if (res.code !== 0) return
h5PopupHistory.clearOwner(this.historyOwner)
this.$emit("refresh")
})
})
.finally(() => {
this.formLoading = false
})
})
}) })
}) })
}) })
@@ -71,10 +71,22 @@ layout("/layouts/platform_h5.html"){
<span>微信群二维码</span> <span>微信群二维码</span>
</div> </div>
<template v-if="$moment().isBefore($moment(activity.activitySignUpEndTime))"> <template v-if="$moment().isBefore($moment(activity.activitySignUpEndTime))">
<div v-if="row.isSign === false" class="action-btn" @click="onApply(row)"> <div v-if="row.isSign === false && isBeforeSignUpTime()" class="action-btn" @click="onPrefill(row)">
<i class="fa fa-edit"></i>
<span>{{row.hasPrefill ? '修改预填信息' : '预填报名信息'}}</span>
</div>
<div v-if="row.isSign === false && isInSignUpTime() && row.hasPrefill" class="action-btn" @click="onQuickSignUp(row)">
<i class="fa fa-bolt"></i>
<span>立即报名</span>
</div>
<div v-if="row.isSign === false && isInSignUpTime() && !row.hasPrefill" class="action-btn" @click="onApply(row)">
<i class="fa fa-sign-in"></i> <i class="fa fa-sign-in"></i>
<span>我要报名</span> <span>我要报名</span>
</div> </div>
<div v-if="row.isSign === true && isInSignUpTime()" class="action-btn" @click="onEdit(row)">
<i class="fa fa-edit"></i>
<span>修改报名</span>
</div>
<div v-if="row.isSign === true" class="action-btn delete" @click="onCancel(row)"> <div v-if="row.isSign === true" class="action-btn delete" @click="onCancel(row)">
<i class="fa fa-trash"></i> <i class="fa fa-trash"></i>
<span>取消报名</span> <span>取消报名</span>
@@ -129,6 +141,8 @@ layout("/layouts/platform_h5.html"){
introduceRow: {}, introduceRow: {},
activity: {}, activity: {},
nowTime: Date.now(),
signTimeTimer: null,
weekdayCNMap: {0: '周日', 1: '周一', 2: '周二', 3: '周三', 4: '周四', 5: '周五', 6: '周六'}, weekdayCNMap: {0: '周日', 1: '周一', 2: '周二', 3: '周三', 4: '周四', 5: '周五', 6: '周六'},
} }
}, },
@@ -136,16 +150,58 @@ layout("/layouts/platform_h5.html"){
refresh() { refresh() {
this.doSearch() this.doSearch()
}, },
async onTime(row) { // 预填信息只能在报名开始时间之前维护。
isBeforeSignUpTime() {
return this.activity.activitySignUpStartTime
&& this.$moment(this.nowTime).isBefore(this.$moment(this.activity.activitySignUpStartTime))
},
onTime(row) {
// 如果设置签到,并且也报名的话 // 如果设置签到,并且也报名的话
if(row.isMobileSign === true && row.isSign === true) { if(row.isMobileSign === true && row.isSign === true) {
const res = await this.$axios.post('/platform/family/mine/queryCourseSign', { this.$axios.post('/platform/family/mine/queryCourseSign', {
courseId: row.id courseId: row.id
}) })
row.courseTimes = res.data .then((res) => {
this.$set(row, "courseTimes", res.data)
this.$refs.timesRef.onOpen(row)
})
return
} }
this.$refs.timesRef.onOpen(row) this.$refs.timesRef.onOpen(row)
}, },
// 修改报名按钮与报名接口使用同一时间边界:开始时间已到且尚未达到结束时间。
isInSignUpTime() {
const now = this.$moment(this.nowTime)
return this.activity.activitySignUpStartTime
&& this.activity.activitySignUpEndTime
&& !now.isBefore(this.$moment(this.activity.activitySignUpStartTime))
&& now.isBefore(this.$moment(this.activity.activitySignUpEndTime))
},
onPrefill(row) {
const courseType = this.sourceTypeOptions.find((item) => item.id === row.courseType)
this.$axios.post("/platform/family/apply/getPrefill", {courseId: row.id})
.then((resp) => {
if (resp.code !== 0) {
this.$toast.fail(resp.msg)
return
}
this.$refs.formRef.onOpen(row, courseType, this.activity, resp.data, "prefill")
})
},
onQuickSignUp(row) {
this.$dialog.confirm({
title: "提示",
message: "将使用报名开始前保存的预填信息直接报名,是否继续?"
}).then(() => {
this.$axios.post("/platform/family/apply/quickSignUp", {courseId: row.id})
.then((resp) => {
this.$dialog.alert({title: "温馨提示", message: resp.msg})
.then(() => {
if (resp.code === 0) this.doSearch()
})
})
}).catch(() => {})
},
onApply(row) { onApply(row) {
const courseType = this.sourceTypeOptions.find((v) => v.id === row.courseType) const courseType = this.sourceTypeOptions.find((v) => v.id === row.courseType)
this.$axios.post('/platform/family/apply/validateSignUp', { this.$axios.post('/platform/family/apply/validateSignUp', {
@@ -171,20 +227,38 @@ layout("/layouts/platform_h5.html"){
} }
}) })
}, },
onEdit(row) {
const courseType = this.sourceTypeOptions.find((item) => item.id === row.courseType)
this.$axios.post("/platform/family/apply/validateUpdateSignUp", {courseId: row.id})
.then((validateRes) => {
if (validateRes.code !== 0) {
this.$toast.fail(validateRes.msg)
return null
}
return this.$axios.post("/platform/family/apply/getMySignUp", {courseId: row.id})
})
.then((signUpRes) => {
if (!signUpRes || signUpRes.code !== 0) {
if (signUpRes) this.$toast.fail(signUpRes.msg)
return
}
this.$refs.formRef.onOpen(row, courseType, this.activity, signUpRes.data)
})
},
onCancel(row) { onCancel(row) {
vant.Dialog.confirm({ vant.Dialog.confirm({
title: '温馨提示', title: '温馨提示',
message: "您确定要<span style='color: red'>取消【" + row.courseName + "】</span>吗?", message: "您确定要<span style='color: red'>取消【" + row.courseName + "】</span>吗?",
confirmButtonColor: '#1867b0', confirmButtonColor: '#1867b0',
}).then(async () => { }).then(() => {
const resp = await this.$axios.post('/platform/family/apply/cancelSignUp', { this.$axios.post('/platform/family/apply/cancelSignUp', {
activityId: row.activityId, activityId: row.activityId,
courseId: row.id courseId: row.id
}) })
this.$toast(resp.msg) .then((resp) => {
if (resp.code === 0) { this.$toast(resp.msg)
this.doSearch() if (resp.code === 0) this.doSearch()
} })
}).catch(() => {}) }).catch(() => {})
}, },
calcSignUpCount(row) { calcSignUpCount(row) {
@@ -200,30 +274,31 @@ layout("/layouts/platform_h5.html"){
return "<span style='color: red'>余" + lave +"</span>/" + row.coursePeopleNumber + "人" return "<span style='color: red'>余" + lave +"</span>/" + row.coursePeopleNumber + "人"
} }
}, },
async onReady() { onReady() {
const typeList = await this.getCourseTypeList() this.getCourseTypeList().then((typeList) => {
this.sourceTypeOptions = clone(typeList) this.$set(this, "sourceTypeOptions", clone(typeList))
this.typeOptions = [ this.$set(this, "typeOptions", [
{ {
text: "全部类型", text: "全部类型",
value: null value: null
}
].concat(typeList.map((item) => ({text: item.typeName, value: item.id}))))
if (this.typeOptions.length > 0) {
this.$set(this.pageForm, "type", this.typeOptions[0].value)
this.doSearch()
} }
].concat(typeList.map((v) => ({ text: v.typeName, value: v.id }))) this.fetchActivity()
if (this.typeOptions.length > 0) { })
this.pageForm.type = this.typeOptions[0].value
this.doSearch()
}
this.fetchActivity()
}, },
fetchActivity() { fetchActivity() {
this.$axios.post('/platform/family/manage/findOne', {id: this.pageForm.activityId}) this.$axios.post('/platform/family/manage/findOne', {id: this.pageForm.activityId})
.then((res) => { .then((res) => {
this.activity = res.data this.$set(this, "activity", res.data)
}) })
}, },
async getCourseTypeList() { getCourseTypeList() {
const resp = await this.$axios.post("/platform/family/type/getAllType") return this.$axios.post("/platform/family/type/getAllType")
return resp.data .then((resp) => resp.data)
}, },
doSearch() { doSearch() {
this.$nextTick(() => { this.$nextTick(() => {
@@ -233,6 +308,15 @@ layout("/layouts/platform_h5.html"){
}) })
} }
}, },
created() {
// 页面停留到报名开始时间时自动切换按钮,保证预填信息按时冻结。
this.signTimeTimer = window.setInterval(() => {
this.nowTime = Date.now()
}, 1000)
},
beforeDestroy() {
if (this.signTimeTimer) window.clearInterval(this.signTimeTimer)
},
}) })
</script> </script>
@@ -203,7 +203,7 @@ layout("/layouts/platform_h5.html"){
<!-- 提交按钮 --> <!-- 提交按钮 -->
<div class="submit-btn"> <div class="submit-btn">
<van-button round block type="info">提交申请</van-button> <van-button round block type="info" :disabled="formLoading">提交申请</van-button>
</div> </div>
</van-form> </van-form>
<van-popup :style="{ height: '100%',width: '100%'}" position="right" safe-area-inset-bottom v-model="viewShow"> <van-popup :style="{ height: '100%',width: '100%'}" position="right" safe-area-inset-bottom v-model="viewShow">
@@ -227,6 +227,7 @@ layout("/layouts/platform_h5.html"){
data() { data() {
return { return {
viewShow: false, viewShow: false,
formLoading: false,
formData: { formData: {
clubId: "", clubId: "",
clubName: "", clubName: "",
@@ -284,6 +285,22 @@ layout("/layouts/platform_h5.html"){
}); });
}, },
init() { init() {
const id = GetQueryString("id");
if (id) {
this.formLoading = true;
this.$axios.post("/platform/club/join/apply/info", { id }).then(res => {
if (res.code === 0 && res.data) {
this.formData = { ...this.formData, ...res.data };
this.isAgree = true;
this.formLoading = false;
} else {
this.$toast.fail(res.msg || "申请信息加载失败");
}
}).catch(() => {
this.$toast.fail("申请信息加载失败");
});
return;
}
const user = this.$store.state.user; const user = this.$store.state.user;
this.formData.userId = user.id; this.formData.userId = user.id;
this.formData.userName = user.username; this.formData.userName = user.username;
@@ -298,6 +315,7 @@ layout("/layouts/platform_h5.html"){
this.formData.governmentPosition = user.governmentPosition; this.formData.governmentPosition = user.governmentPosition;
}, },
doSubmit() { doSubmit() {
if (this.formLoading) return;
if (!this.isAgree) { if (!this.isAgree) {
this.$toast.fail("请先阅读并同意协议"); this.$toast.fail("请先阅读并同意协议");
return; return;
@@ -309,7 +327,9 @@ layout("/layouts/platform_h5.html"){
this.$axios.post("/platform/club/join/apply/submit", this.formData).then(res => { this.$axios.post("/platform/club/join/apply/submit", this.formData).then(res => {
if (res.code === 0) { if (res.code === 0) {
this.$toast(res.msg); this.$toast(res.msg);
this.$pjaxReplace("/platform/club/join/mine") this.$pjaxReplace("/platform/h5/club/mine")
} else {
this.$toast.fail(res.msg);
} }
}); });
}); });
@@ -11,23 +11,17 @@ layout("/layouts/platform_h5.html"){
<div id="app" v-cloak> <div id="app" v-cloak>
<van-sticky> <van-sticky>
<van-nav-bar title="入会审核" left-text="返回" left-arrow @click-left="$pjaxReplace('/platform/h5/home')"></van-nav-bar> <van-nav-bar title="入会审核" left-text="返回" left-arrow @click-left="$pjaxReplace('/platform/h5/home')"></van-nav-bar>
<van-search <van-search v-model="pageForm.loginName" placeholder="请输入工号查询" @search="doSearch" @clear="doSearch"></van-search>
v-model="pageForm.searchKeyword"
:show-action="showAction"
placeholder="请输入姓名/工号查询"
@search="doSearch"
@cancel="doSearch"
></van-search>
<van-dropdown-menu> <van-dropdown-menu>
<van-dropdown-item v-model="pageForm.applyType" :options="applyTypeOptions" @change="doSearch"></van-dropdown-item> <van-dropdown-item v-model="pageForm.year" :options="yearList" @change="doSearch"></van-dropdown-item>
<van-dropdown-item v-model="pageForm.auditType" :options="auditTypeOptions" @change="doSearch"></van-dropdown-item> <van-dropdown-item v-model="pageForm.auditType" :options="auditTypeOptions" @change="doSearch"></van-dropdown-item>
</van-dropdown-menu> </van-dropdown-menu>
</van-sticky> </van-sticky>
<div> <div>
<van-list v-if="tableData.length>0" v-model="tableLoading" :finished="tableFinished" finished-text="没有更多了" @load="pageData"> <van-list v-if="tableData.length>0 || tableLoading" v-model="tableLoading" :immediate-check="false" :finished="tableFinished" loading-text="加载中..." finished-text="没有更多了" @load="pageData">
<div class="table-list"> <div class="table-list">
<div class="table-card" v-for="row in tableData" :key="row.id"> <div class="table-card" v-for="row in tableData" :key="row.processInstanceTaskId">
<van-cell> <van-cell>
<template slot="title"> <template slot="title">
<div class="flex-align"> <div class="flex-align">
@@ -39,23 +33,16 @@ layout("/layouts/platform_h5.html"){
<van-cell title="工号" :value="row.loginName"></van-cell> <van-cell title="工号" :value="row.loginName"></van-cell>
<van-cell title="性别" :value="row.sex"></van-cell> <van-cell title="性别" :value="row.sex"></van-cell>
<van-cell title="所属工会" :value="row.unionName"></van-cell> <van-cell title="所属工会" :value="row.unionName"></van-cell>
<van-cell v-if="pageForm.applyType === 1 && row.status" title="入会状态"> <van-cell title="申请模式" :value="row.mode ? '加入' : '退出'"></van-cell>
<span v-if="row.status == 1">待协会审核</span> <van-cell title="当前节点" :value="row.processInstanceNodeName"></van-cell>
<span v-else-if="row.status == 2">协会不通过</span> <van-cell title="审核状态" :value="row.processInstanceTaskStatus === 'ACTIVE' ? '待审核' : '已审核'"></van-cell>
<span v-else-if="row.status == 3">待校工会审核</span> <van-cell v-if="row.processInstanceTaskStatus === 'ACTIVE'" class="table-card-footer">
<span v-else-if="row.status == 4">校工会不通过</span> <van-button size="small" @click="openAudit(row, 'BACK')" type="danger">退回重新申请</van-button>
<span v-else-if="row.status == 5">审核通过</span> <van-button size="small" @click="openAudit(row, 'REJECT')" type="danger">拒绝申请</van-button>
<van-button size="small" @click="openAudit(row, 'PASS')" type="info">同意申请</van-button>
</van-cell> </van-cell>
<van-cell v-if="pageForm.applyType === 2 && row.exitStatus" title="退会状态"> <van-cell v-if="row.processInstanceTaskStatus === 'COMPLETE' && !row.nextTaskIsComplete" class="table-card-footer">
<span v-if="row.exitStatus == 1">待协会审核</span> <van-button size="small" @click="openRevoke(row.processInstanceTaskId)" type="danger">撤回</van-button>
<span v-else-if="row.exitStatus == 2">协会不通过</span>
<span v-else-if="row.exitStatus == 3">待校工会审核</span>
<span v-else-if="row.exitStatus == 4">校工会不通过</span>
<span v-else-if="row.exitStatus == 5">审核通过</span>
</van-cell>
<van-cell v-if="pageForm.auditType !== 2 && (row.status === 1 || row.exitStatus === 1)" class="table-card-footer">
<van-button size="small" @click="openPayed(row, false)" type="danger">不通过</van-button>
<van-button size="small" @click="openPayed(row, true)" type="info">通过</van-button>
</van-cell> </van-cell>
</div> </div>
</div> </div>
@@ -63,17 +50,8 @@ layout("/layouts/platform_h5.html"){
<van-empty v-else image="/assets/platform/plugins/vant-green/images/nodata/nodata.png" description="暂无数据"></van-empty> <van-empty v-else image="/assets/platform/plugins/vant-green/images/nodata/nodata.png" description="暂无数据"></van-empty>
</div> </div>
<van-dialog v-model="payedShow" title="是否缴费" show-cancel-button :lazy-render="false" @confirm="doAudit"> <van-dialog v-model="auditShow" title="审核意见" show-cancel-button get-container="body" :overlay="true" :overlay-style="{ backgroundColor: 'rgba(0, 0, 0, 0.5)' }" :lazy-render="false" :before-close="beforeAuditClose">
<van-radio-group v-model="payed"> <van-field v-model="approvalOpinion" type="textarea" placeholder="请输入审核意见" :error-message="approvalOpinionError" @input="approvalOpinionError = ''"></van-field>
<van-cell-group>
<van-cell title="已缴费" clickable @click="payed = true">
<van-radio slot="right-icon" :name="true"></van-radio>
</van-cell>
<van-cell title="未缴费" clickable @click="payed = false">
<van-radio slot="right-icon" :name="false"></van-radio>
</van-cell>
</van-cell-group>
</van-radio-group>
</van-dialog> </van-dialog>
</div> </div>
@@ -84,19 +62,21 @@ layout("/layouts/platform_h5.html"){
components: {}, components: {},
data() { data() {
return { return {
showAction: true, auditShow: false,
payedShow: false, approvalOpinion: "",
payed: false, approvalOpinionError: "",
tableLoading: false,
tableFinished: false,
submitting: false,
pageForm: { pageForm: {
pageNumber: 1, pageNumber: 1,
pageSize: 10, pageSize: 10,
totalCount: 0, totalCount: 0,
searchKeyword: "" loginName: "",
year: new Date().getFullYear(),
auditType: 3
}, },
applyTypeOptions: [ yearList: [],
{ text: "申请加入", value: 1 },
{ text: "申请退会", value: 2 }
],
auditTypeOptions: [ auditTypeOptions: [
{ text: "全部", value: 1 }, { text: "全部", value: 1 },
{ text: "已审核", value: 2 }, { text: "已审核", value: 2 },
@@ -104,54 +84,83 @@ layout("/layouts/platform_h5.html"){
], ],
typeOptions: [], typeOptions: [],
tableData: [], tableData: [],
auditResult: false, approvalType: "",
auditIdList: [] approvalParam: {}
} }
}, },
methods: { methods: {
openPayed(row, flag) { openAudit(row, approvalType) {
this.auditResult = flag this.approvalType = approvalType
this.auditIdList = [row.id] this.approvalParam = row.approvalParam
if (this.pageForm.applyType === 1) { this.approvalOpinion = ""
this.payedShow = true this.approvalOpinionError = ""
} else { this.auditShow = true
this.doAudit() },
async beforeAuditClose(action, done) {
if (action !== 'confirm') {
done()
return
} }
if (!this.approvalOpinion.trim()) {
this.approvalOpinionError = "请输入审核意见"
done(false)
return
}
done(await this.doAudit() === true)
}, },
async doAudit() { async doAudit() {
const confirm = await this.$dialog.confirm({ if (this.submitting) return
title: "提示", this.submitting = true
message: "您确定要提交审核吗?" try {
}) const resp = await this.$axios.post("/platform/h5/club/audit/doAudit", {
if (confirm === "confirm") { approval: JSON.stringify({
const resp = await this.$axios.post("/platform/club/applyJoin/clubAudit/doAudit", { ...this.approvalParam,
ids: JSON.stringify(this.auditIdList), bpmTaskApprovalType: this.approvalType,
auditResult: this.auditResult, approvalOpinion: this.approvalOpinion
auditType: 1, })
applyType: this.pageForm.applyType,
payed: this.payed
}) })
if (resp.code === 0) { if (resp.code === 0) {
this.$toast.success(resp.msg) this.$toast.success(resp.msg)
this.auditIdList = [] this.doSearch()
this.payedShow = false return true
} else {
this.$toast(resp.msg)
}
} catch (error) {
this.$toast.fail("审核提交失败")
} finally {
this.submitting = false
}
},
openRevoke(taskId) {
this.$dialog.confirm({ title: "提示", message: "您确定要撤回吗?" }).then(async () => {
const resp = await this.$axios.post("/platform/h5/club/audit/revoke", { taskId })
if (resp.code === 0) {
this.$toast.success(resp.msg)
this.doSearch() this.doSearch()
} else { } else {
this.$toast(resp.msg) this.$toast(resp.msg)
} }
} }).catch(() => {})
}, },
pageData() { pageData() {
this.tableLoading = true this.tableLoading = true
this.pageForm.source = 1 this.$axios.post("/platform/h5/club/audit/pageData", this.pageForm).then((resp) => {
this.$axios.post("/platform/club/applyJoin/clubAudit/pageData", this.pageForm).then((resp) => { if (resp.code !== 0) {
this.tableFinished = true
this.$toast(resp.msg)
return
}
this.tableData = this.tableData.concat(resp.data.list) this.tableData = this.tableData.concat(resp.data.list)
this.pageForm.totalCount = resp.data.totalCount this.pageForm.totalCount = resp.data.totalCount
if (this.tableData.length >= this.pageForm.totalCount) { this.tableFinished = this.tableData.length >= this.pageForm.totalCount
this.tableFinished = true if (!this.tableFinished) {
} else {
this.pageForm.pageNumber++ this.pageForm.pageNumber++
} }
}).catch(() => {
this.tableFinished = true
this.$toast.fail("审核列表加载失败")
}).finally(() => {
this.tableLoading = false this.tableLoading = false
}) })
}, },
@@ -159,12 +168,14 @@ layout("/layouts/platform_h5.html"){
this.pageForm.pageNumber = 1 this.pageForm.pageNumber = 1
this.pageForm.totalCount = 0 this.pageForm.totalCount = 0
this.tableData = [] this.tableData = []
this.tableFinished = false
this.pageData() this.pageData()
} }
}, },
created() { created() {
this.$set(this.pageForm, "applyType", 1) for (let i = new Date().getFullYear() - 10; i <= new Date().getFullYear(); i++) {
this.$set(this.pageForm, "auditType", 3) this.yearList.unshift({ value: i, text: i + "年" })
}
this.pageData() this.pageData()
} }
}) })
@@ -29,6 +29,24 @@ const clubUserJoin = {
</template> </template>
</van-cell> </van-cell>
</van-cell-group> </van-cell-group>
<div v-for="nodeTask in viewData.nodeTasks" :key="nodeTask.id">
<van-cell-group :title="nodeTask.nodeName">
<div v-for="task in nodeTask.tasks" :key="task.id">
<van-cell title="审核人">{{ task.actualOwnerLoginName }}-{{ task.actualOwnerUserName }}</van-cell>
<van-cell title="审核时间">{{ task.endOn }}</van-cell>
<van-cell title="审核结果">
<template v-if="task.extVariable">
<van-tag type="success" v-if="task.extVariable.bpmTaskApprovalType === 'PASS'">同意</van-tag>
<van-tag type="danger" v-if="task.extVariable.bpmTaskApprovalType === 'BACK'">退回重新填写</van-tag>
<van-tag type="danger" v-if="task.extVariable.bpmTaskApprovalType === 'REJECT'">拒绝</van-tag>
</template>
</van-cell>
<van-cell title="审核意见">
<div style="white-space: pre-wrap; overflow-wrap: anywhere; text-align: right">{{ task.extVariable && task.extVariable.approvalOpinion }}</div>
</van-cell>
</div>
</van-cell-group>
</div>
</div> </div>
`, `,
data() { data() {
@@ -81,6 +81,9 @@ layout("/layouts/platform_h5.html"){
} }
}, },
methods: { methods: {
openEdit(row) {
this.$pjaxReplace("/platform/h5/club/apply?id=" + encodeURIComponent(row.id))
},
onOpen(row) { onOpen(row) {
this.viewShow = true this.viewShow = true
this.$nextTick(() => { this.$nextTick(() => {

Some files were not shown because too many files have changed in this diff Show More