Compare commits
28
Commits
7d5d2da60f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
743e6c5428 | ||
|
|
09e955fb38 | ||
|
|
7f578e1f9e | ||
|
|
d8f6298dd8 | ||
|
|
6b0586b638 | ||
|
|
15aef647d1 | ||
|
|
f3021514fd | ||
|
|
4856cd5ddb | ||
|
|
a09c6ae2c9 | ||
|
|
d197c78b9c | ||
|
|
dbca87f09c | ||
|
|
38b1a09e11 | ||
|
|
5cecb3b185 | ||
|
|
fc727c690a | ||
|
|
063e22491a | ||
|
|
48ebcc148d | ||
|
|
50f82e05e5 | ||
|
|
f700d0c6fb | ||
|
|
94fa74af53 | ||
|
|
d717330657 | ||
|
|
3c3e64f2be | ||
|
|
a18f77cd68 | ||
|
|
486934f3b6 | ||
|
|
e3eb5b639b | ||
|
|
f5e5720120 | ||
|
|
36d844a1e8 | ||
|
|
caf83bdbd9 | ||
|
|
e0ecdda400 |
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,7 +166,15 @@ 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").desc("updatedAt"));
|
List<Sys_home_activity> list = Daos.ext(dao, fieldFilter)
|
||||||
|
.query(
|
||||||
|
Sys_home_activity.class,
|
||||||
|
Cnd.where("enable", "=", 1)
|
||||||
|
.desc("top")
|
||||||
|
.desc("startDate")
|
||||||
|
.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) {
|
||||||
@@ -94,8 +97,8 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
|
|||||||
Map<String, Date> doctoralData = pullPostDoctoral();
|
Map<String, Date> doctoralData = pullPostDoctoral();
|
||||||
|
|
||||||
Date nowDate = new Date();
|
Date nowDate = new Date();
|
||||||
String appId = "1926887238437818370";
|
String appId = "XXXXXXXXXXXXXXXXXXXXX";
|
||||||
String secret = "32df31fcf4fc43f9a647ee1f47134f36";
|
String secret = "XXXXXXXXXXXXXXXXXXXX";
|
||||||
|
|
||||||
List<JSONObject> rawDataList = new ArrayList<>();
|
List<JSONObject> rawDataList = new ArrayList<>();
|
||||||
int skip = 0;
|
int skip = 0;
|
||||||
@@ -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) {
|
||||||
//判断是否要更新在职状态字典
|
//判断是否要更新在职状态字典
|
||||||
@@ -248,8 +280,8 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Date> pullPostDoctoral() {
|
public Map<String, Date> pullPostDoctoral() {
|
||||||
String appId = "1926887238437818370";
|
String appId = "XXXXXXXXXXXXXXXXXXXXXX";
|
||||||
String secret = "32df31fcf4fc43f9a647ee1f47134f36";
|
String secret = "XXXXXXXXXXXXXXXXXXXXXX";
|
||||||
|
|
||||||
List<JSONObject> allUsers = new ArrayList<>();
|
List<JSONObject> allUsers = new ArrayList<>();
|
||||||
int skip = 0;
|
int skip = 0;
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 级联删除单位
|
* 级联删除单位
|
||||||
*
|
*
|
||||||
|
|||||||
+1
-5
@@ -112,11 +112,7 @@ public class ActivityCultureUserStatisticsController {
|
|||||||
if (Strings.isNotBlank(page.getSearchName()) && Strings.isNotBlank(page.getSearchKeyword())) {
|
if (Strings.isNotBlank(page.getSearchName()) && Strings.isNotBlank(page.getSearchKeyword())) {
|
||||||
cnd.where().andLike(page.getSearchName(), page.getSearchKeyword());
|
cnd.where().andLike(page.getSearchName(), page.getSearchKeyword());
|
||||||
}
|
}
|
||||||
if (StrUtil.isNotBlank(page.getPageOrderName()) && StrUtil.isNotBlank(page.getPageOrderBy())) {
|
activityCultureApplyUserService.applyUserStatisticsSort(cnd, page);
|
||||||
cnd.orderBy(page.getPageOrderName(), page.getPageOrderBy().equalsIgnoreCase("ascending") ? "ASC" : "DESC");
|
|
||||||
} else {
|
|
||||||
cnd.desc("atp.applyDateTime");
|
|
||||||
}
|
|
||||||
sql.setCondition(cnd);
|
sql.setCondition(cnd);
|
||||||
Pagination pagination = activityCultureApplyUserService.listPageMap(page.getPageNumber(), page.getPageSize(), sql);
|
Pagination pagination = activityCultureApplyUserService.listPageMap(page.getPageNumber(), page.getPageSize(), sql);
|
||||||
List<NutMap> list = pagination.getList();
|
List<NutMap> list = pagination.getList();
|
||||||
|
|||||||
+9
@@ -6,6 +6,7 @@ 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.base.service.BaseService;
|
import com.budwk.app.base.service.BaseService;
|
||||||
import com.budwk.app.zhgh.activity.culture.models.ActivityTissuePerson;
|
import com.budwk.app.zhgh.activity.culture.models.ActivityTissuePerson;
|
||||||
|
import org.nutz.dao.Cnd;
|
||||||
import org.nutz.lang.util.NutMap;
|
import org.nutz.lang.util.NutMap;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -19,6 +20,14 @@ public interface ActivityCultureApplyUserService extends BaseService<ActivityTis
|
|||||||
boolean isEnrolled,
|
boolean isEnrolled,
|
||||||
Integer state);
|
Integer state);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置学校、工会和社团活动人员统计的分页顺序。
|
||||||
|
*
|
||||||
|
* @param cnd 查询条件;方法会在其上追加 ORDER BY
|
||||||
|
* @param page 分页参数;pageOrderName 和 pageOrderBy 均有值时采用用户选择的排序,否则按工号正序
|
||||||
|
*/
|
||||||
|
void applyUserStatisticsSort(Cnd cnd, PageForm page);
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 活动报名
|
* 活动报名
|
||||||
|
|||||||
+11
@@ -110,6 +110,17 @@ public class ActivityCultureApplyUserServiceImpl extends BaseServiceImpl<Activit
|
|||||||
return pagination;
|
return pagination;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void applyUserStatisticsSort(Cnd cnd, PageForm page) {
|
||||||
|
if (StrUtil.isNotBlank(page.getPageOrderName()) && StrUtil.isNotBlank(page.getPageOrderBy())) {
|
||||||
|
cnd.orderBy(page.getPageOrderName(), page.getPageOrderBy().equalsIgnoreCase("ascending") ? "ASC" : "DESC");
|
||||||
|
} else {
|
||||||
|
// 三类活动统计默认按工号正序;相同工号再按报名时间倒序保证分页顺序稳定。
|
||||||
|
cnd.asc("atp.loginName");
|
||||||
|
cnd.desc("atp.applyDateTime");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void signUp(String activityId, List<String> personIds, JSONObject dynamicFormParam) {
|
public void signUp(String activityId, List<String> personIds, JSONObject dynamicFormParam) {
|
||||||
ActivityTissue tissue = dao().fetch(ActivityTissue.class, activityId);
|
ActivityTissue tissue = dao().fetch(ActivityTissue.class, activityId);
|
||||||
|
|||||||
+7
-31
@@ -521,6 +521,12 @@ public class FamilyActivityApplyController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交当前登录教职工的报名,不接受客户端指定报名用户或已有报名ID。
|
||||||
|
* @param familyUser courseId为子活动ID,mobile为联系方式,mobileColumnsValue为家属动态字段二维列表,
|
||||||
|
* 独立报名课程还需activityCourseId时段ID;活动归属及用户由service确定
|
||||||
|
* @return Result JSON,code为0表示正常或候补报名成功,msg说明结果或未通过的规则
|
||||||
|
*/
|
||||||
@At
|
@At
|
||||||
@ApiOperation("活动报名")
|
@ApiOperation("活动报名")
|
||||||
@SaCheckPermission(value = {"family.apply", "h5.family.apply"}, mode = SaMode.OR)
|
@SaCheckPermission(value = {"family.apply", "h5.family.apply"}, mode = SaMode.OR)
|
||||||
@@ -529,37 +535,7 @@ public class FamilyActivityApplyController {
|
|||||||
public Result doSignUp(FamilyUser familyUser) {
|
public Result doSignUp(FamilyUser familyUser) {
|
||||||
try {
|
try {
|
||||||
lock.lock();
|
lock.lock();
|
||||||
FamilyActivity activity = dao.fetch(FamilyActivity.class, familyUser.getActivityId());
|
// 家属、名额和时间冲突由service在数据库锁及事务内统一校验,避免普通报名与快速报名规则分叉。
|
||||||
if(Lang.isEmpty(familyUser.getMobileColumnsValue())) {
|
|
||||||
return Result.error(99,"请填写" + activity.getKeyWord() + "信息");
|
|
||||||
}
|
|
||||||
boolean courseByUser = familyActivityService.isSignCourseByUser(familyUser.getCourseId(), SecurityUtil.getUserId());
|
|
||||||
if(courseByUser) {
|
|
||||||
return Result.error(99,"您已报过该活动");
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<Boolean, String> validFamilyCount = familyActivityService.validFamilyCount(familyUser);
|
|
||||||
if(validFamilyCount.containsKey(false)) {
|
|
||||||
return Result.error(99,validFamilyCount.get(false));
|
|
||||||
}
|
|
||||||
// 判断人数
|
|
||||||
int number = 0;
|
|
||||||
FamilyCourse course = familyActivityService.dao().fetch(FamilyCourse.class, familyUser.getCourseId());
|
|
||||||
FamilyType type = familyActivityService.dao().fetch(FamilyType.class, course.getCourseType());
|
|
||||||
if(type != null) {
|
|
||||||
if(type.getIsBringFamily() && type.getIsAddFamily()) {
|
|
||||||
List<List<NutMap>> mobileColumnsValue = familyUser.getMobileColumnsValue();
|
|
||||||
number = Lang.isNotEmpty(mobileColumnsValue) ? mobileColumnsValue.size() : 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
boolean signFull = familyActivityService.isSignFull(course, number);
|
|
||||||
if(signFull) {
|
|
||||||
return Result.error(99,"当前报名人数已满");
|
|
||||||
}
|
|
||||||
boolean signFullByUnionId = familyActivityService.isSignFullByUnionId(course, number);
|
|
||||||
if(signFullByUnionId) {
|
|
||||||
return Result.error(99,"该活动您所在的分工会名额不足");
|
|
||||||
}
|
|
||||||
FamilyUser finishUser = familyActivityService.doSignUp(familyUser);
|
FamilyUser finishUser = familyActivityService.doSignUp(familyUser);
|
||||||
if(finishUser.getState() == 2) {
|
if(finishUser.getState() == 2) {
|
||||||
return Result.success("正式报名已满,您已列为替补,如有老师放弃报名,将会按先后顺序依次替补。请您自行关注后续结果。谢谢!");
|
return Result.success("正式报名已满,您已列为替补,如有老师放弃报名,将会按先后顺序依次替补。请您自行关注后续结果。谢谢!");
|
||||||
|
|||||||
+9
@@ -13,6 +13,8 @@ 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.dao.Chain;
|
import org.nutz.dao.Chain;
|
||||||
|
import org.nutz.aop.interceptor.ioc.TransAop;
|
||||||
|
import org.nutz.ioc.aop.Aop;
|
||||||
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;
|
||||||
@@ -144,11 +146,18 @@ public class FamilyActivityController {
|
|||||||
return Result.success().addData(dataMap);
|
return Result.success().addData(dataMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存或发布活动配置,业务校验由service执行。
|
||||||
|
* @param activity 活动及子活动列表;familyCountScope为1按类型、2按活动,timeConflictCheck为重叠校验开关,
|
||||||
|
* onlyKey为各类型共有且必填的字段编码,familyMaxCount为活动累计上限
|
||||||
|
* @return Result JSON,code为0表示保存成功,配置不完整时返回具体业务错误
|
||||||
|
*/
|
||||||
@At
|
@At
|
||||||
@Ok("json:full")
|
@Ok("json:full")
|
||||||
@ApiOperation("亲子活动新增/修改")
|
@ApiOperation("亲子活动新增/修改")
|
||||||
@SaCheckPermission("family.manage")
|
@SaCheckPermission("family.manage")
|
||||||
@SLog(tag = "亲子活动-活动管理", msg = "新增/修改活动")
|
@SLog(tag = "亲子活动-活动管理", msg = "新增/修改活动")
|
||||||
|
@Aop(TransAop.READ_COMMITTED)
|
||||||
public Result doHandle(FamilyActivity activity) {
|
public Result doHandle(FamilyActivity activity) {
|
||||||
if (StrUtil.isBlank(activity.getId())) {
|
if (StrUtil.isBlank(activity.getId())) {
|
||||||
familyActivityManageService.add(activity, null);
|
familyActivityManageService.add(activity, null);
|
||||||
|
|||||||
+28
-42
@@ -1,8 +1,10 @@
|
|||||||
package com.budwk.app.zhgh.activity.family.controller.manage;
|
package com.budwk.app.zhgh.activity.family.controller.manage;
|
||||||
|
|
||||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
import cn.hutool.core.date.DateUtil;
|
|
||||||
import com.budwk.app.base.annotation.SLog;
|
import com.budwk.app.base.annotation.SLog;
|
||||||
|
import com.budwk.app.base.exception.BaseException;
|
||||||
|
import org.nutz.aop.interceptor.ioc.TransAop;
|
||||||
|
import org.nutz.ioc.aop.Aop;
|
||||||
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;
|
||||||
@@ -24,7 +26,6 @@ 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 java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,59 +119,44 @@ public class FamilyAdjustController {
|
|||||||
return Result.success(list);
|
return Result.success(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param activityId 当前活动ID
|
||||||
|
* @param oldCourseId 人员原报名子活动ID
|
||||||
|
* @param newCourseId 同活动内的目标子活动ID
|
||||||
|
* @param userId 被调整教职工ID,按该人员而非管理员校验家属和时间规则
|
||||||
|
* @return Result JSON;code为0表示调整成功,失败时msg说明原因且保留原报名
|
||||||
|
*/
|
||||||
@At
|
@At
|
||||||
@ApiOperation("人员调整")
|
@ApiOperation("人员调整")
|
||||||
@SaCheckPermission("family.adjust")
|
@SaCheckPermission("family.adjust")
|
||||||
@SLog(tag = "亲子活动-人员调整", msg = "人员调整")
|
@SLog(tag = "亲子活动-人员调整", msg = "人员调整")
|
||||||
|
@Aop(TransAop.READ_COMMITTED)
|
||||||
public Result adjust(String activityId, String oldCourseId, String newCourseId, String userId) {
|
public Result adjust(String activityId, String oldCourseId, String newCourseId, String userId) {
|
||||||
|
try {
|
||||||
Cnd oldCnd = Cnd.where("activityId", "=", activityId)
|
familyActivityManageService.adjustSignUp(activityId, oldCourseId, newCourseId, userId);
|
||||||
.and("courseId", "=", oldCourseId).and("userId", "=", userId);
|
|
||||||
|
|
||||||
Cnd newCnd = Cnd.where("activityId", "=", activityId)
|
|
||||||
.and("courseId", "=", newCourseId).and("userId", "=", userId);
|
|
||||||
|
|
||||||
//旧的报名信息
|
|
||||||
FamilyUser oldfamilyUser = dao.fetch(FamilyUser.class, oldCnd);
|
|
||||||
oldfamilyUser.setCourseId(newCourseId);
|
|
||||||
oldfamilyUser.setSignUpTime(DateUtil.date());
|
|
||||||
dao.update(oldfamilyUser);
|
|
||||||
|
|
||||||
//新的课程的信息,上课时间
|
|
||||||
List<FamilyActivityCourse> activityCourseList = dao.query(FamilyActivityCourse.class, Cnd.where("activityId", "=", activityId)
|
|
||||||
.and("courseId", "=", newCourseId));
|
|
||||||
//先清楚旧的信息
|
|
||||||
dao.clear(FamilyUserCourse.class, oldCnd);
|
|
||||||
//添加新的信息
|
|
||||||
List<FamilyUserCourse> familyUserCourseList = new ArrayList<>();
|
|
||||||
activityCourseList.forEach(item -> {
|
|
||||||
FamilyUserCourse course = new FamilyUserCourse();
|
|
||||||
course.setActivityCourseId(activityId);
|
|
||||||
course.setCourseId(newCourseId);
|
|
||||||
course.setUserId(userId);
|
|
||||||
course.setCourseStartTime(item.getCourseStartTime());
|
|
||||||
course.setCourseEndTime(item.getCourseEndTime());
|
|
||||||
course.setActivityCourseId(item.getId());
|
|
||||||
familyUserCourseList.add(course);
|
|
||||||
});
|
|
||||||
dao.insert(familyUserCourseList);
|
|
||||||
return Result.success();
|
return Result.success();
|
||||||
|
} catch (BaseException e) {
|
||||||
|
return Result.error(99, e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param activityId 当前活动ID
|
||||||
|
* @param courseId 待删除报名所属子活动ID
|
||||||
|
* @param userId 被删除报名的教职工ID
|
||||||
|
* @return Result JSON;code为0表示对应报名及其时段记录已删除
|
||||||
|
*/
|
||||||
@At
|
@At
|
||||||
@ApiOperation("删除报名人员")
|
@ApiOperation("删除报名人员")
|
||||||
@SaCheckPermission("family.adjust")
|
@SaCheckPermission("family.adjust")
|
||||||
@SLog(tag = "亲子活动-人员调整", msg = "删除报名人员")
|
@SLog(tag = "亲子活动-人员调整", msg = "删除报名人员")
|
||||||
|
@Aop(TransAop.READ_COMMITTED)
|
||||||
public Result deleteSignUser(String activityId, String courseId, String userId) {
|
public Result deleteSignUser(String activityId, String courseId, String userId) {
|
||||||
|
try {
|
||||||
//删除
|
familyActivityManageService.deleteSignUser(activityId, courseId, userId);
|
||||||
Cnd cnd = Cnd.NEW();
|
|
||||||
cnd.and("activityId", "=", activityId);
|
|
||||||
cnd.and("courseId", "=", courseId);
|
|
||||||
cnd.and("userId", "=", userId);
|
|
||||||
|
|
||||||
dao.clear(FamilyUser.class, cnd);
|
|
||||||
dao.clear(FamilyUserCourse.class, cnd);
|
|
||||||
return Result.success();
|
return Result.success();
|
||||||
|
} catch (BaseException e) {
|
||||||
|
return Result.error(99, e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,18 @@ public class FamilyActivity extends BaseModel implements Serializable, SysHomeCo
|
|||||||
@ColDefine(type = ColType.INT)
|
@ColDefine(type = ColType.INT)
|
||||||
private Integer familyMaxCount;
|
private Integer familyMaxCount;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
@Comment("家属人数限制范围:1按子活动类型累计,2按整个活动累计;历史空值按1处理")
|
||||||
|
@ColDefine(type = ColType.INT)
|
||||||
|
@Default("1")
|
||||||
|
private Integer familyCountScope;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
@Comment("是否禁止同一教职工下同一家属报名本活动内时间重叠的子活动,含候补报名")
|
||||||
|
@ColDefine(type = ColType.BOOLEAN)
|
||||||
|
@Default("0")
|
||||||
|
private Boolean timeConflictCheck;
|
||||||
|
|
||||||
@Column
|
@Column
|
||||||
@Comment("判断家属数量的唯一标识")
|
@Comment("判断家属数量的唯一标识")
|
||||||
@ColDefine(type = ColType.VARCHAR, width = 50)
|
@ColDefine(type = ColType.VARCHAR, width = 50)
|
||||||
|
|||||||
+22
-2
@@ -76,6 +76,25 @@ public interface FamilyActivityService extends BaseService<FamilyActivity> {
|
|||||||
*/
|
*/
|
||||||
void cancelSignUp(String activityId, String courseId);
|
void cancelSignUp(String activityId, String courseId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台将指定教职工的正常报名调整到同一活动的另一子活动,复用家属及时间冲突规则。
|
||||||
|
* @param activityId 活动ID,原课程与目标课程必须属于该活动
|
||||||
|
* @param oldCourseId 原课程ID
|
||||||
|
* @param newCourseId 目标课程ID;独立时段课程须只有一个可确定的时段
|
||||||
|
* @param userId 被调整教职工的用户ID,不使用管理员身份参与统计
|
||||||
|
* @return 无返回值;不满足限制时抛出业务异常,原报名不变
|
||||||
|
*/
|
||||||
|
void adjustSignUp(String activityId, String oldCourseId, String newCourseId, String userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台删除指定教职工的报名,与报名和调班共用数据库锁,避免并发统计读取到中间状态。
|
||||||
|
* @param activityId 活动ID
|
||||||
|
* @param courseId 课程ID
|
||||||
|
* @param userId 被删除报名的教职工ID
|
||||||
|
* @return 无返回值;仅删除匹配的报名及其课程时段记录
|
||||||
|
*/
|
||||||
|
void deleteSignUser(String activityId, String courseId, String userId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询当前用户在指定课程中的预填信息。
|
* 查询当前用户在指定课程中的预填信息。
|
||||||
*
|
*
|
||||||
@@ -120,7 +139,8 @@ public interface FamilyActivityService extends BaseService<FamilyActivity> {
|
|||||||
FamilyUser getSignUpInfo(String courseId, String userId);
|
FamilyUser getSignUpInfo(String courseId, String userId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验当前用户是否可以修改指定课程的报名信息。
|
* 打开编辑表单前校验当前用户报名归属、报名时间和人员范围。
|
||||||
|
* 家属、人数和时间冲突在保存新数据时重新校验,允许历史冲突记录进入表单整改。
|
||||||
*
|
*
|
||||||
* @param courseId 课程ID;当前用户必须已经报名该课程
|
* @param courseId 课程ID;当前用户必须已经报名该课程
|
||||||
* @return 无返回值;校验不通过时抛出包含具体原因的业务异常
|
* @return 无返回值;校验不通过时抛出包含具体原因的业务异常
|
||||||
@@ -166,7 +186,7 @@ public interface FamilyActivityService extends BaseService<FamilyActivity> {
|
|||||||
boolean isSignFull(FamilyCourse course, Integer currentFamilyNumber);
|
boolean isSignFull(FamilyCourse course, Integer currentFamilyNumber);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验本次提交的家属数量、唯一标识及分场冲突。
|
* 校验本次提交的类型家属上限、活动累计家属上限、唯一标识及分场冲突。
|
||||||
* 当参数包含报名记录ID时,会排除该记录后再校验,用于修改报名。
|
* 当参数包含报名记录ID时,会排除该记录后再校验,用于修改报名。
|
||||||
*
|
*
|
||||||
* @param user 待校验的报名信息
|
* @param user 待校验的报名信息
|
||||||
|
|||||||
+416
-50
@@ -48,10 +48,74 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
|||||||
super(dao);
|
super(dao);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增、沿用和编辑活动共用配置校验。activity 为页面提交的完整活动,oldActivity 为编辑前记录。
|
||||||
|
* 唯一标识必须使用所有课程类型共有且必填的字段,不限定验证规则,不改写历史字段编码。
|
||||||
|
* 返回值为空;字段缺失、编码不一致或历史数据可能失联时抛出可定位类型的业务异常。
|
||||||
|
*/
|
||||||
|
private void validateActivityRuleConfig(FamilyActivity activity, FamilyActivity oldActivity) {
|
||||||
|
if (activity.getFamilyCountScope() == null) {
|
||||||
|
activity.setFamilyCountScope(1);
|
||||||
|
}
|
||||||
|
if (activity.getFamilyCountScope() != 1 && activity.getFamilyCountScope() != 2) {
|
||||||
|
throw new BaseException("家属人数限制范围不正确");
|
||||||
|
}
|
||||||
|
boolean activityScope = Objects.equals(activity.getFamilyCountScope(), 2);
|
||||||
|
if (activityScope && (activity.getFamilyMaxCount() == null || activity.getFamilyMaxCount() < 1)) {
|
||||||
|
throw new BaseException("按整个活动累计时,家属最多数必须为正整数");
|
||||||
|
}
|
||||||
|
boolean keyChanged = oldActivity == null || !Objects.equals(oldActivity.getOnlyKey(), activity.getOnlyKey());
|
||||||
|
if (oldActivity != null && keyChanged
|
||||||
|
&& (dao().count(FamilyUser.class, Cnd.where("activityId", "=", activity.getId())) > 0
|
||||||
|
|| dao().count(FamilyApplyPrefill.class, Cnd.where("activityId", "=", activity.getId())) > 0)) {
|
||||||
|
throw new BaseException("活动已有报名或预填信息,不能直接更换唯一标识,请先核对历史数据");
|
||||||
|
}
|
||||||
|
List<FamilyCourse> courses = activity.getCourseList();
|
||||||
|
if (Lang.isEmpty(courses)) {
|
||||||
|
if (activityScope || Boolean.TRUE.equals(activity.getTimeConflictCheck())) {
|
||||||
|
throw new BaseException("启用报名规则前请先配置子活动");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 已选唯一标识即检查所有参与类型,即使编码未更换也必须满足共有与必填要求。
|
||||||
|
boolean checkKey = activityScope || StrUtil.isNotBlank(activity.getOnlyKey());
|
||||||
|
if (activityScope && StrUtil.isBlank(activity.getOnlyKey())) {
|
||||||
|
throw new BaseException("请选择各子活动类型共有且必填的字段作为唯一标识");
|
||||||
|
}
|
||||||
|
for (FamilyCourse course : courses) {
|
||||||
|
if (checkKey) {
|
||||||
|
FamilyType type = StrUtil.isBlank(course.getCourseType()) ? null
|
||||||
|
: dao().fetch(FamilyType.class, course.getCourseType());
|
||||||
|
if (type == null) {
|
||||||
|
throw new BaseException("请为子活动配置有效类型:" + course.getCourseName());
|
||||||
|
}
|
||||||
|
List<FamilyMobileSignColumn> columns = dao().query(FamilyMobileSignColumn.class,
|
||||||
|
Cnd.where("typeId", "=", type.getId()).and("columnCode", "=", activity.getOnlyKey()));
|
||||||
|
if (columns.size() != 1 || !Boolean.TRUE.equals(columns.get(0).getIsRequired())) {
|
||||||
|
throw new BaseException("类型【" + type.getTypeName() + "】必须配置同编码且必填的唯一标识字段:"
|
||||||
|
+ activity.getOnlyKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Boolean.TRUE.equals(activity.getTimeConflictCheck())) {
|
||||||
|
if (Lang.isEmpty(course.getCourseTimeList())) {
|
||||||
|
throw new BaseException("启用时间冲突校验前请配置子活动时间:" + course.getCourseName());
|
||||||
|
}
|
||||||
|
for (FamilyActivityCourse time : course.getCourseTimeList()) {
|
||||||
|
if (time == null || time.getCourseDate() == null || time.getCourseStartTime() == null
|
||||||
|
|| time.getCourseEndTime() == null
|
||||||
|
|| DateUtil.format(time.getCourseStartTime(), "HH:mm").compareTo(
|
||||||
|
DateUtil.format(time.getCourseEndTime(), "HH:mm")) >= 0) {
|
||||||
|
throw new BaseException("子活动日期或起止时间不完整、无效:" + course.getCourseName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Aop(TransAop.READ_COMMITTED)
|
@Aop(TransAop.READ_COMMITTED)
|
||||||
public void add(FamilyActivity activity, FamilyCourse course) {
|
public void add(FamilyActivity activity, FamilyCourse course) {
|
||||||
|
validateActivityRuleConfig(activity, null);
|
||||||
dao().insert(activity);
|
dao().insert(activity);
|
||||||
|
|
||||||
//插入类型限制
|
//插入类型限制
|
||||||
@@ -81,7 +145,7 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
|||||||
@Override
|
@Override
|
||||||
@Aop(TransAop.READ_COMMITTED)
|
@Aop(TransAop.READ_COMMITTED)
|
||||||
public void edit(FamilyActivity activity) {
|
public void edit(FamilyActivity activity) {
|
||||||
|
validateActivityRuleConfig(activity, dao().fetch(FamilyActivity.class, activity.getId()));
|
||||||
//修改活动
|
//修改活动
|
||||||
update(activity);
|
update(activity);
|
||||||
|
|
||||||
@@ -155,6 +219,8 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将页面的课程日期与时分合成为实际上课时间,保存到同一事务的课程时段表。 */
|
||||||
|
@Aop(TransAop.READ_COMMITTED)
|
||||||
private void setCourseTimeAndInsert(FamilyCourse course) {
|
private void setCourseTimeAndInsert(FamilyCourse course) {
|
||||||
course.getCourseTimeList().forEach(courseTime -> {
|
course.getCourseTimeList().forEach(courseTime -> {
|
||||||
Calendar calendar = Calendar.getInstance();
|
Calendar calendar = Calendar.getInstance();
|
||||||
@@ -254,7 +320,13 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
|||||||
@Aop(TransAop.READ_COMMITTED)
|
@Aop(TransAop.READ_COMMITTED)
|
||||||
public FamilyUser doSignUp(FamilyUser familyUser) throws Exception {
|
public FamilyUser doSignUp(FamilyUser familyUser) throws Exception {
|
||||||
String userId = SecurityUtil.getUserId();
|
String userId = SecurityUtil.getUserId();
|
||||||
|
if (familyUser == null) {
|
||||||
|
throw new BaseException("报名信息不能为空");
|
||||||
|
}
|
||||||
|
// 先锁教职工,再锁课程;跨课程报名也必须串行校验同一活动的家属总数和时间。
|
||||||
|
lockSignUpUser(userId);
|
||||||
|
familyUser.setId(null);
|
||||||
|
familyUser.setUserId(userId);
|
||||||
// 对课程行加数据库锁,使名额校验和正式写入在多节点部署时仍按顺序执行。
|
// 对课程行加数据库锁,使名额校验和正式写入在多节点部署时仍按顺序执行。
|
||||||
lockCourseForSignUp(familyUser.getCourseId());
|
lockCourseForSignUp(familyUser.getCourseId());
|
||||||
//查询课程
|
//查询课程
|
||||||
@@ -272,6 +344,10 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
|||||||
FamilyActivity activity = dao().fetch(FamilyActivity.class, course.getActivityId());
|
FamilyActivity activity = dao().fetch(FamilyActivity.class, course.getActivityId());
|
||||||
validateSignUpTimeAndScope(activity, userId, false);
|
validateSignUpTimeAndScope(activity, userId, false);
|
||||||
familyUser.setActivityId(course.getActivityId());
|
familyUser.setActivityId(course.getActivityId());
|
||||||
|
if (!Boolean.TRUE.equals(course.getCourseIsLimitApply())) {
|
||||||
|
// 非独立时段课程必须参加全部时段,忽略客户端多传的单个时段ID。
|
||||||
|
familyUser.setActivityCourseId(null);
|
||||||
|
}
|
||||||
if (getSignUpInfo(course.getId(), userId) != null) {
|
if (getSignUpInfo(course.getId(), userId) != null) {
|
||||||
throw new BaseException("抱歉,您已经报名");
|
throw new BaseException("抱歉,您已经报名");
|
||||||
}
|
}
|
||||||
@@ -298,6 +374,7 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
|||||||
: "您选择的类型已达上限,不能再报该类型的了");
|
: "您选择的类型已达上限,不能再报该类型的了");
|
||||||
}
|
}
|
||||||
validateCourseTime(course, familyUser.getActivityCourseId(), null);
|
validateCourseTime(course, familyUser.getActivityCourseId(), null);
|
||||||
|
validateTimeConflict(familyUser, course, activity, userId);
|
||||||
//如果这个课程的预留名额方式为报名人数不变
|
//如果这个课程的预留名额方式为报名人数不变
|
||||||
if (course.getReserveMode() == 2) {
|
if (course.getReserveMode() == 2) {
|
||||||
//如果当前报名+已报小于这个课程限制人数
|
//如果当前报名+已报小于这个课程限制人数
|
||||||
@@ -345,6 +422,7 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
|||||||
throw new BaseException("取消报名参数不完整");
|
throw new BaseException("取消报名参数不完整");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lockSignUpUser(SecurityUtil.getUserId());
|
||||||
// 与正式报名共用课程行锁,保证删除、重新统计和候补转正在同一串行区间内完成。
|
// 与正式报名共用课程行锁,保证删除、重新统计和候补转正在同一串行区间内完成。
|
||||||
lockCourseForSignUp(courseId);
|
lockCourseForSignUp(courseId);
|
||||||
FamilyCourse course = dao().fetch(FamilyCourse.class, courseId);
|
FamilyCourse course = dao().fetch(FamilyCourse.class, courseId);
|
||||||
@@ -378,6 +456,7 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
|||||||
* 按报名时间顺序从全部分工会候补中查找当前可容纳的人员。
|
* 按报名时间顺序从全部分工会候补中查找当前可容纳的人员。
|
||||||
* 本人和家属是否计入总人数,严格按课程类型配置计算。
|
* 本人和家属是否计入总人数,严格按课程类型配置计算。
|
||||||
*/
|
*/
|
||||||
|
@Aop(TransAop.READ_COMMITTED)
|
||||||
private void promoteWaitingUsers(FamilyCourse course) {
|
private void promoteWaitingUsers(FamilyCourse course) {
|
||||||
FamilyType type = dao().fetch(FamilyType.class, course.getCourseType());
|
FamilyType type = dao().fetch(FamilyType.class, course.getCourseType());
|
||||||
if (type == null) {
|
if (type == null) {
|
||||||
@@ -459,6 +538,78 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
|||||||
&& DateUtil.compare(now, activity.getActivitySignUpEndTime()) < 0;
|
&& DateUtil.compare(now, activity.getActivitySignUpEndTime()) < 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Aop(TransAop.READ_COMMITTED)
|
||||||
|
public void adjustSignUp(String activityId, String oldCourseId, String newCourseId, String userId) {
|
||||||
|
if (StrUtil.hasBlank(activityId, oldCourseId, newCourseId, userId)) {
|
||||||
|
throw new BaseException("人员调整参数不完整");
|
||||||
|
}
|
||||||
|
lockSignUpUser(userId);
|
||||||
|
// 调班涉及两门课,固定按ID排序加锁,避免不同教职工反向调班时死锁。
|
||||||
|
List.of(oldCourseId, newCourseId).stream().distinct().sorted().forEach(this::lockCourseForSignUp);
|
||||||
|
FamilyUser original = dao().fetch(FamilyUser.class, Cnd.where("activityId", "=", activityId)
|
||||||
|
.and("courseId", "=", oldCourseId).and("userId", "=", userId));
|
||||||
|
if (original == null || (!Objects.equals(original.getState(), 1) && !Objects.equals(original.getState(), 3))) {
|
||||||
|
throw new BaseException("只允许调整当前有效的正常报名");
|
||||||
|
}
|
||||||
|
FamilyCourse source = dao().fetch(FamilyCourse.class, oldCourseId);
|
||||||
|
FamilyCourse target = dao().fetch(FamilyCourse.class, newCourseId);
|
||||||
|
if (!Objects.equals(source.getActivityId(), activityId) || !Objects.equals(target.getActivityId(), activityId)) {
|
||||||
|
throw new BaseException("原子活动和目标子活动必须属于当前活动");
|
||||||
|
}
|
||||||
|
if (Objects.equals(oldCourseId, newCourseId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (getSignUpInfo(newCourseId, userId) != null) {
|
||||||
|
throw new BaseException("该教职工已经报名目标子活动");
|
||||||
|
}
|
||||||
|
FamilyActivity activity = dao().fetch(FamilyActivity.class, activityId);
|
||||||
|
FamilyType type = dao().fetch(FamilyType.class, target.getCourseType());
|
||||||
|
if (activity == null || type == null) {
|
||||||
|
throw new BaseException("活动或目标子活动类型不存在");
|
||||||
|
}
|
||||||
|
original.setCourseId(newCourseId);
|
||||||
|
original.setActivityCourseId(null);
|
||||||
|
if (Boolean.TRUE.equals(target.getCourseIsLimitApply())) {
|
||||||
|
List<FamilyActivityCourse> times = dao().query(FamilyActivityCourse.class, Cnd.where("courseId", "=", newCourseId));
|
||||||
|
if (times.size() != 1) {
|
||||||
|
throw new BaseException("目标子活动包含多个独立报名时段,请通过报名表单明确选择时段");
|
||||||
|
}
|
||||||
|
original.setActivityCourseId(times.get(0).getId());
|
||||||
|
}
|
||||||
|
original.setMobileColumnsValue(normalizeAndValidateFamilyColumns(original.getMobileColumnsValue(), type, target, activity));
|
||||||
|
Map<Boolean, String> result = validFamilyCount(original, userId);
|
||||||
|
if (result.containsKey(false)) {
|
||||||
|
throw new BaseException(result.get(false));
|
||||||
|
}
|
||||||
|
validateTimeConflict(original, target, activity, userId);
|
||||||
|
validateCourseTime(target, original.getActivityCourseId(), null);
|
||||||
|
int contribution = getFamilyContribution(type, original.getMobileColumnsValue().size());
|
||||||
|
if (statisticsService.queryCourseCount(newCourseId, target.getCourseType()) + contribution
|
||||||
|
+ target.getCourseReservedNumber() > target.getCoursePeopleNumber() || target.getCoursePeopleNumber() == 0) {
|
||||||
|
throw new BaseException("目标子活动名额不足");
|
||||||
|
}
|
||||||
|
original.setSignUpTime(new Date());
|
||||||
|
dao().update(original);
|
||||||
|
// 调班后的所有校验通过才删除旧时段,异常时事务回滚,保留原报名。
|
||||||
|
dao().clear(FamilyUserCourse.class, Cnd.where("activityId", "=", activityId)
|
||||||
|
.and("courseId", "=", oldCourseId).and("userId", "=", userId));
|
||||||
|
rebuildUserCourseTimes(original);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Aop(TransAop.READ_COMMITTED)
|
||||||
|
public void deleteSignUser(String activityId, String courseId, String userId) {
|
||||||
|
if (StrUtil.hasBlank(activityId, courseId, userId)) {
|
||||||
|
throw new BaseException("删除报名参数不完整");
|
||||||
|
}
|
||||||
|
lockSignUpUser(userId);
|
||||||
|
lockCourseForSignUp(courseId);
|
||||||
|
Cnd cnd = Cnd.where("activityId", "=", activityId).and("courseId", "=", courseId).and("userId", "=", userId);
|
||||||
|
dao().clear(FamilyUserCourse.class, cnd);
|
||||||
|
dao().clear(FamilyUser.class, cnd);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public FamilyApplyPrefill getPrefill(String courseId, String userId) {
|
public FamilyApplyPrefill getPrefill(String courseId, String userId) {
|
||||||
if (StrUtil.isBlank(courseId) || StrUtil.isBlank(userId)) {
|
if (StrUtil.isBlank(courseId) || StrUtil.isBlank(userId)) {
|
||||||
@@ -581,7 +732,13 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
|||||||
if (currentSignUp == null) {
|
if (currentSignUp == null) {
|
||||||
throw new BaseException("未找到本人在该课程的报名信息");
|
throw new BaseException("未找到本人在该课程的报名信息");
|
||||||
}
|
}
|
||||||
validateUpdateSignUpData(currentSignUp, currentSignUp);
|
// 打开编辑表单只校验归属和报名时间,让历史冲突数据仍可进入表单整改;保存时校验新数据。
|
||||||
|
FamilyCourse course = dao().fetch(FamilyCourse.class, courseId);
|
||||||
|
if (course == null || !Objects.equals(course.getActivityId(), currentSignUp.getActivityId())) {
|
||||||
|
throw new BaseException("报名课程不存在或已变更");
|
||||||
|
}
|
||||||
|
validateSignUpTimeAndScope(dao().fetch(FamilyActivity.class, course.getActivityId()),
|
||||||
|
SecurityUtil.getUserId(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -592,11 +749,31 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
|||||||
}
|
}
|
||||||
|
|
||||||
String currentUserId = SecurityUtil.getUserId();
|
String currentUserId = SecurityUtil.getUserId();
|
||||||
|
lockSignUpUser(currentUserId);
|
||||||
|
lockCourseForSignUp(familyUser.getCourseId());
|
||||||
FamilyUser currentSignUp = getSignUpInfo(familyUser.getCourseId(), currentUserId);
|
FamilyUser currentSignUp = getSignUpInfo(familyUser.getCourseId(), currentUserId);
|
||||||
if (currentSignUp == null || !Objects.equals(currentSignUp.getId(), familyUser.getId())) {
|
if (currentSignUp == null || !Objects.equals(currentSignUp.getId(), familyUser.getId())) {
|
||||||
throw new BaseException("未找到本人在该课程的报名信息");
|
throw new BaseException("未找到本人在该课程的报名信息");
|
||||||
}
|
}
|
||||||
validateUpdateSignUpData(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, currentUserId, true);
|
||||||
|
FamilyType type = dao().fetch(FamilyType.class, course.getCourseType());
|
||||||
|
if (type == null) {
|
||||||
|
throw new BaseException("报名类型不存在或已变更");
|
||||||
|
}
|
||||||
|
validateUpdateSignUpData(familyUser, currentSignUp, course, activity, type);
|
||||||
|
|
||||||
|
// 正式报名和候补转正都占正式名额,按类型计人数以识别本次修改实际释放的名额。
|
||||||
|
int oldFamilyNumber = Lang.isNotEmpty(currentSignUp.getMobileColumnsValue())
|
||||||
|
? currentSignUp.getMobileColumnsValue().size() : 0;
|
||||||
|
boolean releasedFormalSlots = (Objects.equals(currentSignUp.getState(), 1)
|
||||||
|
|| Objects.equals(currentSignUp.getState(), 3))
|
||||||
|
&& getFamilyContribution(type, oldFamilyNumber)
|
||||||
|
> getFamilyContribution(type, familyUser.getMobileColumnsValue().size());
|
||||||
|
|
||||||
String oldActivityCourseId = currentSignUp.getActivityCourseId();
|
String oldActivityCourseId = currentSignUp.getActivityCourseId();
|
||||||
currentSignUp.setMobile(familyUser.getMobile());
|
currentSignUp.setMobile(familyUser.getMobile());
|
||||||
@@ -604,23 +781,28 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
|||||||
currentSignUp.setActivityCourseId(familyUser.getActivityCourseId());
|
currentSignUp.setActivityCourseId(familyUser.getActivityCourseId());
|
||||||
dao().update(currentSignUp);
|
dao().update(currentSignUp);
|
||||||
syncUpdatedUserCourse(currentSignUp, oldActivityCourseId);
|
syncUpdatedUserCourse(currentSignUp, oldActivityCourseId);
|
||||||
|
// 保存减员及其时段记录后,在同一课程锁内按最新占用人数执行递补。
|
||||||
|
if (releasedFormalSlots && Objects.equals(course.getReserveMode(), 2) && isWithinSignUpTime(activity)) {
|
||||||
|
promoteWaitingUsers(course);
|
||||||
|
}
|
||||||
return currentSignUp;
|
return currentSignUp;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 修改按钮预校验与最终保存共用本方法,确保两处业务规则不会产生偏差。
|
* 保存修改前校验家属信息、报名名额与时段,人数及冲突校验须排除本人的原记录。
|
||||||
*/
|
*/
|
||||||
private void validateUpdateSignUpData(FamilyUser familyUser, FamilyUser currentSignUp) {
|
private void validateUpdateSignUpData(FamilyUser familyUser, FamilyUser currentSignUp,
|
||||||
FamilyCourse course = dao().fetch(FamilyCourse.class, familyUser.getCourseId());
|
FamilyCourse course, FamilyActivity activity, FamilyType type) {
|
||||||
if (course == null || !Objects.equals(course.getActivityId(), currentSignUp.getActivityId())) {
|
if (!Boolean.TRUE.equals(course.getCourseIsLimitApply())) {
|
||||||
throw new BaseException("报名课程不存在或已变更");
|
familyUser.setActivityCourseId(null);
|
||||||
}
|
}
|
||||||
FamilyActivity activity = dao().fetch(FamilyActivity.class, course.getActivityId());
|
|
||||||
validateSignUpTimeAndScope(activity, SecurityUtil.getUserId(), true);
|
|
||||||
|
|
||||||
if (Lang.isEmpty(familyUser.getMobileColumnsValue())) {
|
if (Lang.isEmpty(familyUser.getMobileColumnsValue())) {
|
||||||
throw new BaseException("请填写" + activity.getKeyWord() + "信息");
|
throw new BaseException("请填写" + activity.getKeyWord() + "信息");
|
||||||
}
|
}
|
||||||
|
// 修改报名与新增报名使用同一套子活动类型字段及家属人数上限,防止绕过前端提交超额数据。
|
||||||
|
familyUser.setMobileColumnsValue(normalizeAndValidateFamilyColumns(
|
||||||
|
familyUser.getMobileColumnsValue(), type, course, activity));
|
||||||
|
|
||||||
// 修改校验必须排除本人原报名,否则人数、唯一标识及报名限制都会重复计算。
|
// 修改校验必须排除本人原报名,否则人数、唯一标识及报名限制都会重复计算。
|
||||||
familyUser.setActivityId(currentSignUp.getActivityId());
|
familyUser.setActivityId(currentSignUp.getActivityId());
|
||||||
@@ -630,10 +812,6 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
|||||||
throw new BaseException(familyCountResult.get(false));
|
throw new BaseException(familyCountResult.get(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
FamilyType type = dao().fetch(FamilyType.class, course.getCourseType());
|
|
||||||
if (type == null) {
|
|
||||||
throw new BaseException("报名类型不存在或已变更");
|
|
||||||
}
|
|
||||||
int currentFamilyNumber = Boolean.TRUE.equals(type.getIsBringFamily()) && Boolean.TRUE.equals(type.getIsAddFamily())
|
int currentFamilyNumber = Boolean.TRUE.equals(type.getIsBringFamily()) && Boolean.TRUE.equals(type.getIsAddFamily())
|
||||||
? familyUser.getMobileColumnsValue().size() : 0;
|
? familyUser.getMobileColumnsValue().size() : 0;
|
||||||
if (isSignFullForUpdate(course, type, currentFamilyNumber, currentSignUp, null)) {
|
if (isSignFullForUpdate(course, type, currentFamilyNumber, currentSignUp, null)) {
|
||||||
@@ -648,6 +826,7 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
|||||||
: "您选择的类型已达上限,不能再报该类型的了");
|
: "您选择的类型已达上限,不能再报该类型的了");
|
||||||
}
|
}
|
||||||
validateCourseTime(course, familyUser.getActivityCourseId(), currentSignUp);
|
validateCourseTime(course, familyUser.getActivityCourseId(), currentSignUp);
|
||||||
|
validateTimeConflict(familyUser, course, activity, SecurityUtil.getUserId());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -698,6 +877,22 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 锁定实际报名教职工的账号行,覆盖多节点、PC/H5和快速报名的跨课程并发。
|
||||||
|
* userId 必须由登录态或已校验的后台人员调整参数提供,所有写入口统一先锁账号再锁课程。
|
||||||
|
*/
|
||||||
|
private void lockSignUpUser(String userId) {
|
||||||
|
if (StrUtil.isBlank(userId)) {
|
||||||
|
throw new BaseException("报名人员不能为空");
|
||||||
|
}
|
||||||
|
Sql sql = Sqls.create("SELECT id FROM sys_user WHERE id = @userId FOR UPDATE");
|
||||||
|
sql.setParam("userId", userId);
|
||||||
|
sql.setCallback(Sqls.callback.str());
|
||||||
|
if (dao().execute(sql).getResult() == null) {
|
||||||
|
throw new BaseException("报名人员不存在或已删除");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 预填新增、修改和删除共用时间及人员范围校验。
|
* 预填新增、修改和删除共用时间及人员范围校验。
|
||||||
* 预填只允许发生在报名开始时间之前,到达开始时间后数据立即冻结。
|
* 预填只允许发生在报名开始时间之前,到达开始时间后数据立即冻结。
|
||||||
@@ -745,14 +940,14 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
|||||||
if (StrUtil.isBlank(activity.getOnlyKey())) {
|
if (StrUtil.isBlank(activity.getOnlyKey())) {
|
||||||
throw new BaseException("活动未配置家属唯一标识,请联系管理员");
|
throw new BaseException("活动未配置家属唯一标识,请联系管理员");
|
||||||
}
|
}
|
||||||
if (activity.getFamilyMaxCount() == null) {
|
if (type.getFamilyMaxCount() == null) {
|
||||||
throw new BaseException("活动未配置家属最多人数,请联系管理员");
|
throw new BaseException("子活动类型未配置家属最多人数,请联系管理员");
|
||||||
}
|
}
|
||||||
if (Lang.isEmpty(submittedFamilies)) {
|
if (Lang.isEmpty(submittedFamilies)) {
|
||||||
throw new BaseException("请填写" + activity.getKeyWord() + "信息");
|
throw new BaseException("请填写" + activity.getKeyWord() + "信息");
|
||||||
}
|
}
|
||||||
if (activity.getFamilyMaxCount() != null && submittedFamilies.size() > activity.getFamilyMaxCount()) {
|
if (submittedFamilies.size() > type.getFamilyMaxCount()) {
|
||||||
throw new BaseException(activity.getKeyWord() + "人数最多为" + activity.getFamilyMaxCount());
|
throw new BaseException(activity.getKeyWord() + "人数最多为" + type.getFamilyMaxCount());
|
||||||
}
|
}
|
||||||
dao().fetchLinks(type, "familyMobileSignColumnList", Cnd.NEW().asc("columnIndex"));
|
dao().fetchLinks(type, "familyMobileSignColumnList", Cnd.NEW().asc("columnIndex"));
|
||||||
List<FamilyMobileSignColumn> columnConfigs = type.getFamilyMobileSignColumnList();
|
List<FamilyMobileSignColumn> columnConfigs = type.getFamilyMobileSignColumnList();
|
||||||
@@ -772,6 +967,14 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
|||||||
for (FamilyMobileSignColumn config : columnConfigs) {
|
for (FamilyMobileSignColumn config : columnConfigs) {
|
||||||
NutMap submittedColumn = submittedValueMap.get(config.getColumnCode());
|
NutMap submittedColumn = submittedValueMap.get(config.getColumnCode());
|
||||||
Object columnValue = submittedColumn == null ? null : submittedColumn.get("columnValue");
|
Object columnValue = submittedColumn == null ? null : submittedColumn.get("columnValue");
|
||||||
|
if (Objects.equals(activity.getFamilyCountScope(), 2)
|
||||||
|
&& Objects.equals(activity.getOnlyKey(), config.getColumnCode())) {
|
||||||
|
if (!Boolean.TRUE.equals(config.getIsRequired())) {
|
||||||
|
throw new BaseException("活动累计家属人数需要同编码且必填的唯一标识字段,请联系管理员核对类型配置");
|
||||||
|
}
|
||||||
|
// 姓名等普通字段保留大小写;仅有效身份证号统一末位X,历史统计采用相同规则。
|
||||||
|
columnValue = normalizeFamilyIdentityValue(columnValue == null ? null : String.valueOf(columnValue));
|
||||||
|
}
|
||||||
validateDynamicColumnValue(config, columnValue, familyIndex, course, activity);
|
validateDynamicColumnValue(config, columnValue, familyIndex, course, activity);
|
||||||
|
|
||||||
NutMap normalizedColumn = NutMap.NEW();
|
NutMap normalizedColumn = NutMap.NEW();
|
||||||
@@ -983,13 +1186,133 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 提取活动指定字段的值;活动级累计去除首尾空格,普通字段保留大小写,历史按类型规则保持原值。 */
|
||||||
|
private String getFamilyIdentity(List<NutMap> family, FamilyActivity activity) {
|
||||||
|
if (family == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String value = family.stream().filter(Objects::nonNull)
|
||||||
|
.filter(column -> Objects.equals(activity.getOnlyKey(), column.getString("columnCode")))
|
||||||
|
.map(column -> column.getString("columnValue")).filter(Objects::nonNull).findFirst().orElse(null);
|
||||||
|
return value != null && Objects.equals(activity.getFamilyCountScope(), 2)
|
||||||
|
? normalizeFamilyIdentityValue(value) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一本次提交与历史记录的唯一标识比较值,不要求值必须为身份证。
|
||||||
|
* value 为所选字段的文本值,返回去除首尾空格的文本;有效身份证号末位x统一为X,其他文本保持原大小写。
|
||||||
|
*/
|
||||||
|
private String normalizeFamilyIdentityValue(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String identity = value.trim();
|
||||||
|
return IdcardUtil.isValidCard(identity) ? identity.toUpperCase(Locale.ROOT) : identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取一条报名实际参加的课程时段。独立报名仅取所选时段,否则取全部时段。
|
||||||
|
* course 为所属子活动,activityCourseId 为用户所选时段ID;返回完整有效的日期时间区间列表。
|
||||||
|
* 缺失、越权或无效时间不允许跳过冲突检查,避免缺配置成为绕过入口。
|
||||||
|
*/
|
||||||
|
private List<FamilyActivityCourse> getSignUpCourseTimes(FamilyCourse course, String activityCourseId) {
|
||||||
|
Cnd cnd = Cnd.where(FamilyActivityCourse::getCourseId, "=", course.getId());
|
||||||
|
if (Boolean.TRUE.equals(course.getCourseIsLimitApply())) {
|
||||||
|
if (StrUtil.isBlank(activityCourseId)) {
|
||||||
|
throw new BaseException("请选择报名时段:" + course.getCourseName());
|
||||||
|
}
|
||||||
|
cnd.and(FamilyActivityCourse::getId, "=", activityCourseId);
|
||||||
|
}
|
||||||
|
List<FamilyActivityCourse> times = dao().query(FamilyActivityCourse.class, cnd);
|
||||||
|
if (Lang.isEmpty(times)) {
|
||||||
|
throw new BaseException("子活动报名时段不存在或未配置,请联系管理员:" + course.getCourseName());
|
||||||
|
}
|
||||||
|
for (FamilyActivityCourse time : times) {
|
||||||
|
if (time.getCourseStartTime() == null || time.getCourseEndTime() == null
|
||||||
|
|| !time.getCourseStartTime().before(time.getCourseEndTime())) {
|
||||||
|
throw new BaseException("子活动起止时间配置无效,请联系管理员:" + course.getCourseName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return times;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取一条报名中的全部家属唯一标识,用于判断两次报名是否包含同一个孩子。
|
||||||
|
* user 为当前或历史报名,activity.onlyKey 为管理员配置的统一字段编码;返回规范化后的标识集合。
|
||||||
|
* 缺少字段或值为空时提示核对数据,不能将无法识别的历史报名当作不同孩子而跳过冲突校验。
|
||||||
|
*/
|
||||||
|
private Set<String> getTimeConflictFamilyIdentities(FamilyUser user, FamilyActivity activity) {
|
||||||
|
if (StrUtil.isBlank(activity.getOnlyKey())) {
|
||||||
|
throw new BaseException("活动未配置家属唯一标识,请联系管理员");
|
||||||
|
}
|
||||||
|
if (Lang.isEmpty(user.getMobileColumnsValue())) {
|
||||||
|
throw new BaseException("报名记录缺少家属信息,请联系管理员核对历史数据");
|
||||||
|
}
|
||||||
|
Set<String> identities = new HashSet<>();
|
||||||
|
for (List<NutMap> family : user.getMobileColumnsValue()) {
|
||||||
|
String identity = normalizeFamilyIdentityValue(getFamilyIdentity(family, activity));
|
||||||
|
if (StrUtil.isBlank(identity)) {
|
||||||
|
throw new BaseException("报名记录缺少家属唯一标识,请联系管理员核对字段编码和历史数据");
|
||||||
|
}
|
||||||
|
identities.add(identity);
|
||||||
|
}
|
||||||
|
return identities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在同一活动、同一教职工下仅校验同一家属的时间重叠,含候补,修改和调班排除当前报名记录。
|
||||||
|
* 参数为本次报名、所属课程、活动配置和实际报名用户ID;无返回值,同一家属冲突时提示已有课程和时间。
|
||||||
|
* 使用半开区间:一场结束时刻等于另一场开始时刻允许报名;不同日期自然不会冲突。
|
||||||
|
*/
|
||||||
|
private void validateTimeConflict(FamilyUser user, FamilyCourse course, FamilyActivity activity, String userId) {
|
||||||
|
if (!Boolean.TRUE.equals(activity.getTimeConflictCheck())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Set<String> currentIdentities = getTimeConflictFamilyIdentities(user, activity);
|
||||||
|
List<FamilyActivityCourse> currentTimes = getSignUpCourseTimes(course, user.getActivityCourseId());
|
||||||
|
Cnd cnd = Cnd.where(FamilyUser::getActivityId, "=", activity.getId())
|
||||||
|
.and(FamilyUser::getUserId, "=", userId).and(FamilyUser::getState, "in", List.of(1, 2, 3));
|
||||||
|
if (StrUtil.isNotBlank(user.getId())) {
|
||||||
|
cnd.and(FamilyUser::getId, "!=", user.getId());
|
||||||
|
}
|
||||||
|
for (FamilyUser existing : dao().query(FamilyUser.class, cnd)) {
|
||||||
|
Set<String> existingIdentities = getTimeConflictFamilyIdentities(existing, activity);
|
||||||
|
// 两次报名没有相同孩子时允许同时段报名;多人报名只要有一个相同孩子,仍需检查时间。
|
||||||
|
if (Collections.disjoint(currentIdentities, existingIdentities)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
FamilyCourse oldCourse = dao().fetch(FamilyCourse.class, existing.getCourseId());
|
||||||
|
if (oldCourse == null || !Objects.equals(oldCourse.getActivityId(), activity.getId())) {
|
||||||
|
throw new BaseException("已有报名的子活动不存在或已变更,请联系管理员核对");
|
||||||
|
}
|
||||||
|
for (FamilyActivityCourse oldTime : getSignUpCourseTimes(oldCourse, existing.getActivityCourseId())) {
|
||||||
|
for (FamilyActivityCourse currentTime : currentTimes) {
|
||||||
|
if (currentTime.getCourseStartTime().before(oldTime.getCourseEndTime())
|
||||||
|
&& oldTime.getCourseStartTime().before(currentTime.getCourseEndTime())) {
|
||||||
|
throw new BaseException("同一家属已报名【" + oldCourse.getCourseName() + "】("
|
||||||
|
+ DateUtil.format(oldTime.getCourseStartTime(), "yyyy-MM-dd HH:mm") + "至"
|
||||||
|
+ DateUtil.format(oldTime.getCourseEndTime(), "yyyy-MM-dd HH:mm")
|
||||||
|
+ "),与当前报名时间重叠,不能兼报");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 仅在报名时段发生变化时重建本人课程时段记录,未变化时保留原签到和领取信息。
|
* 仅在报名时段发生变化时重建本人课程时段记录,未变化时保留原签到和领取信息。
|
||||||
*/
|
*/
|
||||||
|
@Aop(TransAop.READ_COMMITTED)
|
||||||
private void syncUpdatedUserCourse(FamilyUser currentSignUp, String oldActivityCourseId) {
|
private void syncUpdatedUserCourse(FamilyUser currentSignUp, String oldActivityCourseId) {
|
||||||
if (Objects.equals(oldActivityCourseId, currentSignUp.getActivityCourseId())) {
|
if (Objects.equals(oldActivityCourseId, currentSignUp.getActivityCourseId())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
rebuildUserCourseTimes(currentSignUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改所选时段或后台调班后,按本次报名实际参加的时段重建课程记录。 */
|
||||||
|
@Aop(TransAop.READ_COMMITTED)
|
||||||
|
private void rebuildUserCourseTimes(FamilyUser currentSignUp) {
|
||||||
dao().clear(FamilyUserCourse.class, Cnd.where(FamilyUserCourse::getActivityId, "=", currentSignUp.getActivityId())
|
dao().clear(FamilyUserCourse.class, Cnd.where(FamilyUserCourse::getActivityId, "=", currentSignUp.getActivityId())
|
||||||
.and(FamilyUserCourse::getCourseId, "=", currentSignUp.getCourseId())
|
.and(FamilyUserCourse::getCourseId, "=", currentSignUp.getCourseId())
|
||||||
.and(FamilyUserCourse::getUserId, "=", currentSignUp.getUserId()));
|
.and(FamilyUserCourse::getUserId, "=", currentSignUp.getUserId()));
|
||||||
@@ -1020,6 +1343,7 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
|||||||
|
|
||||||
@Async
|
@Async
|
||||||
@Override
|
@Override
|
||||||
|
@Aop(TransAop.READ_COMMITTED)
|
||||||
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);
|
insertUserCourse(activityId, courseId, userId);
|
||||||
@@ -1028,6 +1352,7 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
|||||||
/**
|
/**
|
||||||
* 同步写入报名人员的全部课程时段,用于保证正式报名主记录与时段记录处于同一事务。
|
* 同步写入报名人员的全部课程时段,用于保证正式报名主记录与时段记录处于同一事务。
|
||||||
*/
|
*/
|
||||||
|
@Aop(TransAop.READ_COMMITTED)
|
||||||
private void insertUserCourse(String activityId, String courseId, String 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<>();
|
||||||
@@ -1109,15 +1434,12 @@ 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();
|
return validFamilyCount(user, SecurityUtil.getUserId());
|
||||||
// 查询报名记录
|
|
||||||
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);
|
|
||||||
|
|
||||||
|
/** 按实际报名用户校验家属集合,后台调班不能使用当前管理员身份查询报名。 */
|
||||||
|
private Map<Boolean, String> validFamilyCount(FamilyUser user, String userId) {
|
||||||
|
String activityId = user.getActivityId();
|
||||||
FamilyActivity activity = dao().fetch(FamilyActivity.class, activityId);
|
FamilyActivity activity = dao().fetch(FamilyActivity.class, activityId);
|
||||||
if (activity == null) {
|
if (activity == null) {
|
||||||
return Map.of(false, "报名活动不存在或已删除");
|
return Map.of(false, "报名活动不存在或已删除");
|
||||||
@@ -1125,14 +1447,64 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
|||||||
if (StrUtil.isBlank(activity.getOnlyKey())) {
|
if (StrUtil.isBlank(activity.getOnlyKey())) {
|
||||||
return Map.of(false, "活动未配置家属唯一标识,请联系管理员");
|
return Map.of(false, "活动未配置家属唯一标识,请联系管理员");
|
||||||
}
|
}
|
||||||
if (activity.getFamilyMaxCount() == null) {
|
|
||||||
return Map.of(false, "活动未配置家属最多人数,请联系管理员");
|
|
||||||
}
|
|
||||||
if (Lang.isEmpty(user.getMobileColumnsValue())) {
|
if (Lang.isEmpty(user.getMobileColumnsValue())) {
|
||||||
return Map.of(false, "请填写" + activity.getKeyWord() + "信息");
|
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, "=", userId);
|
||||||
|
if (Objects.equals(activity.getFamilyCountScope(), 2) || Boolean.TRUE.equals(activity.getTimeConflictCheck())) {
|
||||||
|
// 正常、候补及候补转正占用规则;废弃报名不占用新规则的家属名额。
|
||||||
|
signUpCnd.and(FamilyUser::getState, "in", List.of(1, 2, 3));
|
||||||
|
}
|
||||||
|
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));
|
||||||
|
|
||||||
|
if (Objects.equals(activity.getFamilyCountScope(), 2)) {
|
||||||
|
if (activity.getFamilyMaxCount() == null || activity.getFamilyMaxCount() < 1) {
|
||||||
|
return Map.of(false, "活动未配置有效的家属最多数,请联系管理员");
|
||||||
|
}
|
||||||
|
Set<String> identities = new HashSet<>();
|
||||||
|
List<FamilyUser> allSignUps = new ArrayList<>(list);
|
||||||
|
allSignUps.add(user);
|
||||||
|
for (FamilyUser signUp : allSignUps) {
|
||||||
|
if (Lang.isEmpty(signUp.getMobileColumnsValue())) {
|
||||||
|
return Map.of(false, "报名记录缺少家属信息,请联系管理员核对历史数据");
|
||||||
|
}
|
||||||
|
for (List<NutMap> family : signUp.getMobileColumnsValue()) {
|
||||||
|
String identity = getFamilyIdentity(family, activity);
|
||||||
|
if (StrUtil.isBlank(identity)) {
|
||||||
|
return Map.of(false, "报名记录缺少统一的家属唯一标识,请联系管理员核对字段编码和历史数据");
|
||||||
|
}
|
||||||
|
identities.add(identity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (identities.size() > activity.getFamilyMaxCount()) {
|
||||||
|
return Map.of(false, "本活动累计" + activity.getKeyWord() + "人数最多为" + activity.getFamilyMaxCount()
|
||||||
|
+ ",请使用已报名的家属,不能更换为其他人");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 家属最多数按当前子活动类型独立累计,同类型多个子活动按家属唯一标识去重。
|
||||||
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)
|
||||||
@@ -1140,28 +1512,19 @@ 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)
|
.map(family -> getFamilyIdentity(family, activity))
|
||||||
.flatMap(List::stream)
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.filter(nutMap -> Objects.equals(activity.getOnlyKey(), nutMap.getString("columnCode")))
|
|
||||||
.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));
|
Map<String, String> courseNameMap = courseList.stream().collect(Collectors.toMap(FamilyCourse::getId,
|
||||||
|
course -> StrUtil.isBlank(course.getCourseName()) ? "原子活动" : course.getCourseName()));
|
||||||
List<FamilyType> typeList = dao().query(FamilyType.class, Cnd.NEW());
|
|
||||||
Map<String, String> typeMap = typeList.stream().collect(Collectors.toMap(FamilyType::getId, FamilyType::getTypeName));
|
|
||||||
|
|
||||||
List<List<NutMap>> currentValue = user.getMobileColumnsValue();
|
List<List<NutMap>> currentValue = user.getMobileColumnsValue();
|
||||||
|
|
||||||
@@ -1180,7 +1543,7 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 家属匹配唯一标识的值
|
// 家属匹配唯一标识的值
|
||||||
String onlyKeyValue = nutMap.getString("columnValue");
|
String onlyKeyValue = getFamilyIdentity(listMap, activity);
|
||||||
if(Objects.equals(tempValue, onlyKeyValue)) {
|
if(Objects.equals(tempValue, onlyKeyValue)) {
|
||||||
return Map.of(false, activity.getKeyWord() + "不能重复报同一分场");
|
return Map.of(false, activity.getKeyWord() + "不能重复报同一分场");
|
||||||
}
|
}
|
||||||
@@ -1202,18 +1565,21 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
|||||||
if(hisNutMap == null) {
|
if(hisNutMap == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
String hisKeyValue = hisNutMap.getString("columnValue");
|
String hisKeyValue = getFamilyIdentity(hisValue, activity);
|
||||||
// 判断是不是同一个孩子
|
// 判断是不是同一个孩子
|
||||||
if(Objects.equals(onlyKeyValue, hisKeyValue)) {
|
if(Objects.equals(onlyKeyValue, hisKeyValue)) {
|
||||||
oneFamilyCount ++;
|
oneFamilyCount ++;
|
||||||
if(Objects.equals(courseMap.get(user.getCourseId()), courseMap.get(hisRecord.getCourseId()))) {
|
if(Objects.equals(courseMap.get(user.getCourseId()), courseMap.get(hisRecord.getCourseId()))) {
|
||||||
return Map.of(false, onlyKeyValue + "已经报名" + typeMap.get(courseMap.get(user.getCourseId())) + "分场");
|
return Map.of(false, onlyKeyValue + "已经报名“" + courseNameMap.getOrDefault(hisRecord.getCourseId(), "原子活动")
|
||||||
|
+ "”,不能重复报名同时间段");
|
||||||
}
|
}
|
||||||
if(aa.contains(user.getCourseId()) && bb.contains(hisRecord.getCourseId())) {
|
if(aa.contains(user.getCourseId()) && bb.contains(hisRecord.getCourseId())) {
|
||||||
return Map.of(false, onlyKeyValue + "已经报名" + typeMap.get(courseMap.get(hisRecord.getCourseId())) + "分场,存在冲突");
|
return Map.of(false, onlyKeyValue + "已经报名“" + courseNameMap.getOrDefault(hisRecord.getCourseId(), "原子活动")
|
||||||
|
+ "”,与当前子活动存在冲突");
|
||||||
}
|
}
|
||||||
if(bb.contains(user.getCourseId()) && aa.contains(hisRecord.getCourseId())) {
|
if(bb.contains(user.getCourseId()) && aa.contains(hisRecord.getCourseId())) {
|
||||||
return Map.of(false, onlyKeyValue + "已经报名" + typeMap.get(courseMap.get(hisRecord.getCourseId())) + "分场,存在冲突");
|
return Map.of(false, onlyKeyValue + "已经报名“" + courseNameMap.getOrDefault(hisRecord.getCourseId(), "原子活动")
|
||||||
|
+ "”,与当前子活动存在冲突");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -12,6 +12,7 @@ import org.nutz.dao.sql.Criteria;
|
|||||||
import org.nutz.dao.sql.Sql;
|
import org.nutz.dao.sql.Sql;
|
||||||
import org.nutz.ioc.loader.annotation.IocBean;
|
import org.nutz.ioc.loader.annotation.IocBean;
|
||||||
import org.nutz.lang.Lang;
|
import org.nutz.lang.Lang;
|
||||||
|
import org.nutz.lang.Strings;
|
||||||
import org.nutz.lang.util.NutMap;
|
import org.nutz.lang.util.NutMap;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -51,9 +52,12 @@ public class TrainSignUpUserServiceImpl extends BaseServiceImpl<TrainSignUpBlack
|
|||||||
LEFT JOIN train_sign_up_course tsuc ON tsuc.id = tsuu.courseId
|
LEFT JOIN train_sign_up_course tsuc ON tsuc.id = tsuu.courseId
|
||||||
LEFT JOIN train_sign_up_black_list tsubl on tsubl.userId = tsuu.userId
|
LEFT JOIN train_sign_up_black_list tsubl on tsubl.userId = tsuu.userId
|
||||||
$condition
|
$condition
|
||||||
ORDER BY FIELD( tsuu.state, 1, 3, 2, 4 )
|
|
||||||
""");
|
""");
|
||||||
cnd.groupBy("tsuu.userId");
|
cnd.groupBy("tsuu.userId");
|
||||||
|
// 默认按工号正序,工号相同时用用户 ID 保持分页稳定;手动列排序沿用传入条件。
|
||||||
|
if (Strings.isBlank(pageForm.getPageOrderName()) || Strings.isBlank(pageForm.getPageOrderBy())) {
|
||||||
|
cnd.asc("u.loginname").asc("tsuu.userId");
|
||||||
|
}
|
||||||
Criteria varCnd = Cnd.cri();
|
Criteria varCnd = Cnd.cri();
|
||||||
varCnd.where().setTop(false);
|
varCnd.where().setTop(false);
|
||||||
varCnd.where().andEX("activityId", "=", activityId);
|
varCnd.where().andEX("activityId", "=", activityId);
|
||||||
|
|||||||
+16
-1
@@ -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())) {
|
||||||
|
|||||||
+3
-117
@@ -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()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+410
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+380
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+247
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+110
@@ -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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
+412
@@ -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 + "秒";
|
||||||
|
}
|
||||||
|
}
|
||||||
+615
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
package com.budwk.app.zhgh.learning.service;
|
||||||
|
|
||||||
|
import com.budwk.app.zhgh.learning.models.LearningCourseOutline;
|
||||||
|
import com.budwk.app.zhgh.learning.models.LearningOutlineResource;
|
||||||
|
import com.budwk.app.zhgh.learning.models.LearningStudyRecord;
|
||||||
|
import com.budwk.app.zhgh.learning.models.LearningStudyRule;
|
||||||
|
import org.nutz.dao.Chain;
|
||||||
|
import org.nutz.dao.Cnd;
|
||||||
|
import org.nutz.dao.Dao;
|
||||||
|
import org.nutz.dao.Sqls;
|
||||||
|
import org.nutz.dao.sql.Sql;
|
||||||
|
import org.nutz.ioc.loader.annotation.Inject;
|
||||||
|
import org.nutz.ioc.loader.annotation.IocBean;
|
||||||
|
import org.nutz.json.Json;
|
||||||
|
import org.nutz.lang.util.NutMap;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@IocBean
|
||||||
|
public class LearningResourceProgressService {
|
||||||
|
@Inject
|
||||||
|
private Dao dao;
|
||||||
|
|
||||||
|
public LearningStudyRecord lock(String id) {
|
||||||
|
Sql sql = Sqls.create("SELECT * FROM learning_study_record WHERE id = @id FOR UPDATE");
|
||||||
|
sql.params().set("id", id);
|
||||||
|
sql.setEntity(dao.getEntity(LearningStudyRecord.class));
|
||||||
|
sql.setCallback(Sqls.callback.entity());
|
||||||
|
dao.execute(sql);
|
||||||
|
return sql.getObject(LearningStudyRecord.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public NutMap state(LearningStudyRecord record, LearningOutlineResource resource) {
|
||||||
|
NutMap totals = NutMap.NEW();
|
||||||
|
if (record != null) {
|
||||||
|
Sql sql = Sqls.create("""
|
||||||
|
SELECT COUNT(1) AS visits, COALESCE(SUM(activeSeconds), 0) AS studySeconds,
|
||||||
|
COALESCE(SUM(CASE WHEN state = 'finished' THEN 1 ELSE 0 END), 0) AS finishes
|
||||||
|
FROM learning_study_segment WHERE recordId = @recordId AND resourceId = @resourceId
|
||||||
|
""");
|
||||||
|
sql.params().set("recordId", record.getId()).set("resourceId", resource.getId());
|
||||||
|
sql.setCallback(Sqls.callback.map());
|
||||||
|
dao.execute(sql);
|
||||||
|
totals = sql.getObject(NutMap.class);
|
||||||
|
}
|
||||||
|
if (totals == null) totals = NutMap.NEW();
|
||||||
|
NutMap positions = positions(record);
|
||||||
|
String id = resource.getId();
|
||||||
|
int seconds = Math.max(0, totals.getInt("studySeconds", 0));
|
||||||
|
int required = Math.max(0, resource.getDurationSeconds() == null ? 0 : resource.getDurationSeconds());
|
||||||
|
boolean studied = totals.getInt("visits", 0) > 0;
|
||||||
|
boolean media = "video".equals(resource.getResourceType()) || "audio".equals(resource.getResourceType());
|
||||||
|
boolean completed = positions.getInt("completed:" + id, 0) == 1
|
||||||
|
|| (required > 0 && seconds >= required)
|
||||||
|
|| (!media && required == 0 && totals.getInt("finishes", 0) > 0);
|
||||||
|
int percent = completed ? 100 : required > 0 ? Math.min(99, (int) (seconds * 100.0 / required)) : 0;
|
||||||
|
int maxPosition = Math.max(0, positions.getInt(id, 0));
|
||||||
|
return NutMap.NEW().addv("resourceId", id).addv("studied", studied)
|
||||||
|
.addv("studySeconds", seconds).addv("requiredSeconds", required)
|
||||||
|
.addv("progressPercent", percent)
|
||||||
|
.addv("completeStatus", completed ? "completed" : studied ? "studying" : "not_started")
|
||||||
|
.addv("lastPositionSeconds", Math.max(0, positions.getInt("last:" + id, maxPosition)))
|
||||||
|
.addv("maxPositionSeconds", maxPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留原有资源最高位置键,附加独立续播位置和完成标记,不修改数据库结构。
|
||||||
|
public void savePosition(LearningStudyRecord record, String resourceId, Integer position, Integer maxPosition, boolean completed) {
|
||||||
|
NutMap positions = positions(record);
|
||||||
|
if (position != null && position >= 0) positions.put("last:" + resourceId, position);
|
||||||
|
if (maxPosition != null) positions.put(resourceId, Math.max(positions.getInt(resourceId, 0), maxPosition));
|
||||||
|
if (completed) positions.put("completed:" + resourceId, 1);
|
||||||
|
record.setResourceMaxPositionData(Json.toJson(positions));
|
||||||
|
}
|
||||||
|
|
||||||
|
public LearningStudyRule outlineRule(String outlineId) {
|
||||||
|
return dao.fetch(LearningStudyRule.class, Cnd.where("targetType", "=", "outline")
|
||||||
|
.and("targetId", "=", outlineId).and("status", "=", "enabled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int requiredSeconds(String outlineId) {
|
||||||
|
LearningStudyRule rule = outlineRule(outlineId);
|
||||||
|
if (rule != null && "min_study_time".equals(rule.getCompletionRule())) {
|
||||||
|
return Math.max(0, rule.getMinStudySeconds() == null ? 0 : rule.getMinStudySeconds());
|
||||||
|
}
|
||||||
|
Sql sql = Sqls.create("SELECT COALESCE(SUM(durationSeconds), 0) FROM learning_outline_resource WHERE outlineId = @outlineId AND status = 'enabled' AND COALESCE(allowPreview, 1) = 1");
|
||||||
|
sql.params().set("outlineId", outlineId);
|
||||||
|
sql.setCallback(Sqls.callback.integer());
|
||||||
|
dao.execute(sql);
|
||||||
|
return Math.max(0, sql.getInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
public NutMap outlineState(LearningStudyRecord record, LearningCourseOutline outline) {
|
||||||
|
List<LearningOutlineResource> resources = outline != null
|
||||||
|
&& "enabled".equals(outline.getStatus())
|
||||||
|
? dao.query(LearningOutlineResource.class, Cnd.where("outlineId", "=", outline.getId())
|
||||||
|
.and("courseId", "=", outline.getCourseId()).and("status", "=", "enabled")
|
||||||
|
.and(Cnd.exps("allowPreview", "=", true).or("allowPreview", "is", null))) : List.of();
|
||||||
|
int seconds = 0;
|
||||||
|
int totalPercent = 0;
|
||||||
|
boolean studied = false;
|
||||||
|
boolean completed = !resources.isEmpty();
|
||||||
|
for (LearningOutlineResource resource : resources) {
|
||||||
|
NutMap state = state(record, resource);
|
||||||
|
seconds += state.getInt("studySeconds", 0);
|
||||||
|
totalPercent += state.getInt("progressPercent", 0);
|
||||||
|
studied |= state.getBoolean("studied", false);
|
||||||
|
completed &= "completed".equals(state.getString("completeStatus"));
|
||||||
|
}
|
||||||
|
int percent = resources.isEmpty() ? 0 : totalPercent / resources.size();
|
||||||
|
LearningStudyRule rule = outline == null ? null : outlineRule(outline.getId());
|
||||||
|
int required = resources.isEmpty() ? 0 : requiredSeconds(outline.getId());
|
||||||
|
String mode = rule == null ? "all_required_resource" : rule.getCompletionRule();
|
||||||
|
if (!resources.isEmpty() && "resource_progress".equals(mode)) {
|
||||||
|
int threshold = rule.getCompletePercent() == null ? 100 : Math.max(1, Math.min(100, rule.getCompletePercent()));
|
||||||
|
completed = studied && totalPercent >= threshold * resources.size();
|
||||||
|
percent = (int) Math.min(100, totalPercent * 100L / (resources.size() * threshold));
|
||||||
|
} else if (!resources.isEmpty() && "min_study_time".equals(mode)) {
|
||||||
|
completed = studied && required > 0 && seconds >= required;
|
||||||
|
percent = required > 0 ? (int) Math.min(100, seconds * 100L / required) : 0;
|
||||||
|
}
|
||||||
|
return NutMap.NEW().addv("counted", !resources.isEmpty()).addv("studySeconds", seconds)
|
||||||
|
.addv("requiredSeconds", required).addv("progressPercent", completed ? 100 : Math.min(99, percent))
|
||||||
|
.addv("completeStatus", completed ? "completed" : studied ? "studying" : "not_started");
|
||||||
|
}
|
||||||
|
|
||||||
|
public NutMap courseState(String courseId, String userId) {
|
||||||
|
List<LearningStudyRecord> records = dao.query(LearningStudyRecord.class,
|
||||||
|
Cnd.where("courseId", "=", courseId).and("userId", "=", userId));
|
||||||
|
java.util.Map<String, LearningStudyRecord> recordMap = new java.util.HashMap<>();
|
||||||
|
for (LearningStudyRecord record : records) recordMap.put(record.getOutlineId(), record);
|
||||||
|
int count = 0, completed = 0, percent = 0, seconds = 0;
|
||||||
|
for (LearningCourseOutline outline : dao.query(LearningCourseOutline.class,
|
||||||
|
Cnd.where("courseId", "=", courseId).and("status", "=", "enabled"))) {
|
||||||
|
NutMap state = outlineState(recordMap.get(outline.getId()), outline);
|
||||||
|
if (!state.getBoolean("counted", false)) continue;
|
||||||
|
count++;
|
||||||
|
percent += state.getInt("progressPercent", 0);
|
||||||
|
seconds += state.getInt("studySeconds", 0);
|
||||||
|
if ("completed".equals(state.getString("completeStatus"))) completed++;
|
||||||
|
}
|
||||||
|
return NutMap.NEW().addv("outlineCount", count).addv("completedOutlineCount", completed)
|
||||||
|
.addv("progressPercent", count == 0 ? 0 : percent / count).addv("studySeconds", seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void refreshRecords(Cnd condition) {
|
||||||
|
for (LearningStudyRecord record : dao.query(LearningStudyRecord.class, condition)) {
|
||||||
|
refreshOutline(record, requiredSeconds(record.getOutlineId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void refreshOutline(String outlineId) {
|
||||||
|
refreshRecords(Cnd.where("outlineId", "=", outlineId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void refreshOutline(LearningStudyRecord record, int requiredSeconds) {
|
||||||
|
NutMap state = outlineState(record, dao.fetch(LearningCourseOutline.class, record.getOutlineId()));
|
||||||
|
boolean completed = "completed".equals(state.getString("completeStatus"));
|
||||||
|
dao.update(LearningStudyRecord.class, Chain.make("studySeconds", state.getInt("studySeconds", 0))
|
||||||
|
.add("requiredSeconds", state.getInt("requiredSeconds", 0)).add("progressPercent", state.getInt("progressPercent", 0))
|
||||||
|
.add("completeStatus", state.getString("completeStatus"))
|
||||||
|
.add("completedAt", completed ? record.getCompletedAt() == null ? new Date() : record.getCompletedAt() : null),
|
||||||
|
Cnd.where("id", "=", record.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private NutMap positions(LearningStudyRecord record) {
|
||||||
|
if (record == null || record.getResourceMaxPositionData() == null) return NutMap.NEW();
|
||||||
|
try {
|
||||||
|
NutMap positions = Json.fromJson(NutMap.class, record.getResourceMaxPositionData());
|
||||||
|
return positions == null ? NutMap.NEW() : positions;
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return NutMap.NEW();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -6,6 +6,7 @@ import com.budwk.app.base.result.Result;
|
|||||||
import com.budwk.app.base.service.BaseService;
|
import com.budwk.app.base.service.BaseService;
|
||||||
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.WelfareMemberAddress;
|
import com.budwk.app.zhgh.welfare.model.WelfareMemberAddress;
|
||||||
|
import com.budwk.app.zhgh.welfare.service.WelfareAddressManageService;
|
||||||
import org.nutz.aop.interceptor.ioc.TransAop;
|
import org.nutz.aop.interceptor.ioc.TransAop;
|
||||||
import org.nutz.dao.Chain;
|
import org.nutz.dao.Chain;
|
||||||
import org.nutz.dao.Cnd;
|
import org.nutz.dao.Cnd;
|
||||||
@@ -18,6 +19,8 @@ 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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @ClassName WelfareAddressManageController
|
* @ClassName WelfareAddressManageController
|
||||||
* @Description 福利地址管理
|
* @Description 福利地址管理
|
||||||
@@ -31,12 +34,28 @@ public class WelfareAddressManageController {
|
|||||||
@Inject
|
@Inject
|
||||||
private BaseService baseService;
|
private BaseService baseService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private WelfareAddressManageService welfareAddressManageService;
|
||||||
|
|
||||||
@At("")
|
@At("")
|
||||||
@Ok("beetl:/platform/zhgh/welfare/addressManage/index.html")
|
@Ok("beetl:/platform/zhgh/welfare/addressManage/index.html")
|
||||||
@SaCheckPermission("welfare.addressManage")
|
@SaCheckPermission("welfare.addressManage")
|
||||||
public void index() {
|
public void index() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出全部用户的收货地址,不接收用户或分页筛选参数。
|
||||||
|
* 无返回对象,文件内容直接写入响应。
|
||||||
|
*
|
||||||
|
* @param response 文件下载响应,输出包含工号、姓名、收货地址三列的 XLSX 文件
|
||||||
|
*/
|
||||||
|
@At
|
||||||
|
@Ok("void")
|
||||||
|
@SaCheckPermission("welfare.addressManage")
|
||||||
|
public void exportAllAddress(HttpServletResponse response) {
|
||||||
|
welfareAddressManageService.exportAllAddress(response);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 地址列表
|
* 地址列表
|
||||||
*
|
*
|
||||||
|
|||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package com.budwk.app.zhgh.welfare.h5Controller;
|
||||||
|
|
||||||
|
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/h5/welfare/selection/situation")
|
||||||
|
public class H5WelfareSelectionSituationController {
|
||||||
|
|
||||||
|
@At("")
|
||||||
|
@Ok("beetl:/platform/zhghh5/welfare/selectionSituation/index.html")
|
||||||
|
@SaCheckPermission("h5.welfare.selection.situation")
|
||||||
|
public void index() {
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
package com.budwk.app.zhgh.welfare.h5Controller;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.budwk.app.base.param.PageForm;
|
||||||
|
import com.budwk.app.base.result.Result;
|
||||||
|
import com.budwk.app.zhgh.welfare.service.WelfareProjectService;
|
||||||
|
import com.budwk.app.zhgh.welfare.service.WelfareStatisticsService;
|
||||||
|
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 javax.validation.Valid;
|
||||||
|
|
||||||
|
/** 移动端福利统计只读入口;统计和人员查询业务统一由现有 service 承担。 */
|
||||||
|
@IocBean
|
||||||
|
@At("/platform/h5/welfare/statistics")
|
||||||
|
@Ok("json:full")
|
||||||
|
public class H5WelfareStatisticsController {
|
||||||
|
@Inject
|
||||||
|
private WelfareStatisticsService welfareStatisticsService;
|
||||||
|
@Inject
|
||||||
|
private WelfareProjectService welfareProjectService;
|
||||||
|
|
||||||
|
@At("")
|
||||||
|
@Ok("beetl:/platform/zhghh5/welfare/statistics/index.html")
|
||||||
|
@SaCheckPermission("h5.welfare.statistics")
|
||||||
|
public void index() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param year 可选年份,省略时返回全部年份的项目,供移动端联动筛选
|
||||||
|
* @return Result.data 为项目数组,使用 id、name、year 生成筛选项
|
||||||
|
*/
|
||||||
|
@At
|
||||||
|
@SaCheckPermission("h5.welfare.statistics")
|
||||||
|
public Result projects(Integer year) {
|
||||||
|
return Result.success(welfareProjectService.getWelfareList(year));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param projectId 必填福利项目 ID
|
||||||
|
* @param unionId 可选工会 ID,空表示全部工会
|
||||||
|
* @param isSelectByAdmin null 全部方式,true 管理员代选,false 个人选择
|
||||||
|
* @return Result.data 含 tableList 工会人数统计、tableColumn 固定列和动态福利选项列
|
||||||
|
*/
|
||||||
|
@At
|
||||||
|
@SaCheckPermission("h5.welfare.statistics")
|
||||||
|
public Result pageData(String projectId, String unionId, Boolean isSelectByAdmin) {
|
||||||
|
if (StrUtil.isBlank(projectId)) return Result.error("请选择福利项目");
|
||||||
|
return Result.success(welfareStatisticsService.mobilePageData(projectId, unionId, isSelectByAdmin));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param pageForm pageNumber 从 1 开始,pageSize 每页条数,searchKeyword 为工号或姓名
|
||||||
|
* @param projectId 必填福利项目 ID
|
||||||
|
* @param unionId 必填工会 ID
|
||||||
|
* @param isSelectByAdmin null 全部方式,true 管理员代选,false 个人选择
|
||||||
|
* @return Result.data 为 Pagination,list 含人员、单位、所选福利及选择方式,totalCount 为匹配人数
|
||||||
|
*/
|
||||||
|
@At
|
||||||
|
@SaCheckPermission("h5.welfare.statistics")
|
||||||
|
public Result selectedUnionUserPageData(@Valid PageForm pageForm, String projectId, String unionId, Boolean isSelectByAdmin) {
|
||||||
|
if (StrUtil.isBlank(projectId) || StrUtil.isBlank(unionId)) return Result.error("请选择福利项目和工会");
|
||||||
|
return Result.success(welfareStatisticsService.selectedUnionUserPageData(pageForm, projectId, unionId, isSelectByAdmin));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param pageForm pageNumber 从 1 开始,pageSize 每页条数,searchKeyword 为工号或姓名
|
||||||
|
* @param projectId 必填福利项目 ID
|
||||||
|
* @param unionId 必填工会 ID;未选人员不受个人选择/管理员代选条件影响
|
||||||
|
* @return Result.data 为 Pagination,list 含未选人员的工号、姓名、单位,totalCount 为匹配人数
|
||||||
|
*/
|
||||||
|
@At
|
||||||
|
@SaCheckPermission("h5.welfare.statistics")
|
||||||
|
public Result unSelectedUnionUserPageData(@Valid PageForm pageForm, String projectId, String unionId) {
|
||||||
|
if (StrUtil.isBlank(projectId) || StrUtil.isBlank(unionId)) return Result.error("请选择福利项目和工会");
|
||||||
|
return Result.success(welfareStatisticsService.unSelectedUnionUserPageData(pageForm, projectId, unionId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.budwk.app.zhgh.welfare.service;
|
||||||
|
|
||||||
|
import com.budwk.app.base.service.BaseService;
|
||||||
|
import com.budwk.app.zhgh.welfare.model.WelfareMemberAddress;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 福利收货地址管理服务。
|
||||||
|
*/
|
||||||
|
public interface WelfareAddressManageService extends BaseService<WelfareMemberAddress> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅导出有收货地址的人员,每人一行,多地址优先取默认地址。
|
||||||
|
* 无返回对象,文件内容直接写入响应。
|
||||||
|
*
|
||||||
|
* @param response 文件下载响应,输出工号、姓名、收货地址三列的 XLSX 文件
|
||||||
|
*/
|
||||||
|
void exportAllAddress(HttpServletResponse response);
|
||||||
|
}
|
||||||
@@ -18,6 +18,16 @@ public interface WelfareStatisticsService extends BaseService<WelfareProject> {
|
|||||||
*/
|
*/
|
||||||
NutMap pageData(String projectId, String unionId, Boolean isSelectByAdmin);
|
NutMap pageData(String projectId, String unionId, Boolean isSelectByAdmin);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动端工会统计;方式筛选只影响已选人数和各福利选项人数。
|
||||||
|
* @param projectId 必填福利项目 ID
|
||||||
|
* @param unionId 工会 ID,空值表示全部工会
|
||||||
|
* @param isSelectByAdmin null 表示全部方式,true 表示管理员代选,false 表示个人选择
|
||||||
|
* @return tableList 为工会统计行(teacherSum 总人数、selectedNum 已选、unSelectedNum 真正未选);
|
||||||
|
* tableColumn 为列描述(label 名称、prop 字段),其中包含动态福利选项人数列
|
||||||
|
*/
|
||||||
|
NutMap mobilePageData(String projectId, String unionId, Boolean isSelectByAdmin);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询某分工会已选择人员
|
* 查询某分工会已选择人员
|
||||||
* @param pageForm
|
* @param pageForm
|
||||||
|
|||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package com.budwk.app.zhgh.welfare.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 com.budwk.app.base.service.impl.BaseServiceImpl;
|
||||||
|
import com.budwk.app.base.utils.CommonDownloadUtil;
|
||||||
|
import com.budwk.app.zhgh.welfare.model.WelfareMemberAddress;
|
||||||
|
import com.budwk.app.zhgh.welfare.service.WelfareAddressManageService;
|
||||||
|
import org.apache.poi.ss.usermodel.Workbook;
|
||||||
|
import org.nutz.dao.Dao;
|
||||||
|
import org.nutz.dao.Sqls;
|
||||||
|
import org.nutz.dao.sql.Sql;
|
||||||
|
import org.nutz.ioc.loader.annotation.IocBean;
|
||||||
|
import org.nutz.lang.util.NutMap;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 汇总用户与福利地址信息,生成每人一行的收货地址表。
|
||||||
|
*/
|
||||||
|
@IocBean(args = {"refer:dao"})
|
||||||
|
public class WelfareAddressManageServiceImpl extends BaseServiceImpl<WelfareMemberAddress> implements WelfareAddressManageService {
|
||||||
|
|
||||||
|
public WelfareAddressManageServiceImpl(Dao dao) {
|
||||||
|
super(dao);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出有收货地址人员的工号、姓名和完整收货地址,按工号排序。
|
||||||
|
* 无返回对象,XLSX 文件直接写入响应。
|
||||||
|
*
|
||||||
|
* @param response 文件下载响应,输出工号、姓名、收货地址三列的 XLSX 文件
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void exportAllAddress(HttpServletResponse response) {
|
||||||
|
// 沿用福利地址导出的排序规则:默认地址优先;未设默认或多个默认时按 ID 稳定选取一条。
|
||||||
|
// 仅关联存在地址记录的人员;逐段处理空值,避免部分地区字段为空导致整条地址丢失。
|
||||||
|
Sql sql = Sqls.create("""
|
||||||
|
SELECT
|
||||||
|
u.loginname,
|
||||||
|
u.username,
|
||||||
|
CONCAT('收货人:', IFNULL(a.userName, ''), ' 电话:', IFNULL(a.tel, ''),
|
||||||
|
'详细地址:', IFNULL(a.province, ''), IFNULL(a.city, ''),
|
||||||
|
IFNULL(a.county, ''), IFNULL(a.addressDetail, '')) AS address
|
||||||
|
FROM sys_user u
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT userId, userName, tel, province, city, county, addressDetail,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY userId ORDER BY isDefault DESC, id) AS rn
|
||||||
|
FROM welfare_member_address
|
||||||
|
) a ON a.userId = u.id AND a.rn = 1
|
||||||
|
ORDER BY u.loginname, u.id
|
||||||
|
""");
|
||||||
|
List<NutMap> list = listMap(sql);
|
||||||
|
|
||||||
|
// 工号按字符串导出,保留前导零;收货地址合并所选地址的收货人、电话及省市区县和详细地址。
|
||||||
|
List<ExcelExportEntity> entities = new ArrayList<>();
|
||||||
|
entities.add(new ExcelExportEntity("工号", "loginname", 20));
|
||||||
|
entities.add(new ExcelExportEntity("姓名", "username", 20));
|
||||||
|
entities.add(new ExcelExportEntity("收货地址", "address", 70));
|
||||||
|
|
||||||
|
ExportParams exportParams = new ExportParams();
|
||||||
|
exportParams.setType(ExcelType.XSSF);
|
||||||
|
Workbook workbook = ExcelExportUtil.exportExcel(exportParams, entities, list);
|
||||||
|
// 全局下载方法负责写入响应并关闭工作簿。
|
||||||
|
CommonDownloadUtil.download("所有人收货地址.xlsx", workbook, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -415,6 +415,8 @@ public class WelfareListServiceImpl extends BaseServiceImpl<WelfareList> impleme
|
|||||||
cnd.andEX("t1.preparedBy", "in", pageForm.getPreparedBys());
|
cnd.andEX("t1.preparedBy", "in", pageForm.getPreparedBys());
|
||||||
cnd.andEX("t1.userState", "in", pageForm.getUserStates());
|
cnd.andEX("t1.userState", "in", pageForm.getUserStates());
|
||||||
if (StrUtil.isAllBlank(pageForm.getPageOrderName(), pageForm.getPageOrderBy())) {
|
if (StrUtil.isAllBlank(pageForm.getPageOrderName(), pageForm.getPageOrderBy())) {
|
||||||
|
// 默认先按工号正序分页,工号相同时沿用工会和单位顺序。
|
||||||
|
cnd.asc("t2.loginname");
|
||||||
cnd.asc("t1.welfareUnionName");
|
cnd.asc("t1.welfareUnionName");
|
||||||
cnd.asc("t1.welfareUnitName");
|
cnd.asc("t1.welfareUnitName");
|
||||||
} else {
|
} else {
|
||||||
@@ -644,32 +646,52 @@ public class WelfareListServiceImpl extends BaseServiceImpl<WelfareList> impleme
|
|||||||
LEFT JOIN sys_user t2 ON t2.id = t1.userId
|
LEFT JOIN sys_user t2 ON t2.id = t1.userId
|
||||||
$condition
|
$condition
|
||||||
""");
|
""");
|
||||||
Cnd cnd = Cnd.NEW();
|
// 只统计当前福利项目名单中的身份证号;同号名单记录至少两条才属于重复组。
|
||||||
cnd.and("t1.projectId", "=", pageForm.getProjectId());
|
Sql repeatIdCardSql = Sqls.create("""
|
||||||
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"));
|
select u.idCard from welfare_list w
|
||||||
|
inner join sys_user u on u.id = w.userId
|
||||||
|
where w.projectId = @projectId and u.idCard is not null and u.idCard != ''
|
||||||
|
group by u.idCard having count(1) > 1
|
||||||
|
""").setParam("projectId", pageForm.getProjectId());
|
||||||
|
|
||||||
|
// 页面筛选只决定导出哪些重复组,匹配到的身份证号用于补齐同组全部记录。
|
||||||
|
Sql matchedIdCardSql = Sqls.create("""
|
||||||
|
select distinct t2.idCard from welfare_list t1
|
||||||
|
inner join sys_user t2 on t2.id = t1.userId
|
||||||
|
$condition
|
||||||
|
""");
|
||||||
|
Cnd matchedCnd = Cnd.NEW();
|
||||||
|
matchedCnd.and("t1.projectId", "=", pageForm.getProjectId());
|
||||||
|
matchedCnd.and("t2.idCard", "in", repeatIdCardSql);
|
||||||
if (StrUtil.isNotBlank(pageForm.getUserName())) {
|
if (StrUtil.isNotBlank(pageForm.getUserName())) {
|
||||||
cnd.where().andLike("t2.username", pageForm.getUserName());
|
matchedCnd.where().andLike("t2.username", pageForm.getUserName());
|
||||||
}
|
}
|
||||||
if (StrUtil.isNotBlank(pageForm.getLoginName())) {
|
if (StrUtil.isNotBlank(pageForm.getLoginName())) {
|
||||||
cnd.where().andLike("t2.loginname", pageForm.getLoginName());
|
matchedCnd.where().andLike("t2.loginname", pageForm.getLoginName());
|
||||||
}
|
}
|
||||||
cnd.andEX("t2.sex", "=", pageForm.getSex());
|
matchedCnd.andEX("t2.sex", "=", pageForm.getSex());
|
||||||
cnd.andEX("t2.birthday", "=", pageForm.getBirthday());
|
matchedCnd.andEX("t2.birthday", "=", pageForm.getBirthday());
|
||||||
if (pageForm.getBirthMonths() != null && pageForm.getBirthMonths().length > 0) {
|
if (pageForm.getBirthMonths() != null && pageForm.getBirthMonths().length > 0) {
|
||||||
cnd.andEX("MONTH(t2.birthday)", "in", pageForm.getBirthMonths());
|
matchedCnd.andEX("MONTH(t2.birthday)", "in", pageForm.getBirthMonths());
|
||||||
}
|
}
|
||||||
cnd.andEX("t1.welfareUnitId", "in", pageForm.getUnitIds());
|
matchedCnd.andEX("t1.welfareUnitId", "in", pageForm.getUnitIds());
|
||||||
cnd.andEX("t1.welfareUnionId", "=", pageForm.getUnionId());
|
matchedCnd.andEX("t1.welfareUnionId", "=", pageForm.getUnionId());
|
||||||
cnd.andEX("t1.personType", "in", pageForm.getPersonTypes());
|
matchedCnd.andEX("t1.personType", "in", pageForm.getPersonTypes());
|
||||||
cnd.andEX("t1.preparedBy", "in", pageForm.getPreparedBys());
|
matchedCnd.andEX("t1.preparedBy", "in", pageForm.getPreparedBys());
|
||||||
cnd.andEX("t1.userState", "in", pageForm.getUserStates());
|
matchedCnd.andEX("t1.userState", "in", pageForm.getUserStates());
|
||||||
|
matchedIdCardSql.setCondition(matchedCnd);
|
||||||
|
List<NutMap> matchedIdCards = listMap(matchedIdCardSql);
|
||||||
|
List<NutMap> list = new ArrayList<>();
|
||||||
|
if (!matchedIdCards.isEmpty()) {
|
||||||
|
Cnd cnd = Cnd.NEW();
|
||||||
|
cnd.and("t1.projectId", "=", pageForm.getProjectId());
|
||||||
|
cnd.and("t2.idCard", "in", matchedIdCards.stream().map(item -> item.getString("idCard")).toArray(String[]::new));
|
||||||
cnd.asc("t2.idCard");
|
cnd.asc("t2.idCard");
|
||||||
cnd.asc("t1.welfareUnionName");
|
cnd.asc("t2.loginname");
|
||||||
cnd.asc("t1.welfareUnitName");
|
cnd.asc("t1.id");
|
||||||
|
|
||||||
sql.setCondition(cnd);
|
sql.setCondition(cnd);
|
||||||
List<NutMap> list = listMap(sql);
|
list = listMap(sql);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
List<ExcelExportEntity> exportEntities = new ArrayList<>();
|
List<ExcelExportEntity> exportEntities = new ArrayList<>();
|
||||||
|
|||||||
+2
@@ -102,6 +102,8 @@ public class WelfareSelectionSituationServiceImpl extends BaseServiceImpl<Welfar
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (StrUtil.isAllBlank(pageForm.getPageOrderName(), pageForm.getPageOrderBy())) {
|
if (StrUtil.isAllBlank(pageForm.getPageOrderName(), pageForm.getPageOrderBy())) {
|
||||||
|
// 默认先按工号正序分页,工号相同时沿用工会和单位顺序。
|
||||||
|
cnd.asc("t4.loginname");
|
||||||
cnd.asc("welfareUnionName");
|
cnd.asc("welfareUnionName");
|
||||||
cnd.asc("welfareUnitName");
|
cnd.asc("welfareUnitName");
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+20
@@ -138,6 +138,26 @@ public class WelfareStatisticsServiceImpl extends BaseServiceImpl<WelfareProject
|
|||||||
return NutMap.NEW().addv("tableList", mapUnions).addv("tableColumn", dynamicTableColumns);
|
return NutMap.NEW().addv("tableList", mapUnions).addv("tableColumn", dynamicTableColumns);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 沿用现有统计查询;筛选选择方式时,以不限方式的未选人数匹配未选人员明细口径。
|
||||||
|
* 例如已经个人选择的人,在筛选管理员代选时不会被计为未选择。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public NutMap mobilePageData(String projectId, String unionId, Boolean isSelectByAdmin) {
|
||||||
|
NutMap result = pageData(projectId, unionId, isSelectByAdmin);
|
||||||
|
if (isSelectByAdmin != null) {
|
||||||
|
NutMap allMethods = pageData(projectId, unionId, null);
|
||||||
|
Map<String, Long> unSelectedByUnion = new HashMap<>();
|
||||||
|
for (NutMap row : allMethods.getList("tableList", NutMap.class)) {
|
||||||
|
unSelectedByUnion.put(row.getString("id"), row.getLong("unSelectedNum"));
|
||||||
|
}
|
||||||
|
for (NutMap row : result.getList("tableList", NutMap.class)) {
|
||||||
|
row.put("unSelectedNum", unSelectedByUnion.getOrDefault(row.getString("id"), 0L));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Pagination selectedUnionUserPageData(PageForm pageForm, String projectId, String unionId, Boolean isSelectByAdmin) {
|
public Pagination selectedUnionUserPageData(PageForm pageForm, String projectId, String unionId, Boolean isSelectByAdmin) {
|
||||||
Sql sql = Sqls.create("""
|
Sql sql = Sqls.create("""
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 |
@@ -14,7 +14,8 @@ const h5PopupHistory = (() => {
|
|||||||
popup.closeFromHistory()
|
popup.closeFromHistory()
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("popstate", handlePopState)
|
// 捕获阶段优先消费弹框返回事件,避免 PJAX 重载页面并重置筛选条件。
|
||||||
|
window.addEventListener("popstate", handlePopState, true)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/**
|
/**
|
||||||
@@ -37,11 +38,13 @@ const h5PopupHistory = (() => {
|
|||||||
/**
|
/**
|
||||||
* 通过取消、关闭按钮关闭指定弹框时,同步回退对应历史记录。
|
* 通过取消、关闭按钮关闭指定弹框时,同步回退对应历史记录。
|
||||||
* @param id 弹框唯一标识,必须是当前最上层弹框
|
* @param id 弹框唯一标识,必须是当前最上层弹框
|
||||||
* @return boolean;true 表示已发起历史回退,false 表示弹框不在栈顶
|
* @return boolean;true 表示已发起历史回退,false 表示弹框不在栈顶或正在关闭
|
||||||
*/
|
*/
|
||||||
close(id) {
|
close(id) {
|
||||||
const top = popupStack[popupStack.length - 1]
|
const top = popupStack[popupStack.length - 1]
|
||||||
if (!top || top.id !== id) return false
|
if (!top || top.id !== id || top.closing) return false
|
||||||
|
// history.back 异步触发 popstate;等待期间重复点击或关闭回调不能再回退一层。
|
||||||
|
top.closing = true
|
||||||
window.history.back()
|
window.history.back()
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
.field .van-field__error-message {
|
.field .van-field__error-message {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 与测量节点共用排版,确保滚轮每一行都能容纳完整选项。 */
|
||||||
|
.train-picker-option-text {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 22px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
@@ -89,8 +100,11 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<van-picker
|
<van-picker
|
||||||
|
:key="column.columnCode + '-' + (pickerItemHeights[column.columnCode] || 44)"
|
||||||
:columns="column.selectValues"
|
:columns="column.selectValues"
|
||||||
:title="column.columnName"
|
:title="column.columnName"
|
||||||
|
:item-height="pickerItemHeights[column.columnCode] || 44"
|
||||||
|
:visible-item-count="pickerVisibleItemCounts[column.columnCode] || 6"
|
||||||
show-toolbar
|
show-toolbar
|
||||||
@cancel="ordinaryPickerCancel(column)"
|
@cancel="ordinaryPickerCancel(column)"
|
||||||
@confirm="
|
@confirm="
|
||||||
@@ -98,7 +112,11 @@
|
|||||||
ordinaryPickerConfirm(value, column)
|
ordinaryPickerConfirm(value, column)
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
/>
|
>
|
||||||
|
<template v-if="fullOptionText" #option="option">
|
||||||
|
<div class="train-picker-option-text">{{ option }}</div>
|
||||||
|
</template>
|
||||||
|
</van-picker>
|
||||||
</template>
|
</template>
|
||||||
</van-popup>
|
</van-popup>
|
||||||
</template>
|
</template>
|
||||||
@@ -140,7 +158,12 @@ module.exports = {
|
|||||||
name: "TrainDynamicForm",
|
name: "TrainDynamicForm",
|
||||||
components: {},
|
components: {},
|
||||||
props: {
|
props: {
|
||||||
dynamicData: []
|
dynamicData: [],
|
||||||
|
// 由亲子活动报名页单独启用,其余引用页面保留原有选项展示。
|
||||||
|
fullOptionText: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
model: {
|
model: {
|
||||||
prop: "dynamicData",
|
prop: "dynamicData",
|
||||||
@@ -160,6 +183,8 @@ module.exports = {
|
|||||||
return {
|
return {
|
||||||
//所有的picker状态
|
//所有的picker状态
|
||||||
pickerStates: {},
|
pickerStates: {},
|
||||||
|
pickerItemHeights: {},
|
||||||
|
pickerVisibleItemCounts: {},
|
||||||
fileList: [],
|
fileList: [],
|
||||||
addressStates: {},
|
addressStates: {},
|
||||||
addressAreaList: window.trainSignUpAreaList || { province_list: {}, city_list: {}, county_list: {} }
|
addressAreaList: window.trainSignUpAreaList || { province_list: {}, city_list: {}, county_list: {} }
|
||||||
@@ -444,7 +469,44 @@ module.exports = {
|
|||||||
const addressValue = (state.displayArea || "") + (state.detail || "")
|
const addressValue = (state.displayArea || "") + (state.detail || "")
|
||||||
this.$set(column, "columnValue", addressValue)
|
this.$set(column, "columnValue", addressValue)
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* 按当前屏幕宽度测量最长选项,统一行高以保持滚轮定位准确。
|
||||||
|
* @param column 动态字段配置,selectValues 为后台配置的字符串选项数组
|
||||||
|
* @return 无返回值;将像素行高和屏内可见行数写入对应字段的展示状态
|
||||||
|
*/
|
||||||
|
measurePickerOptions(column) {
|
||||||
|
if (!this.fullOptionText || column.columnFormType !== "SELECT"
|
||||||
|
|| ["DATE", "DATETIME"].includes(column.columnType)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const measureElement = document.createElement("div")
|
||||||
|
measureElement.className = "train-picker-option-text"
|
||||||
|
// Vant 选项左右各有 4px 内边距,测量宽度必须与实际文字区域一致。
|
||||||
|
Object.assign(measureElement.style, {
|
||||||
|
position: "fixed",
|
||||||
|
visibility: "hidden",
|
||||||
|
pointerEvents: "none",
|
||||||
|
width: Math.max(document.documentElement.clientWidth - 8, 1) + "px",
|
||||||
|
fontFamily: window.getComputedStyle(this.$el).fontFamily
|
||||||
|
})
|
||||||
|
document.body.appendChild(measureElement)
|
||||||
|
let itemHeight = 44
|
||||||
|
try {
|
||||||
|
(column.selectValues || []).forEach((option) => {
|
||||||
|
measureElement.textContent = option
|
||||||
|
itemHeight = Math.max(itemHeight, Math.ceil(measureElement.getBoundingClientRect().height) + 16)
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(measureElement)
|
||||||
|
}
|
||||||
|
this.$set(this.pickerItemHeights, column.columnCode, itemHeight)
|
||||||
|
// 多行选项减少同时展示的行数,避免滚轮随行高增加而撑出屏幕。
|
||||||
|
const availableHeight = Math.min(264, Math.max(window.innerHeight - 44, 0))
|
||||||
|
this.$set(this.pickerVisibleItemCounts, column.columnCode,
|
||||||
|
Math.max(1, Math.floor(availableHeight / itemHeight)))
|
||||||
|
},
|
||||||
selectFieldClick(column) {
|
selectFieldClick(column) {
|
||||||
|
this.measurePickerOptions(column)
|
||||||
this.$set(this.pickerStates, column.columnCode + "Picker", true)
|
this.$set(this.pickerStates, column.columnCode + "Picker", true)
|
||||||
if (!["DATE", "DATETIME"].includes(column.columnType)) {
|
if (!["DATE", "DATETIME"].includes(column.columnType)) {
|
||||||
//说明是普通的下拉框
|
//说明是普通的下拉框
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -65,8 +65,8 @@
|
|||||||
<!-- <link rel="stylesheet" href="https://vxeui.com/umd/vxe-pc-ui@3.1.25/lib/style.min.css" />-->
|
<!-- <link rel="stylesheet" href="https://vxeui.com/umd/vxe-pc-ui@3.1.25/lib/style.min.css" />-->
|
||||||
<!-- <link rel="stylesheet" href="https://vxeui.com/umd/vxe-table@3.9.0/lib/style.min.css" />-->
|
<!-- <link rel="stylesheet" href="https://vxeui.com/umd/vxe-table@3.9.0/lib/style.min.css" />-->
|
||||||
<script src="${base!}/assets/platform/plugins/vxeui/xe-utils.js"></script>
|
<script src="${base!}/assets/platform/plugins/vxeui/xe-utils.js"></script>
|
||||||
<script src="${base!}/assets/platform/plugins/vxeui/vxe-table.js"></script>
|
|
||||||
<script src="${base!}/assets/platform/plugins/vxeui/vxe-pc-ui.js"></script>
|
<script src="${base!}/assets/platform/plugins/vxeui/vxe-pc-ui.js"></script>
|
||||||
|
<script src="${base!}/assets/platform/plugins/vxeui/vxe-table.js"></script>
|
||||||
|
|
||||||
<!-- 引入 form-create 和 designer -->
|
<!-- 引入 form-create 和 designer -->
|
||||||
<script src="${base!}/assets/platform/plugins/form-create/form-create.min.js"></script>
|
<script src="${base!}/assets/platform/plugins/form-create/form-create.min.js"></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
|
||||||
}
|
}
|
||||||
//拷贝一份
|
//拷贝一份
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
<script src="${base!}/assets/platform/plugins/axios/axios.js"></script>
|
<script src="${base!}/assets/platform/plugins/axios/axios.js"></script>
|
||||||
|
|
||||||
<script src="${base!}/assets/platform/plugins/jquery/jquery.js"></script>
|
<script src="${base!}/assets/platform/plugins/jquery/jquery.js"></script>
|
||||||
|
<!-- 弹框历史必须先于 PJAX 注册,关闭弹框时优先消费返回事件,避免重新加载业务页面。 -->
|
||||||
|
<script src="${base!}/assets/platform/js/util/h5PopupHistory.js?v=20260930-2"></script>
|
||||||
<!-- pjax是异步加载html片段的工具,模拟前端路由机制 -->
|
<!-- pjax是异步加载html片段的工具,模拟前端路由机制 -->
|
||||||
<script src="${base!}/assets/platform/plugins/pjax/jquery.pjax.js"></script>
|
<script src="${base!}/assets/platform/plugins/pjax/jquery.pjax.js"></script>
|
||||||
|
|
||||||
@@ -57,7 +59,6 @@
|
|||||||
<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>
|
||||||
|
<!--#
|
||||||
|
}
|
||||||
|
#-->
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
const ACTIVITY_CULTURE_USER_STATISTICS = {
|
const ACTIVITY_CULTURE_USER_STATISTICS = {
|
||||||
template: /*language=HTML*/ `
|
template: /*language=HTML*/ `
|
||||||
<guava ref="guava">
|
<guava ref="guava" @hook:mounted="onPageVisible">
|
||||||
<template>
|
<template>
|
||||||
<el-card shadow="never">
|
<el-card shadow="never">
|
||||||
|
|
||||||
@@ -65,7 +65,8 @@ const ACTIVITY_CULTURE_USER_STATISTICS = {
|
|||||||
</el-button>
|
</el-button>
|
||||||
</table-tool>
|
</table-tool>
|
||||||
|
|
||||||
<el-table :data="tableData" :size="tableSize" @sort-change="pageOrder" row-key="id"
|
<el-table ref="tableRef" class="activity-culture-statistics-table" :data="tableData"
|
||||||
|
:max-height="tableHeight" :size="tableSize" @sort-change="pageOrder" row-key="id"
|
||||||
style="width: 100%;" v-loading="tableLoading">
|
style="width: 100%;" v-loading="tableLoading">
|
||||||
<el-table-column :index="indexMethod" align="center" header-align="center"
|
<el-table-column :index="indexMethod" align="center" header-align="center"
|
||||||
label="序号" type="index"
|
label="序号" type="index"
|
||||||
@@ -110,8 +111,10 @@ const ACTIVITY_CULTURE_USER_STATISTICS = {
|
|||||||
pageForm: {
|
pageForm: {
|
||||||
unionId: "",
|
unionId: "",
|
||||||
searchName: "atp.username",
|
searchName: "atp.username",
|
||||||
|
pageSize: 50,
|
||||||
year: new Date().getFullYear() + ""
|
year: new Date().getFullYear() + ""
|
||||||
},
|
},
|
||||||
|
tableHeight: 300,
|
||||||
tableColumns: [
|
tableColumns: [
|
||||||
{ prop: "userName", label: "姓名", width: 180, fixed: "left" },
|
{ prop: "userName", label: "姓名", width: 180, fixed: "left" },
|
||||||
{ prop: "loginName", label: "工号", width: 180, fixed: "left" },
|
{ prop: "loginName", label: "工号", width: 180, fixed: "left" },
|
||||||
@@ -133,6 +136,60 @@ const ACTIVITY_CULTURE_USER_STATISTICS = {
|
|||||||
methods: {
|
methods: {
|
||||||
getNestedValue: getNestedValue,
|
getNestedValue: getNestedValue,
|
||||||
|
|
||||||
|
// 页面在 PJAX 显示后再测量,避免首次渲染时隐藏容器给出错误高度。
|
||||||
|
onPageVisible() {
|
||||||
|
this.$nextTick(() => requestAnimationFrame(() => this.updateTableHeight()))
|
||||||
|
},
|
||||||
|
|
||||||
|
// 按当前视口和分页实际位置计算表格最大高度,保留分页及卡片底部空间。
|
||||||
|
updateTableHeight() {
|
||||||
|
if (!this.$refs.tableRef) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const table = this.$refs.tableRef.$el
|
||||||
|
const card = table.closest(".el-card")
|
||||||
|
const content = table.closest(".ele-admin-content")
|
||||||
|
const pagination = card && card.querySelector(".el-pagination-container")
|
||||||
|
if (!content || !pagination || !table.isConnected || !table.getBoundingClientRect().width || !pagination.getBoundingClientRect().height) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content.scrollTop = 0
|
||||||
|
const pageBody = table.closest(".ele-body")
|
||||||
|
const pagePadding = pageBody ? parseFloat(getComputedStyle(pageBody).paddingBottom) || 0 : 0
|
||||||
|
// 页签异步出现时内容区可能被下推,以浏览器可视底部为最终边界。
|
||||||
|
const visibleBottom = Math.min(content.getBoundingClientRect().bottom, window.innerHeight) - pagePadding - 4
|
||||||
|
const nextHeight = Math.max(120, Math.floor(table.getBoundingClientRect().height + visibleBottom - card.getBoundingClientRect().bottom))
|
||||||
|
if (nextHeight !== this.tableHeight) {
|
||||||
|
this.tableHeight = nextHeight
|
||||||
|
}
|
||||||
|
// 筛选区和卡片高度变化时重新计算,避免异步内容或窗口变化遮挡分页。
|
||||||
|
if (!this.tableResizeObserver && window.ResizeObserver) {
|
||||||
|
this.tableResizeObserver = new ResizeObserver(() => this.updateTableHeight())
|
||||||
|
const resizeTargets = [content, document.querySelector("#ele-admin-tabs"), this.$el.querySelector(".el-card"), card, card.querySelector(".ele-table-tool"), pagination]
|
||||||
|
resizeTargets.forEach((element) => {
|
||||||
|
if (element) {
|
||||||
|
this.tableResizeObserver.observe(element)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 组件离开页面时清理监听,防止旧表格继续响应尺寸变化。
|
||||||
|
stopTableResize() {
|
||||||
|
window.removeEventListener("resize", this.pageVisibleHandler)
|
||||||
|
window.removeEventListener("load", this.pageVisibleHandler)
|
||||||
|
$(document).off("pjax:send", this.pageExitHandler)
|
||||||
|
$(document).off("pjax:complete", this.pageVisibleHandler)
|
||||||
|
if (this.tabsObserver) {
|
||||||
|
this.tabsObserver.disconnect()
|
||||||
|
this.tabsObserver = null
|
||||||
|
}
|
||||||
|
if (this.tableResizeObserver) {
|
||||||
|
this.tableResizeObserver.disconnect()
|
||||||
|
this.tableResizeObserver = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
openMore(dynamicFormData) {
|
openMore(dynamicFormData) {
|
||||||
const formConfig = this.activityList.find((v) => v.id === this.pageForm.activityId)?.formConfig
|
const formConfig = this.activityList.find((v) => v.id === this.pageForm.activityId)?.formConfig
|
||||||
this.$refs.dynamicTableFormEvalRef.onOpen(formConfig, dynamicFormData)
|
this.$refs.dynamicTableFormEvalRef.onOpen(formConfig, dynamicFormData)
|
||||||
@@ -197,8 +254,42 @@ const ACTIVITY_CULTURE_USER_STATISTICS = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
mounted() {
|
||||||
|
// 原生和 jQuery 事件会改变 this,使用稳定的箭头回调以便正确执行和解绑。
|
||||||
|
this.pageVisibleHandler = () => this.onPageVisible()
|
||||||
|
this.pageExitHandler = () => this.stopTableResize()
|
||||||
|
window.addEventListener("resize", this.pageVisibleHandler)
|
||||||
|
window.addEventListener("load", this.pageVisibleHandler)
|
||||||
|
$(document).one("pjax:send", this.pageExitHandler)
|
||||||
|
$(document).on("pjax:complete", this.pageVisibleHandler)
|
||||||
|
// 顶部页签独立异步挂载,只移动内容区时 ResizeObserver 不会收到尺寸变化。
|
||||||
|
const tabs = document.querySelector("#ele-admin-tabs")
|
||||||
|
if (tabs && window.MutationObserver) {
|
||||||
|
this.tabsObserver = new MutationObserver(() => this.onPageVisible())
|
||||||
|
this.tabsObserver.observe(tabs, { childList: true, subtree: true })
|
||||||
|
}
|
||||||
|
this.onPageVisible()
|
||||||
|
},
|
||||||
|
updated() {
|
||||||
|
// 每次异步渲染后重新测量,首次取得尺寸不代表页面布局已稳定。
|
||||||
|
if (this.$refs.tableRef) {
|
||||||
|
this.onPageVisible()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.stopTableResize()
|
||||||
|
},
|
||||||
async created() {
|
async created() {
|
||||||
await this.getActivityByYearOrType()
|
await this.getActivityByYearOrType()
|
||||||
this.unionList = await this.$businessTool.listUnion()
|
this.unionList = await this.$businessTool.listUnion()
|
||||||
|
},
|
||||||
|
style: /*language=CSS*/ `
|
||||||
|
.ele-admin-fixed-body .ele-admin-content {
|
||||||
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.activity-culture-statistics-table .el-table__body-wrapper {
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
|
}
|
||||||
|
`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const signForm = {
|
|||||||
<div>
|
<div>
|
||||||
<el-dialog :close-on-click-modal="false" :visible.sync="signDialog"
|
<el-dialog :close-on-click-modal="false" :visible.sync="signDialog"
|
||||||
:title="prefillMode ? (formData.id ? '修改预填信息' : '预填报名信息') : (editMode ? '修改报名' : '信息填写')" width="50%"
|
:title="prefillMode ? (formData.id ? '修改预填信息' : '预填报名信息') : (editMode ? '修改报名' : '信息填写')" width="50%"
|
||||||
append-to-body>
|
class="family-sign-dialog" top="5vh" 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>
|
||||||
<el-row>
|
<el-row>
|
||||||
@@ -72,6 +72,9 @@ const signForm = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="activity.familyCountScope === 2" style="color: #c64120; margin-bottom: 10px">
|
||||||
|
本活动累计最多报名{{activity.familyMaxCount}}名不同{{activity.keyWord}},含候补;
|
||||||
|
</div>
|
||||||
<div v-if="formData.mobileColumnsValue && formData.mobileColumnsValue.length > 0">
|
<div v-if="formData.mobileColumnsValue && formData.mobileColumnsValue.length > 0">
|
||||||
<el-tabs v-model="active" type="card">
|
<el-tabs v-model="active" type="card">
|
||||||
<el-tab-pane v-for="(item, index) in formData.mobileColumnsValue"
|
<el-tab-pane v-for="(item, index) in formData.mobileColumnsValue"
|
||||||
@@ -137,7 +140,7 @@ const signForm = {
|
|||||||
|
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<el-row class="mt20" justify="end" type="flex">
|
<el-row slot="footer" justify="end" type="flex">
|
||||||
<el-button @click="signDialog = false">取 消</el-button>
|
<el-button @click="signDialog = false">取 消</el-button>
|
||||||
<el-button :loading="formLoading" @click="onSubmit" type="primary">{{prefillMode ? '保存预填' : '提 交'}}</el-button>
|
<el-button :loading="formLoading" @click="onSubmit" type="primary">{{prefillMode ? '保存预填' : '提 交'}}</el-button>
|
||||||
</el-row>
|
</el-row>
|
||||||
@@ -158,24 +161,38 @@ const signForm = {
|
|||||||
courseRow: {},
|
courseRow: {},
|
||||||
courseTypeRow: {},
|
courseTypeRow: {},
|
||||||
courseTimeSelectList: [],
|
courseTimeSelectList: [],
|
||||||
active: '',
|
active: '0',
|
||||||
|
|
||||||
activity: {},
|
activity: {},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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
|
||||||
|
}
|
||||||
|
// 类型上限与整个活动上限同时生效;跨课程已用家属集合由后端在最终提交时校验。
|
||||||
|
if (this.activity.familyCountScope === 2
|
||||||
|
&& this.formData.mobileColumnsValue.length >= Number(this.activity.familyMaxCount)) {
|
||||||
|
this.$message.warning('本活动累计家属人数最多为' + this.activity.familyMaxCount)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const list = clone(this.courseTypeRow.familyMobileSignColumnList)
|
const list = clone(this.courseTypeRow.familyMobileSignColumnList)
|
||||||
this.formData.mobileColumnsValue.push(list)
|
this.formData.mobileColumnsValue.push(list)
|
||||||
|
// 新增儿童后选中对应的字符串索引,确保标签和填写区域同步显示。
|
||||||
|
this.$set(this, "active", String(this.formData.mobileColumnsValue.length - 1))
|
||||||
},
|
},
|
||||||
delFamily() {
|
delFamily() {
|
||||||
|
// 无儿童时不执行删除;删完后保留首项索引,兼容重新添加儿童。
|
||||||
|
if (this.formData.mobileColumnsValue.length === 0) return
|
||||||
this.formData.mobileColumnsValue.splice(this.active, 1)
|
this.formData.mobileColumnsValue.splice(this.active, 1)
|
||||||
const ac = (Number(this.active) - 1)
|
const ac = (Number(this.active) - 1)
|
||||||
this.active = '' + (ac >= 0 ? ac : 0)
|
this.$set(this, "active", String(ac >= 0 ? ac : 0))
|
||||||
},
|
},
|
||||||
validSignUp() {
|
validSignUp() {
|
||||||
//获取家属是多少人
|
//获取家属是多少人
|
||||||
@@ -474,12 +491,33 @@ const signForm = {
|
|||||||
this.mergeMobileColumnsValue(courseType, signUpData.mobileColumnsValue))
|
this.mergeMobileColumnsValue(courseType, signUpData.mobileColumnsValue))
|
||||||
}
|
}
|
||||||
this.$set(this, "formData", formData)
|
this.$set(this, "formData", formData)
|
||||||
|
// 不同活动共用报名组件,每次打开都选中首个儿童,避免沿用上次的标签状态。
|
||||||
|
this.$set(this, "active", "0")
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.$refs.form.clearValidate()
|
this.$refs.form.clearValidate()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
style: /*language=CSS*/ `
|
style: /*language=CSS*/ `
|
||||||
|
/* 报名弹框保留上下可视边距,仅正文滚动,标题和操作按钮始终可见。 */
|
||||||
|
.family-sign-dialog {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.family-sign-dialog > .el-dialog {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 90vh;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.family-sign-dialog > .el-dialog > .el-dialog__header,
|
||||||
|
.family-sign-dialog > .el-dialog > .el-dialog__footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.family-sign-dialog > .el-dialog > .el-dialog__body {
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
|
}
|
||||||
::v-deep .el-tabs__header {
|
::v-deep .el-tabs__header {
|
||||||
margin: 0 0 15px !important;
|
margin: 0 0 15px !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,24 @@ const basicForm = {
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
<el-form-item label="家属人数范围" prop="familyCountScope">
|
||||||
|
<el-radio-group :value="formData.familyCountScope" size="small"
|
||||||
|
@input="(val) => { this.$set(formData, 'familyCountScope', val) }">
|
||||||
|
<el-radio :label="1" border>按子活动类型累计</el-radio>
|
||||||
|
<el-radio :label="2" border>按整个活动累计</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
<div style="color: #c64120; font-size: 12px" v-if="formData.familyCountScope === 2">
|
||||||
|
同一教职工在本活动所有子活动中,家属按唯一标识去重后的累计人数不得超过家属最多数;含候补报名。
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="报名时间冲突" prop="timeConflictCheck">
|
||||||
|
<el-switch :value="formData.timeConflictCheck"
|
||||||
|
@input="(val) => { this.$set(formData, 'timeConflictCheck', val) }"
|
||||||
|
active-text="禁止同一家属时间重叠报名"></el-switch>
|
||||||
|
<div style="color: #c64120; font-size: 12px">
|
||||||
|
按同一教职工下同一家属的子活动实际日期及起止时间判断,含候补报名。同一家属上午与同日全天互斥,上午与下午、不同日期可兼报;不同家属可以报名同时段的不同子活动。
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item :rules="{required:true,message: '请输入活动介绍', trigger: 'blur'}" label="活动介绍" prop="introduce">
|
<el-form-item :rules="{required:true,message: '请输入活动介绍', trigger: 'blur'}" label="活动介绍" prop="introduce">
|
||||||
<text-editor v-model="formData.introduce"></text-editor>
|
<text-editor v-model="formData.introduce"></text-editor>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -280,9 +298,14 @@ const basicForm = {
|
|||||||
|
|
||||||
<div class="left-span-label" style="margin-top: 20px">判断家属数量唯一标识</div>
|
<div class="left-span-label" style="margin-top: 20px">判断家属数量唯一标识</div>
|
||||||
<el-form-item label="唯一标识" prop="onlyKey">
|
<el-form-item label="唯一标识" prop="onlyKey">
|
||||||
<el-select @focus="keyFocus" v-model="formData.onlyKey" placeholder="请选择唯一标识">
|
<el-select @focus="keyFocus" :value="formData.onlyKey" placeholder="请选择唯一标识" style="width: 420px"
|
||||||
<el-option v-for="item in typeArray" :key="item.columnCode" :label="item.columnName" :value="item.columnCode"></el-option>
|
@input="(val) => { this.$set(formData, 'onlyKey', val) }">
|
||||||
|
<el-option v-for="item in typeArray" :key="item.columnCode" :label="item.optionLabel"
|
||||||
|
:value="item.columnCode" :disabled="item.legacy"></el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<div style="color: #c64120; font-size: 12px">
|
||||||
|
请选择本活动所有子活动类型共有且均设为必填的字段,各类型字段编码必须一致。有报名或预填后禁止直接更换。
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -290,8 +313,8 @@ const basicForm = {
|
|||||||
<el-button @click="$emit('back')">取消</el-button>
|
<el-button @click="$emit('back')">取消</el-button>
|
||||||
<el-button v-if="step === 2" type="primary" @click="step = 1">上一步</el-button>
|
<el-button v-if="step === 2" type="primary" @click="step = 1">上一步</el-button>
|
||||||
<el-button v-if="step === 1" type="primary" @click="step = 2">下一步</el-button>
|
<el-button v-if="step === 1" type="primary" @click="step = 2">下一步</el-button>
|
||||||
<el-button type="primary" @click="onSave">保存</el-button>
|
<el-button type="primary" :loading="formLoading" @click="onSave">保存</el-button>
|
||||||
<el-button type="primary" @click="onSubmit">提交</el-button>
|
<el-button type="primary" :loading="formLoading" @click="onSubmit">提交</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-dialog :close-on-click-modal="false" :visible.sync="signUpDialog" title="设置报名限制" append-to-body>
|
<el-dialog :close-on-click-modal="false" :visible.sync="signUpDialog" title="设置报名限制" append-to-body>
|
||||||
@@ -325,6 +348,7 @@ const basicForm = {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
signUpDialog: false,
|
signUpDialog: false,
|
||||||
|
formLoading: false,
|
||||||
step: 1,
|
step: 1,
|
||||||
formData: {
|
formData: {
|
||||||
notice: false,
|
notice: false,
|
||||||
@@ -348,7 +372,9 @@ const basicForm = {
|
|||||||
restrictLimit: 1,
|
restrictLimit: 1,
|
||||||
limitNum: 1,
|
limitNum: 1,
|
||||||
typeLimits: [],
|
typeLimits: [],
|
||||||
keyWord: '家属'
|
keyWord: '家属',
|
||||||
|
familyCountScope: 1,
|
||||||
|
timeConflictCheck: false,
|
||||||
},
|
},
|
||||||
historicalActList: [],
|
historicalActList: [],
|
||||||
trainTypeList: [],
|
trainTypeList: [],
|
||||||
@@ -372,28 +398,47 @@ const basicForm = {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
keyFocus() {
|
keyFocus() {
|
||||||
const types = this.formData.courseList.filter(o => o.courseType !== undefined).map(o => o.courseType)
|
const types = [...new Set(this.formData.courseList.map(o => o.courseType).filter(Boolean))]
|
||||||
if(types.length === 0) {
|
if(types.length === 0) {
|
||||||
|
this.$set(this, 'typeArray', [])
|
||||||
this.$message.warning('请先设置' + this.activityType + '类型')
|
this.$message.warning('请先设置' + this.activityType + '类型')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const typeList = this.courseTypeList.filter(o => types.includes(o.id))
|
const typeList = this.courseTypeList.filter(o => types.includes(o.id))
|
||||||
let array = []
|
// 唯一标识必须在每种类型中各出现一次且均为必填,不限定字段的验证规则。
|
||||||
typeList.forEach(item => {
|
const firstColumns = typeList[0] ? (typeList[0].familyMobileSignColumnList || []) : []
|
||||||
array = array.concat(item.familyMobileSignColumnList)
|
const options = firstColumns.filter(column => column.columnCode && typeList.length === types.length
|
||||||
|
&& typeList.every(type => {
|
||||||
|
const matches = (type.familyMobileSignColumnList || []).filter(item => item.columnCode === column.columnCode)
|
||||||
|
return matches.length === 1 && matches[0].isRequired === true
|
||||||
|
})).map(column => ({
|
||||||
|
columnCode: column.columnCode,
|
||||||
|
optionLabel: column.columnName,
|
||||||
|
legacy: false
|
||||||
|
}))
|
||||||
|
// 历史选择仍可回显,但不能作为新选择;不改写历史编码,也不静默清空原配置。
|
||||||
|
if (this.formData.onlyKey && !options.some(item => item.columnCode === this.formData.onlyKey)) {
|
||||||
|
const oldColumn = typeList.flatMap(type => type.familyMobileSignColumnList || [])
|
||||||
|
.find(column => column.columnCode === this.formData.onlyKey)
|
||||||
|
options.push({
|
||||||
|
columnCode: this.formData.onlyKey,
|
||||||
|
optionLabel: oldColumn ? oldColumn.columnName : '原字段',
|
||||||
|
legacy: true
|
||||||
})
|
})
|
||||||
// 根据 columnCode 去重
|
|
||||||
const uniqueMap = new Map()
|
|
||||||
array.forEach(item => {
|
|
||||||
if (item.columnCode) {
|
|
||||||
uniqueMap.set(item.columnCode, item)
|
|
||||||
}
|
}
|
||||||
})
|
this.$set(this, 'typeArray', options)
|
||||||
// 可选:保留没有 columnCode 的项
|
|
||||||
const withoutCode = array.filter(item => !item.columnCode)
|
|
||||||
this.typeArray = [...uniqueMap.values(), ...withoutCode]
|
|
||||||
},
|
},
|
||||||
async validNumber(row, old) {
|
// 老活动空配置按原来的按类型累计、不检查重叠回显,沿用活动时也保留所选规则。
|
||||||
|
applyRuleDefaults() {
|
||||||
|
this.$set(this.formData, 'familyCountScope', Number(this.formData.familyCountScope) === 2 ? 2 : 1)
|
||||||
|
this.$set(this.formData, 'timeConflictCheck', this.formData.timeConflictCheck === true)
|
||||||
|
if (this.formData.courseList.some(course => course.courseType)) {
|
||||||
|
this.keyFocus()
|
||||||
|
} else {
|
||||||
|
this.$set(this, 'typeArray', [])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
validNumber(row, old) {
|
||||||
if (GetQueryString("id") === "") {
|
if (GetQueryString("id") === "") {
|
||||||
if (row.courseReservedNumber > row.coursePeopleNumber && row.reserveMode === 1) {
|
if (row.courseReservedNumber > row.coursePeopleNumber && row.reserveMode === 1) {
|
||||||
this.$alert("预留人数不能大于" + this.activityType + "人数!", "提示", {
|
this.$alert("预留人数不能大于" + this.activityType + "人数!", "提示", {
|
||||||
@@ -402,7 +447,7 @@ const basicForm = {
|
|||||||
row.courseReservedNumber = 0
|
row.courseReservedNumber = 0
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const registerUserCount = await this.getRegisterUserCount(row.id)
|
return this.getRegisterUserCount(row.id).then((registerUserCount) => {
|
||||||
if (row.courseReservedNumber + registerUserCount > row.coursePeopleNumber) {
|
if (row.courseReservedNumber + registerUserCount > row.coursePeopleNumber) {
|
||||||
if (row.reserveMode === 1) {
|
if (row.reserveMode === 1) {
|
||||||
let num = row.coursePeopleNumber - registerUserCount
|
let num = row.coursePeopleNumber - registerUserCount
|
||||||
@@ -422,11 +467,12 @@ const basicForm = {
|
|||||||
row.courseReservedNumber = 0
|
row.courseReservedNumber = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getRegisterUserCount(courseId) {
|
getRegisterUserCount(courseId) {
|
||||||
const resp = await this.$axios.post("/platform/family/manage/getRegisterUserCount", { courseId })
|
return this.$axios.post("/platform/family/manage/getRegisterUserCount", { courseId })
|
||||||
return resp.code === 0 ? resp.data : 0
|
.then((resp) => resp.code === 0 ? resp.data : 0)
|
||||||
},
|
},
|
||||||
courseTypeChange(val, row) {
|
courseTypeChange(val, row) {
|
||||||
row.reserveMode = 1
|
row.reserveMode = 1
|
||||||
@@ -497,10 +543,11 @@ const basicForm = {
|
|||||||
addCourse() {
|
addCourse() {
|
||||||
this.formData.courseList.push({ courseTimeList: [], isMobileSign: false, isReceiveGift: false, reserveMode: 1, courseIsLimitApply: false })
|
this.formData.courseList.push({ courseTimeList: [], isMobileSign: false, isReceiveGift: false, reserveMode: 1, courseIsLimitApply: false })
|
||||||
},
|
},
|
||||||
async historicalActChange(val) {
|
historicalActChange(val) {
|
||||||
const resp = await this.$axios.post("/platform/family/manage/findOne", {id: val})
|
return this.$axios.post("/platform/family/manage/findOne", {id: val}).then((resp) => {
|
||||||
if (resp.code === 0) {
|
if (resp.code === 0) {
|
||||||
this.formData = resp.data
|
this.$set(this, "formData", resp.data)
|
||||||
|
this.applyRuleDefaults()
|
||||||
this.typeChange(this.formData.trainType)
|
this.typeChange(this.formData.trainType)
|
||||||
this.formData.id = ""
|
this.formData.id = ""
|
||||||
this.formData.courseList.forEach((course) => {
|
this.formData.courseList.forEach((course) => {
|
||||||
@@ -521,6 +568,7 @@ const basicForm = {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
typeChange(val) {
|
typeChange(val) {
|
||||||
const type = this.dict.type.FAMILY_SIGNUP_TYPE.find(o => o.code === val)
|
const type = this.dict.type.FAMILY_SIGNUP_TYPE.find(o => o.code === val)
|
||||||
@@ -534,15 +582,13 @@ const basicForm = {
|
|||||||
this.$set(this.formData, "activityGroupName", "全部教职工")
|
this.$set(this.formData, "activityGroupName", "全部教职工")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getActivityGroup() {
|
getActivityGroup() {
|
||||||
const { data } = await this.$axios.post("/platform/activity/basic/scope/getActivityUserScopeGroup")
|
return this.$axios.post("/platform/activity/basic/scope/getActivityUserScopeGroup").then((resp) => resp.data)
|
||||||
return data
|
|
||||||
},
|
},
|
||||||
async getHistoricalActList() {
|
getHistoricalActList() {
|
||||||
const resp = await this.$axios.post("/platform/family/manage/getHistoricalActList", {})
|
return this.$axios.post("/platform/family/manage/getHistoricalActList", {}).then((resp) => resp.data)
|
||||||
return resp.data
|
|
||||||
},
|
},
|
||||||
async onSave() {
|
onSave() {
|
||||||
let valid = false
|
let valid = false
|
||||||
this.$refs.form.validateField("activityName", (errMsg) => {
|
this.$refs.form.validateField("activityName", (errMsg) => {
|
||||||
valid = errMsg === ""
|
valid = errMsg === ""
|
||||||
@@ -551,10 +597,10 @@ const basicForm = {
|
|||||||
this.$message.warning("请输入活动名称")
|
this.$message.warning("请输入活动名称")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await this.doHandle('保存')
|
return this.doHandle('保存')
|
||||||
},
|
},
|
||||||
onSubmit() {
|
onSubmit() {
|
||||||
this.$refs["form"].validate(async (valid, errMsg) => {
|
this.$refs["form"].validate((valid, errMsg) => {
|
||||||
if (valid) {
|
if (valid) {
|
||||||
const courseValid = this.formData.courseList.some((v, i) => {
|
const courseValid = this.formData.courseList.some((v, i) => {
|
||||||
const basicValid = v.courseName && v.coursePeopleNumber && v.courseLocation && v.courseInstructor && v.courseType
|
const basicValid = v.courseName && v.coursePeopleNumber && v.courseLocation && v.courseInstructor && v.courseType
|
||||||
@@ -599,7 +645,7 @@ const basicForm = {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.formData.isDisabled = false
|
this.formData.isDisabled = false
|
||||||
await this.doHandle('提交')
|
this.doHandle('提交')
|
||||||
} else {
|
} else {
|
||||||
if(Object.keys(errMsg).length > 0) {
|
if(Object.keys(errMsg).length > 0) {
|
||||||
this.$message.warning(errMsg[Object.keys(errMsg)[0]][0].message)
|
this.$message.warning(errMsg[Object.keys(errMsg)[0]][0].message)
|
||||||
@@ -609,7 +655,7 @@ const basicForm = {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async doHandle(type) {
|
doHandle(type) {
|
||||||
const cloneData = clone(this.formData)
|
const cloneData = clone(this.formData)
|
||||||
cloneData.activitySignUpStartTime = cloneData.activitySignTime !== undefined ? cloneData.activitySignTime[0] : null
|
cloneData.activitySignUpStartTime = cloneData.activitySignTime !== undefined ? cloneData.activitySignTime[0] : null
|
||||||
cloneData.activitySignUpEndTime = cloneData.activitySignTime !== undefined ? cloneData.activitySignTime[1] : null
|
cloneData.activitySignUpEndTime = cloneData.activitySignTime !== undefined ? cloneData.activitySignTime[1] : null
|
||||||
@@ -620,13 +666,13 @@ const basicForm = {
|
|||||||
}
|
}
|
||||||
cloneData.typeLimits = JSON.stringify(this.formData.typeLimits)
|
cloneData.typeLimits = JSON.stringify(this.formData.typeLimits)
|
||||||
cloneData.courseList = JSON.stringify(cloneData.courseList)
|
cloneData.courseList = JSON.stringify(cloneData.courseList)
|
||||||
const confirm = await this.$confirm("您确定要" + type + "吗?", "提示", {
|
return this.$confirm("您确定要" + type + "吗?", "提示", {
|
||||||
confirmButtonText: "确定",
|
confirmButtonText: "确定",
|
||||||
cancelButtonText: "取消",
|
cancelButtonText: "取消",
|
||||||
type: "warning"
|
type: "warning"
|
||||||
})
|
}).then(() => {
|
||||||
if (confirm !== "confirm") return
|
this.formLoading = true
|
||||||
const resp = await this.$axios.post("/platform/family/manage/doHandle", cloneData)
|
return this.$axios.post("/platform/family/manage/doHandle", cloneData).then((resp) => {
|
||||||
if (resp.code === 0) {
|
if (resp.code === 0) {
|
||||||
this.$message.success(resp.msg)
|
this.$message.success(resp.msg)
|
||||||
this.step = 1
|
this.step = 1
|
||||||
@@ -635,26 +681,35 @@ const basicForm = {
|
|||||||
} else {
|
} else {
|
||||||
this.$message.warning(resp.msg)
|
this.$message.warning(resp.msg)
|
||||||
}
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
this.formLoading = false
|
||||||
|
})
|
||||||
|
}).catch(() => {})
|
||||||
},
|
},
|
||||||
async getAllType() {
|
getAllType() {
|
||||||
const resp = await this.$axios.post("/platform/family/type/getAllType", {})
|
return this.$axios.post("/platform/family/type/getAllType", {}).then((resp) => resp.data)
|
||||||
return resp.data
|
|
||||||
},
|
},
|
||||||
async init(row) {
|
init(row) {
|
||||||
if (row && row.id) {
|
if (!row || !row.id) {
|
||||||
const resp = await this.$axios.post("/platform/family/manage/findOne", {id: row.id})
|
this.applyRuleDefaults()
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
return this.$axios.post("/platform/family/manage/findOne", {id: row.id}).then((resp) => {
|
||||||
if (resp.code === 0) {
|
if (resp.code === 0) {
|
||||||
this.formData = resp.data
|
this.$set(this, "formData", resp.data)
|
||||||
this.typeChange(this.formData.trainType)
|
this.typeChange(this.formData.trainType)
|
||||||
if(this.formData.onlyKey) this.keyFocus()
|
this.applyRuleDefaults()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
async initData(row) {
|
initData(row) {
|
||||||
this.activityGroupList = await this.getActivityGroup()
|
return Promise.all([this.getActivityGroup(), this.getHistoricalActList(), this.getAllType()])
|
||||||
this.historicalActList = await this.getHistoricalActList()
|
.then(([groups, activities, types]) => {
|
||||||
this.courseTypeList = await this.getAllType()
|
this.$set(this, "activityGroupList", groups)
|
||||||
await this.init(row)
|
this.$set(this, "historicalActList", activities)
|
||||||
|
this.$set(this, "courseTypeList", types)
|
||||||
|
return this.init(row)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -664,9 +719,11 @@ const basicForm = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
'formData.activityGroupId': {
|
'formData.activityGroupId': {
|
||||||
async handler(newVal, oldVal) {
|
handler(newVal, oldVal) {
|
||||||
this.activityGroupList = await this.getActivityGroup()
|
this.getActivityGroup().then((groups) => {
|
||||||
this.userScopeDialog = false
|
this.$set(this, 'activityGroupList', groups)
|
||||||
|
this.$set(this, 'userScopeDialog', false)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
deep: true
|
deep: true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ const info = {
|
|||||||
<el-descriptions-item label="活动结束时间">{{viewData.activityEndTime}}</el-descriptions-item>
|
<el-descriptions-item label="活动结束时间">{{viewData.activityEndTime}}</el-descriptions-item>
|
||||||
<el-descriptions-item label="报名开始时间">{{viewData.activitySignUpStartTime}}</el-descriptions-item>
|
<el-descriptions-item label="报名开始时间">{{viewData.activitySignUpStartTime}}</el-descriptions-item>
|
||||||
<el-descriptions-item label="报名结束时间">{{viewData.activitySignUpEndTime}}</el-descriptions-item>
|
<el-descriptions-item label="报名结束时间">{{viewData.activitySignUpEndTime}}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="家属人数范围">{{viewData.familyCountScope === 2 ? '按整个活动累计' : '按子活动类型累计'}}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="活动家属最多数">{{viewData.familyMaxCount}}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="禁止同一家属时间重叠">{{viewData.timeConflictCheck === true ? '是(含候补)' : '否'}}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="家属唯一标识编码">{{viewData.onlyKey}}</el-descriptions-item>
|
||||||
<el-descriptions-item label="详细信息" :span="2">
|
<el-descriptions-item label="详细信息" :span="2">
|
||||||
<div v-html="viewData.introduce"></div>
|
<div v-html="viewData.introduce"></div>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
@@ -54,6 +58,7 @@ const info = {
|
|||||||
<el-dialog :visible.sync="viewMapDialog" title="地点" width="60%">
|
<el-dialog :visible.sync="viewMapDialog" title="地点" width="60%">
|
||||||
<div id="viewMap" style="width: 100%; height: 500px"></div>
|
<div id="viewMap" style="width: 100%; height: 500px"></div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
dicts: ["FAMILY_SIGNUP_TYPE"],
|
dicts: ["FAMILY_SIGNUP_TYPE"],
|
||||||
@@ -72,7 +77,7 @@ const info = {
|
|||||||
this.$axios.post("/platform/family/manage/findOne", { id: id })
|
this.$axios.post("/platform/family/manage/findOne", { id: id })
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
if (resp.code === 0) {
|
if (resp.code === 0) {
|
||||||
this.viewData = resp.data
|
this.$set(this, 'viewData', resp.data)
|
||||||
const type = this.dict.type.FAMILY_SIGNUP_TYPE.find((o) => o.code === resp.data.trainType)
|
const type = this.dict.type.FAMILY_SIGNUP_TYPE.find((o) => o.code === resp.data.trainType)
|
||||||
this.activityType = type?.name || "子活动"
|
this.activityType = type?.name || "子活动"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ layout("/layouts/platform.html"){
|
|||||||
#-->
|
#-->
|
||||||
|
|
||||||
<div id="app" v-cloak>
|
<div id="app" v-cloak>
|
||||||
<guava ref="guava">
|
<guava ref="guava" @hook:mounted="onPageVisible">
|
||||||
<el-card shadow="never">
|
<el-card shadow="never">
|
||||||
<search @search="doSearch">
|
<search @search="doSearch">
|
||||||
<search-item label="年度:">
|
<search-item label="年度:">
|
||||||
@@ -36,7 +36,8 @@ layout("/layouts/platform.html"){
|
|||||||
<el-button @click="exportSignPerson" type="primary" size="small">导出签到名单</el-button>
|
<el-button @click="exportSignPerson" type="primary" size="small">导出签到名单</el-button>
|
||||||
<el-button @click="exportGiftPerson" type="primary" size="small">导出领取名单</el-button>
|
<el-button @click="exportGiftPerson" type="primary" size="small">导出领取名单</el-button>
|
||||||
</table-tool>
|
</table-tool>
|
||||||
<el-table :data="tableData" :size="tableSize">
|
<el-table ref="tableRef" class="train-signup-user-table" :data="tableData"
|
||||||
|
:max-height="tableHeight" :size="tableSize">
|
||||||
<el-table-column :index="indexMethod" label="序号" type="index" width="80px"></el-table-column>
|
<el-table-column :index="indexMethod" label="序号" type="index" width="80px"></el-table-column>
|
||||||
<el-table-column
|
<el-table-column
|
||||||
:label="column.prop === 'courseNames' ? trainType : column.label"
|
:label="column.prop === 'courseNames' ? trainType : column.label"
|
||||||
@@ -98,15 +99,42 @@ layout("/layouts/platform.html"){
|
|||||||
new Vue({
|
new Vue({
|
||||||
el: "#app",
|
el: "#app",
|
||||||
mixins: [initTableMixins],
|
mixins: [initTableMixins],
|
||||||
|
mounted() {
|
||||||
|
// 原生和 jQuery 事件使用稳定的箭头回调,确保尺寸重算及销毁时正确解绑。
|
||||||
|
this.pageVisibleHandler = () => this.onPageVisible()
|
||||||
|
this.pageExitHandler = () => this.stopTableResize()
|
||||||
|
window.addEventListener("resize", this.pageVisibleHandler)
|
||||||
|
window.addEventListener("load", this.pageVisibleHandler)
|
||||||
|
$(document).one("pjax:send", this.pageExitHandler)
|
||||||
|
$(document).on("pjax:complete", this.pageVisibleHandler)
|
||||||
|
// 顶部页签异步挂载可能只移动内容区,不改变已监听元素的尺寸。
|
||||||
|
const tabs = document.querySelector("#ele-admin-tabs")
|
||||||
|
if (tabs && window.MutationObserver) {
|
||||||
|
this.tabsObserver = new MutationObserver(() => this.onPageVisible())
|
||||||
|
this.tabsObserver.observe(tabs, { childList: true, subtree: true })
|
||||||
|
}
|
||||||
|
this.onPageVisible()
|
||||||
|
},
|
||||||
|
updated() {
|
||||||
|
// 表格及查询数据异步渲染后继续校正,首次取得的高度不一定是最终布局。
|
||||||
|
if (this.$refs.tableRef) {
|
||||||
|
this.onPageVisible()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.stopTableResize()
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
reserveDialogVisible: false,
|
reserveDialogVisible: false,
|
||||||
reserveTableData: [],
|
reserveTableData: [],
|
||||||
activityList: [],
|
activityList: [],
|
||||||
pageForm: {
|
pageForm: {
|
||||||
|
pageSize: 50,
|
||||||
year: new Date().getFullYear().toString(),
|
year: new Date().getFullYear().toString(),
|
||||||
activityId: null
|
activityId: null
|
||||||
},
|
},
|
||||||
|
tableHeight: 300,
|
||||||
tableColumns: [
|
tableColumns: [
|
||||||
{ label: "姓名", prop: "username" },
|
{ label: "姓名", prop: "username" },
|
||||||
{ label: "工号", prop: "loginname" },
|
{ label: "工号", prop: "loginname" },
|
||||||
@@ -139,6 +167,55 @@ layout("/layouts/platform.html"){
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
// 页面显示后再读取表格位置,避免刷新或 PJAX 隐藏期间计算出错误高度。
|
||||||
|
onPageVisible() {
|
||||||
|
this.$nextTick(() => requestAnimationFrame(() => this.updateTableHeight()))
|
||||||
|
},
|
||||||
|
// 按浏览器可视底部与卡片实际留白计算表格最大高度,让分页始终留在表格外。
|
||||||
|
updateTableHeight() {
|
||||||
|
if (!this.$refs.tableRef) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const table = this.$refs.tableRef.$el
|
||||||
|
const card = table.closest(".el-card")
|
||||||
|
const content = table.closest(".ele-admin-content")
|
||||||
|
const pagination = card && card.querySelector(".el-pagination-container")
|
||||||
|
if (!content || !pagination || !table.isConnected || !table.getBoundingClientRect().width || !pagination.getBoundingClientRect().height) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content.scrollTop = 0
|
||||||
|
const pageBody = table.closest(".ele-body")
|
||||||
|
const pagePadding = pageBody ? parseFloat(getComputedStyle(pageBody).paddingBottom) || 0 : 0
|
||||||
|
const visibleBottom = Math.min(content.getBoundingClientRect().bottom, window.innerHeight) - pagePadding - 4
|
||||||
|
const nextHeight = Math.max(120, Math.floor(table.getBoundingClientRect().height + visibleBottom - card.getBoundingClientRect().bottom))
|
||||||
|
if (nextHeight !== this.tableHeight) {
|
||||||
|
this.tableHeight = nextHeight
|
||||||
|
}
|
||||||
|
if (!this.tableResizeObserver && window.ResizeObserver) {
|
||||||
|
this.tableResizeObserver = new ResizeObserver(() => this.updateTableHeight())
|
||||||
|
const resizeTargets = [content, document.querySelector("#ele-admin-tabs"), this.$el.querySelector(".el-card"), card, card.querySelector(".ele-table-tool"), pagination]
|
||||||
|
resizeTargets.forEach((element) => {
|
||||||
|
if (element) {
|
||||||
|
this.tableResizeObserver.observe(element)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// PJAX 切换或组件销毁时清理监听,防止旧页面继续修改表格高度。
|
||||||
|
stopTableResize() {
|
||||||
|
window.removeEventListener("resize", this.pageVisibleHandler)
|
||||||
|
window.removeEventListener("load", this.pageVisibleHandler)
|
||||||
|
$(document).off("pjax:send", this.pageExitHandler)
|
||||||
|
$(document).off("pjax:complete", this.pageVisibleHandler)
|
||||||
|
if (this.tabsObserver) {
|
||||||
|
this.tabsObserver.disconnect()
|
||||||
|
this.tabsObserver = null
|
||||||
|
}
|
||||||
|
if (this.tableResizeObserver) {
|
||||||
|
this.tableResizeObserver.disconnect()
|
||||||
|
this.tableResizeObserver = null
|
||||||
|
}
|
||||||
|
},
|
||||||
handleSelectionChange(val) {
|
handleSelectionChange(val) {
|
||||||
this.multipleSelection = val
|
this.multipleSelection = val
|
||||||
},
|
},
|
||||||
@@ -259,6 +336,16 @@ layout("/layouts/platform.html"){
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ele-admin-fixed-body .ele-admin-content {
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app .train-signup-user-table .el-table__body-wrapper {
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<!--#
|
<!--#
|
||||||
}
|
}
|
||||||
#-->
|
#-->
|
||||||
|
|||||||
@@ -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.tableLoading = true
|
||||||
|
try {
|
||||||
this.unitList = await this.$businessTool.listUnit()
|
this.unitList = await this.$businessTool.listUnit()
|
||||||
this.unionList = await this.$businessTool.listUnion()
|
this.unionList = await this.$businessTool.listUnion()
|
||||||
this.clubList = await this.$businessTool.listClub()
|
this.clubList = await this.$businessTool.listClub()
|
||||||
this.pageData()
|
await this.pageData()
|
||||||
|
} finally {
|
||||||
|
this.tableLoading = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ layout("/layouts/platform.html"){
|
|||||||
<el-card shadow="never">
|
<el-card shadow="never">
|
||||||
<table-tool :app="this" label="收货地址">
|
<table-tool :app="this" label="收货地址">
|
||||||
<el-button @click="openAddress" size="small" type="primary">新增地址</el-button>
|
<el-button @click="openAddress" size="small" type="primary">新增地址</el-button>
|
||||||
|
<el-button v-if="$auth.hasRole('SYSADMIN')" @click="exportAllAddress" size="small" type="primary">导出所有人收货地址</el-button>
|
||||||
</table-tool>
|
</table-tool>
|
||||||
|
|
||||||
<el-table
|
<el-table
|
||||||
@@ -74,6 +75,10 @@ layout("/layouts/platform.html"){
|
|||||||
"address-dialog": ADDRESS_DIALOG
|
"address-dialog": ADDRESS_DIALOG
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
// 导出全部用户的收货地址,不传当前用户或分页条件;下载和 loading 复用全局方法。
|
||||||
|
exportAllAddress() {
|
||||||
|
this.$downLoad("/platform/welfare/addressManage/exportAllAddress", {})
|
||||||
|
},
|
||||||
selectUserAddress(){
|
selectUserAddress(){
|
||||||
this.pageData()
|
this.pageData()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ layout("/layouts/platform.html"){
|
|||||||
#-->
|
#-->
|
||||||
|
|
||||||
<div id="app" v-cloak>
|
<div id="app" v-cloak>
|
||||||
<guava>
|
<guava @hook:mounted="onPageVisible">
|
||||||
<el-card shadow="never">
|
<el-card shadow="never">
|
||||||
<search @search="doSearch">
|
<search @search="doSearch">
|
||||||
<search-item label="所属年份">
|
<search-item label="所属年份">
|
||||||
@@ -96,7 +96,9 @@ layout("/layouts/platform.html"){
|
|||||||
|
|
||||||
<el-table
|
<el-table
|
||||||
:data="tableData"
|
:data="tableData"
|
||||||
|
:max-height="tableHeight"
|
||||||
:size="tableSize"
|
:size="tableSize"
|
||||||
|
class="welfare-selection-table"
|
||||||
@sort-change="pageOrder"
|
@sort-change="pageOrder"
|
||||||
ref="tableRef"
|
ref="tableRef"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
@@ -168,6 +170,31 @@ layout("/layouts/platform.html"){
|
|||||||
new Vue({
|
new Vue({
|
||||||
el: "#app",
|
el: "#app",
|
||||||
mixins: [initTableMixins],
|
mixins: [initTableMixins],
|
||||||
|
mounted() {
|
||||||
|
// 原生和 jQuery 事件会改变 this,保留箭头回调供执行和解绑使用。
|
||||||
|
this.pageVisibleHandler = () => this.onPageVisible()
|
||||||
|
this.pageExitHandler = () => this.stopTableResize()
|
||||||
|
window.addEventListener("resize", this.pageVisibleHandler)
|
||||||
|
window.addEventListener("load", this.pageVisibleHandler)
|
||||||
|
$(document).one("pjax:send", this.pageExitHandler)
|
||||||
|
$(document).on("pjax:complete", this.pageVisibleHandler)
|
||||||
|
// 顶部页签独立异步挂载,只移动内容区时 ResizeObserver 不会收到尺寸变化。
|
||||||
|
const tabs = document.querySelector("#ele-admin-tabs")
|
||||||
|
if (tabs && window.MutationObserver) {
|
||||||
|
this.tabsObserver = new MutationObserver(() => this.onPageVisible())
|
||||||
|
this.tabsObserver.observe(tabs, { childList: true, subtree: true })
|
||||||
|
}
|
||||||
|
this.onPageVisible()
|
||||||
|
},
|
||||||
|
updated() {
|
||||||
|
// 首次取得尺寸不代表异步布局已稳定,每次渲染后继续校正。
|
||||||
|
if (this.$refs.tableRef) {
|
||||||
|
this.onPageVisible()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.stopTableResize()
|
||||||
|
},
|
||||||
components: {
|
components: {
|
||||||
"option-select": optionSelect
|
"option-select": optionSelect
|
||||||
},
|
},
|
||||||
@@ -175,9 +202,11 @@ layout("/layouts/platform.html"){
|
|||||||
return {
|
return {
|
||||||
pageForm: {
|
pageForm: {
|
||||||
searchName: "username",
|
searchName: "username",
|
||||||
|
pageSize: 50,
|
||||||
year: new Date().getFullYear().toString(),
|
year: new Date().getFullYear().toString(),
|
||||||
isSelect: null
|
isSelect: null
|
||||||
},
|
},
|
||||||
|
tableHeight: 300,
|
||||||
unionOptions: [],
|
unionOptions: [],
|
||||||
unitOptions: [],
|
unitOptions: [],
|
||||||
projectOptions: [],
|
projectOptions: [],
|
||||||
@@ -216,6 +245,57 @@ layout("/layouts/platform.html"){
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
// 等页面完成显示后再测量,避免 PJAX 隐藏内容区时得到错误尺寸。
|
||||||
|
onPageVisible() {
|
||||||
|
this.$nextTick(() => requestAnimationFrame(() => this.updateTableHeight()))
|
||||||
|
},
|
||||||
|
// 用卡片实际底部校正表格高度,涵盖分页和卡片留白,避免首次渲染后布局变化遮住分页。
|
||||||
|
updateTableHeight() {
|
||||||
|
if (!this.$refs.tableRef) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const table = this.$refs.tableRef.$el
|
||||||
|
const card = table.closest(".el-card")
|
||||||
|
const content = table.closest(".ele-admin-content")
|
||||||
|
const pagination = card.querySelector(".el-pagination-container")
|
||||||
|
if (!pagination || !table.isConnected || !table.getBoundingClientRect().width || !pagination.getBoundingClientRect().height) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content.scrollTop = 0
|
||||||
|
const pageBody = table.closest(".ele-body")
|
||||||
|
const pagePadding = parseFloat(getComputedStyle(pageBody).paddingBottom) || 0
|
||||||
|
// 页签异步出现时内容区可能下移,分页必须留在浏览器可视范围内。
|
||||||
|
const visibleBottom = Math.min(content.getBoundingClientRect().bottom, window.innerHeight) - pagePadding - 4
|
||||||
|
const nextHeight = Math.max(120, Math.floor(table.getBoundingClientRect().height + visibleBottom - card.getBoundingClientRect().bottom))
|
||||||
|
if (nextHeight !== this.tableHeight) {
|
||||||
|
this.tableHeight = nextHeight
|
||||||
|
}
|
||||||
|
// 卡片和筛选区异步变化时重新校正;表格高度稳定后不再触发更新。
|
||||||
|
if (!this.tableResizeObserver && window.ResizeObserver) {
|
||||||
|
this.tableResizeObserver = new ResizeObserver(() => this.updateTableHeight())
|
||||||
|
const resizeTargets = [content, document.querySelector("#ele-admin-tabs"), this.$el.querySelector(".el-card"), card, card.querySelector(".ele-table-tool"), pagination]
|
||||||
|
resizeTargets.forEach((element) => {
|
||||||
|
if (element) {
|
||||||
|
this.tableResizeObserver.observe(element)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// PJAX 切换或组件销毁时移除监听,避免旧页面继续响应窗口变化。
|
||||||
|
stopTableResize() {
|
||||||
|
window.removeEventListener("resize", this.pageVisibleHandler)
|
||||||
|
window.removeEventListener("load", this.pageVisibleHandler)
|
||||||
|
$(document).off("pjax:send", this.pageExitHandler)
|
||||||
|
$(document).off("pjax:complete", this.pageVisibleHandler)
|
||||||
|
if (this.tabsObserver) {
|
||||||
|
this.tabsObserver.disconnect()
|
||||||
|
this.tabsObserver = null
|
||||||
|
}
|
||||||
|
if (this.tableResizeObserver) {
|
||||||
|
this.tableResizeObserver.disconnect()
|
||||||
|
this.tableResizeObserver = null
|
||||||
|
}
|
||||||
|
},
|
||||||
// 获取福利列表
|
// 获取福利列表
|
||||||
getWelfareList() {
|
getWelfareList() {
|
||||||
this.$axios.post("/platform/welfare/common/list", { year: this.pageForm.year }).then((res) => {
|
this.$axios.post("/platform/welfare/common/list", { year: this.pageForm.year }).then((res) => {
|
||||||
@@ -355,6 +435,16 @@ layout("/layouts/platform.html"){
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ele-admin-fixed-body .ele-admin-content {
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app .welfare-selection-table .el-table__body-wrapper {
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<!--#
|
<!--#
|
||||||
}
|
}
|
||||||
#-->
|
#-->
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ layout("/layouts/platform.html"){
|
|||||||
#-->
|
#-->
|
||||||
|
|
||||||
<div id="app" v-cloak>
|
<div id="app" v-cloak>
|
||||||
<guava ref="guava">
|
<guava ref="guava" @hook:mounted="onPageVisible">
|
||||||
<template>
|
<template>
|
||||||
<el-card shadow="never">
|
<el-card shadow="never">
|
||||||
<search @search="doSearch">
|
<search @search="doSearch">
|
||||||
@@ -194,7 +194,9 @@ layout("/layouts/platform.html"){
|
|||||||
|
|
||||||
<el-table
|
<el-table
|
||||||
:data="tableData"
|
:data="tableData"
|
||||||
|
:max-height="tableHeight"
|
||||||
:size="tableSize"
|
:size="tableSize"
|
||||||
|
class="welfare-list-table"
|
||||||
@sort-change="pageOrder"
|
@sort-change="pageOrder"
|
||||||
ref="tableRef"
|
ref="tableRef"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
@@ -294,6 +296,29 @@ layout("/layouts/platform.html"){
|
|||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.checkedFields = this.tableColumns.filter(c => c.checked !== 0).map(c => c.prop);
|
this.checkedFields = this.tableColumns.filter(c => c.checked !== 0).map(c => c.prop);
|
||||||
|
// 原生和 jQuery 事件会改变 this,保留箭头回调供执行和解绑使用。
|
||||||
|
this.pageVisibleHandler = () => this.onPageVisible()
|
||||||
|
this.pageExitHandler = () => this.stopTableResize()
|
||||||
|
window.addEventListener("resize", this.pageVisibleHandler)
|
||||||
|
window.addEventListener("load", this.pageVisibleHandler)
|
||||||
|
$(document).one("pjax:send", this.pageExitHandler)
|
||||||
|
$(document).on("pjax:complete", this.pageVisibleHandler)
|
||||||
|
// 顶部页签独立异步挂载,只移动内容区时 ResizeObserver 不会收到尺寸变化。
|
||||||
|
const tabs = document.querySelector("#ele-admin-tabs")
|
||||||
|
if (tabs && window.MutationObserver) {
|
||||||
|
this.tabsObserver = new MutationObserver(() => this.onPageVisible())
|
||||||
|
this.tabsObserver.observe(tabs, { childList: true, subtree: true })
|
||||||
|
}
|
||||||
|
this.onPageVisible()
|
||||||
|
},
|
||||||
|
updated() {
|
||||||
|
// 首次取得尺寸不代表异步布局已稳定,每次渲染后继续校正。
|
||||||
|
if (this.$refs.tableRef) {
|
||||||
|
this.onPageVisible()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.stopTableResize()
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
add_welfare_list_by_select: ADD_WELFARE_LIST_BY_SELECT,
|
add_welfare_list_by_select: ADD_WELFARE_LIST_BY_SELECT,
|
||||||
@@ -306,8 +331,10 @@ layout("/layouts/platform.html"){
|
|||||||
checkedFields: [],
|
checkedFields: [],
|
||||||
pageForm: {
|
pageForm: {
|
||||||
searchName: "username",
|
searchName: "username",
|
||||||
|
pageSize: 50,
|
||||||
year: new Date().getFullYear().toString()
|
year: new Date().getFullYear().toString()
|
||||||
},
|
},
|
||||||
|
tableHeight: 300,
|
||||||
unionOptions: [],
|
unionOptions: [],
|
||||||
unitOptions: [],
|
unitOptions: [],
|
||||||
sexOptions: ["男", "女"],
|
sexOptions: ["男", "女"],
|
||||||
@@ -335,6 +362,57 @@ layout("/layouts/platform.html"){
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
// 等页面完成显示后再测量,避免 PJAX 隐藏内容区时得到错误尺寸。
|
||||||
|
onPageVisible() {
|
||||||
|
this.$nextTick(() => requestAnimationFrame(() => this.updateTableHeight()))
|
||||||
|
},
|
||||||
|
// 用卡片实际底部校正表格高度,涵盖分页和卡片留白,避免首次渲染后布局变化遮住分页。
|
||||||
|
updateTableHeight() {
|
||||||
|
if (!this.$refs.tableRef) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const table = this.$refs.tableRef.$el
|
||||||
|
const card = table.closest(".el-card")
|
||||||
|
const content = table.closest(".ele-admin-content")
|
||||||
|
const pagination = card.querySelector(".el-pagination-container")
|
||||||
|
if (!pagination || !table.isConnected || !table.getBoundingClientRect().width || !pagination.getBoundingClientRect().height) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content.scrollTop = 0
|
||||||
|
const pageBody = table.closest(".ele-body")
|
||||||
|
const pagePadding = parseFloat(getComputedStyle(pageBody).paddingBottom) || 0
|
||||||
|
// 页签异步出现时内容区可能下移,分页必须留在浏览器可视范围内。
|
||||||
|
const visibleBottom = Math.min(content.getBoundingClientRect().bottom, window.innerHeight) - pagePadding - 4
|
||||||
|
const nextHeight = Math.max(120, Math.floor(table.getBoundingClientRect().height + visibleBottom - card.getBoundingClientRect().bottom))
|
||||||
|
if (nextHeight !== this.tableHeight) {
|
||||||
|
this.tableHeight = nextHeight
|
||||||
|
}
|
||||||
|
// 卡片和筛选区异步变化时重新校正;表格高度稳定后不再触发更新。
|
||||||
|
if (!this.tableResizeObserver && window.ResizeObserver) {
|
||||||
|
this.tableResizeObserver = new ResizeObserver(() => this.updateTableHeight())
|
||||||
|
const resizeTargets = [content, document.querySelector("#ele-admin-tabs"), this.$el.querySelector(".el-card"), card, card.querySelector(".ele-table-tool"), pagination]
|
||||||
|
resizeTargets.forEach((element) => {
|
||||||
|
if (element) {
|
||||||
|
this.tableResizeObserver.observe(element)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// PJAX 切换或组件销毁时移除监听,避免旧页面继续响应窗口变化。
|
||||||
|
stopTableResize() {
|
||||||
|
window.removeEventListener("resize", this.pageVisibleHandler)
|
||||||
|
window.removeEventListener("load", this.pageVisibleHandler)
|
||||||
|
$(document).off("pjax:send", this.pageExitHandler)
|
||||||
|
$(document).off("pjax:complete", this.pageVisibleHandler)
|
||||||
|
if (this.tabsObserver) {
|
||||||
|
this.tabsObserver.disconnect()
|
||||||
|
this.tabsObserver = null
|
||||||
|
}
|
||||||
|
if (this.tableResizeObserver) {
|
||||||
|
this.tableResizeObserver.disconnect()
|
||||||
|
this.tableResizeObserver = null
|
||||||
|
}
|
||||||
|
},
|
||||||
// 导出收货地址
|
// 导出收货地址
|
||||||
exportAddress() {
|
exportAddress() {
|
||||||
this.$downLoad("/platform/welfare/list/mange/exportAddress", this.pageForm)
|
this.$downLoad("/platform/welfare/list/mange/exportAddress", this.pageForm)
|
||||||
@@ -422,6 +500,16 @@ layout("/layouts/platform.html"){
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ele-admin-fixed-body .ele-admin-content {
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app .welfare-list-table .el-table__body-wrapper {
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<!--#
|
<!--#
|
||||||
}
|
}
|
||||||
#-->
|
#-->
|
||||||
|
|||||||
@@ -52,13 +52,16 @@ const applyForm = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<div v-if="activity.familyCountScope === 2" style="color: #c64120; padding: 10px 16px">
|
||||||
|
本活动累计最多报名{{activity.familyMaxCount}}名不同{{activity.keyWord}},含候补;
|
||||||
|
</div>
|
||||||
<div v-if="formData.mobileColumnsValue && formData.mobileColumnsValue.length > 0" class="mt10">
|
<div v-if="formData.mobileColumnsValue && formData.mobileColumnsValue.length > 0" class="mt10">
|
||||||
<van-tabs v-model="familyActive" type="card" color="#0e78c5" animated>
|
<van-tabs v-model="familyActive" type="card" color="#0e78c5" animated>
|
||||||
<van-tab v-for="item, index in formData.mobileColumnsValue"
|
<van-tab v-for="item, index in formData.mobileColumnsValue"
|
||||||
:name="index + ''"
|
:name="index + ''"
|
||||||
:title="activity.keyWord + (index + 1)"
|
:title="activity.keyWord + (index + 1)"
|
||||||
:key="index">
|
:key="index">
|
||||||
<train-dynamic-form v-model="formData.mobileColumnsValue[index]" :key="index"></train-dynamic-form>
|
<train-dynamic-form v-model="formData.mobileColumnsValue[index]" :key="index" full-option-text></train-dynamic-form>
|
||||||
</van-tab>
|
</van-tab>
|
||||||
</van-tabs>
|
</van-tabs>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,8 +106,18 @@ const applyForm = {
|
|||||||
},
|
},
|
||||||
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
|
||||||
|
}
|
||||||
|
// 与PC一致,单次添加同时受类型和活动上限约束,跨课程累计由后端负责。
|
||||||
|
if (this.activity.familyCountScope === 2
|
||||||
|
&& this.formData.mobileColumnsValue.length >= Number(this.activity.familyMaxCount)) {
|
||||||
|
this.$toast('本活动累计家属人数最多为' + this.activity.familyMaxCount)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const list = clone(this.courseType.familyMobileSignColumnList)
|
const list = clone(this.courseType.familyMobileSignColumnList)
|
||||||
|
|||||||
@@ -16,11 +16,22 @@ layout("/layouts/platform_h5.html"){
|
|||||||
text-overflow: unset !important;
|
text-overflow: unset !important;
|
||||||
white-space: normal !important;
|
white-space: normal !important;
|
||||||
}
|
}
|
||||||
.sign-success .label{
|
/* 仅突出当前活动列表的报名结果,姓名过长时完整换行展示。 */
|
||||||
color: #0e9558;
|
.table-list-container .sign-success {
|
||||||
|
align-items: baseline;
|
||||||
|
line-height: 28px;
|
||||||
}
|
}
|
||||||
.sign-success .value{
|
.table-list-container .sign-success .label {
|
||||||
color: #0e9558;
|
color: #087f4f;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.table-list-container .sign-success .value {
|
||||||
|
min-width: 0;
|
||||||
|
color: #087f4f;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
approval: JSON.stringify({
|
||||||
|
...this.approvalParam,
|
||||||
|
bpmTaskApprovalType: this.approvalType,
|
||||||
|
approvalOpinion: this.approvalOpinion
|
||||||
})
|
})
|
||||||
if (confirm === "confirm") {
|
|
||||||
const resp = await this.$axios.post("/platform/club/applyJoin/clubAudit/doAudit", {
|
|
||||||
ids: JSON.stringify(this.auditIdList),
|
|
||||||
auditResult: this.auditResult,
|
|
||||||
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(() => {
|
||||||
|
|||||||
@@ -0,0 +1,504 @@
|
|||||||
|
<!--#
|
||||||
|
layout("/layouts/platform_h5.html"){
|
||||||
|
#-->
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.learning-course-page {
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #f4f7fb;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.learning-course-page .van-nav-bar {
|
||||||
|
background: #f4f7fb;
|
||||||
|
}
|
||||||
|
.learning-filter-card {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin: 10px 10px 0;
|
||||||
|
padding: 4px 10px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 3px 12px rgba(31, 72, 122, .08);
|
||||||
|
}
|
||||||
|
.learning-list-scroll {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.learning-filter-group {
|
||||||
|
overflow: hidden;
|
||||||
|
border-bottom: 1px solid #edf1f7;
|
||||||
|
}
|
||||||
|
.learning-filter-group + .learning-filter-group {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.learning-filter-group:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.learning-filter-category {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 2px;
|
||||||
|
color: #26354c;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.learning-filter-category-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.learning-filter-category-title {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.learning-filter-category-main .van-icon {
|
||||||
|
color: #1677ff;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
.learning-filter-options {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 1px 10px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.learning-filter-options::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.learning-filter-item {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 62px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid #e8f2ff;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f0f7ff;
|
||||||
|
text-align: center;
|
||||||
|
color: #7c899c;
|
||||||
|
font-size: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.learning-filter-item.active {
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 400;
|
||||||
|
background: #1989fa;
|
||||||
|
border-color: #1989fa;
|
||||||
|
}
|
||||||
|
.learning-filter-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.learning-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 18px 16px 10px;
|
||||||
|
}
|
||||||
|
.learning-section-title {
|
||||||
|
color: #202733;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.learning-section-total {
|
||||||
|
color: #8b96a6;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.learning-course-list {
|
||||||
|
padding: 0 12px 18px;
|
||||||
|
}
|
||||||
|
.learning-course-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 104px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.learning-course-cover {
|
||||||
|
position: relative;
|
||||||
|
flex: none;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #e6edf6;
|
||||||
|
}
|
||||||
|
.learning-course-cover img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.learning-course-cover-empty {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: linear-gradient(135deg, #3a7bd5, #8b5cf6);
|
||||||
|
}
|
||||||
|
.learning-course-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.learning-course-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 7px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.learning-course-title-text {
|
||||||
|
min-width: 0;
|
||||||
|
color: #202733;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.learning-course-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-width: 72px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px solid #ffd1a8;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #f28a35;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
background: #fff7ee;
|
||||||
|
}
|
||||||
|
.learning-course-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 9px;
|
||||||
|
}
|
||||||
|
.learning-course-intro {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #7a8798;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.learning-empty {
|
||||||
|
padding: 22px 0 36px;
|
||||||
|
}
|
||||||
|
@media (max-width: 360px) {
|
||||||
|
.learning-filter-card { margin-left: 8px; margin-right: 8px; padding-left: 8px; padding-right: 8px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="app" class="learning-course-page" v-cloak>
|
||||||
|
<van-nav-bar title="学习教育" left-text="返回" left-arrow @click-left="historyBack" fixed placeholder></van-nav-bar>
|
||||||
|
|
||||||
|
<div class="learning-filter-card">
|
||||||
|
<div class="learning-filter-group" v-for="group in filterGroups" :key="group.key">
|
||||||
|
<div class="learning-filter-category">
|
||||||
|
<div class="learning-filter-category-main">
|
||||||
|
<van-icon :name="group.icon"></van-icon>
|
||||||
|
<div class="learning-filter-category-title">{{ group.name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="learning-filter-options">
|
||||||
|
<div
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="item.key"
|
||||||
|
class="learning-filter-item"
|
||||||
|
:class="{active: isFilterActive(item)}"
|
||||||
|
@click="selectFilter(item)">
|
||||||
|
<div class="learning-filter-name">{{ item.name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="learning-list-scroll">
|
||||||
|
<div class="learning-section-head">
|
||||||
|
<div class="learning-section-title">我的课堂</div>
|
||||||
|
<div class="learning-section-total">共 {{ pageForm.totalCount || 0 }} 门</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||||
|
<van-list
|
||||||
|
v-model="loading"
|
||||||
|
:finished="finished"
|
||||||
|
:immediate-check="false"
|
||||||
|
:finished-text="courseList.length ? '没有更多了' : ''"
|
||||||
|
@load="onLoad">
|
||||||
|
<div class="learning-course-list">
|
||||||
|
<div
|
||||||
|
class="learning-course-card"
|
||||||
|
v-for="course in courseList"
|
||||||
|
:key="course.id"
|
||||||
|
@click="openCourse(course)">
|
||||||
|
<div class="learning-course-cover">
|
||||||
|
<img v-if="getCoverUrl(course.cover)" :src="getCoverUrl(course.cover)" :alt="course.courseName">
|
||||||
|
<div v-else class="learning-course-cover-empty">{{ course.courseTypeName || '课程' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="learning-course-info">
|
||||||
|
<div class="learning-course-title">
|
||||||
|
<div class="learning-course-title-text">{{ course.courseName || '未命名课程' }}</div>
|
||||||
|
<div v-if="firstRecommendName(course.recommendFlags)" class="learning-course-badge">{{ firstRecommendName(course.recommendFlags) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="learning-course-meta">
|
||||||
|
<van-tag v-if="course.courseTypeName" plain type="primary">{{ course.courseTypeName }}</van-tag>
|
||||||
|
<van-tag v-if="course.lecturerName" plain type="success">{{ course.lecturerName }}</van-tag>
|
||||||
|
</div>
|
||||||
|
<div class="learning-course-intro">{{ course.courseIntro || '暂无课程介绍' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-list>
|
||||||
|
</van-pull-refresh>
|
||||||
|
|
||||||
|
<van-empty v-if="!loading && !courseList.length" class="learning-empty" description="暂无相关课程"></van-empty>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script nonce="${cspNonce!}">
|
||||||
|
new Vue({
|
||||||
|
el: "#app",
|
||||||
|
store,
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
apiBase: "/platform/learning/course/display",
|
||||||
|
query: {
|
||||||
|
keyword: "",
|
||||||
|
courseTypeId: "",
|
||||||
|
recommendFlag: ""
|
||||||
|
},
|
||||||
|
pageForm: {
|
||||||
|
pageNumber: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
totalCount: 0
|
||||||
|
},
|
||||||
|
courseList: [],
|
||||||
|
courseTypeOptions: [],
|
||||||
|
recommendOptions: [],
|
||||||
|
loading: false,
|
||||||
|
finished: false,
|
||||||
|
refreshing: false,
|
||||||
|
requestSeq: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
filterGroups() {
|
||||||
|
const recommendItems = [{ key: "recommend_all", type: "recommend", value: "", name: "全部" }].concat(this.recommendOptions.map((item) => ({
|
||||||
|
key: "recommend_" + item.code,
|
||||||
|
type: "recommend",
|
||||||
|
value: item.code,
|
||||||
|
name: item.name
|
||||||
|
})))
|
||||||
|
const typeItems = [{ key: "type_all", type: "type", value: "", name: "全部" }].concat(this.courseTypeOptions.map((item) => ({
|
||||||
|
key: "type_" + item.id,
|
||||||
|
type: "type",
|
||||||
|
value: item.id,
|
||||||
|
name: item.typeName
|
||||||
|
})))
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "recommend",
|
||||||
|
name: "推荐标识",
|
||||||
|
icon: "label-o",
|
||||||
|
items: recommendItems
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "type",
|
||||||
|
name: "课程类型",
|
||||||
|
icon: "apps-o",
|
||||||
|
items: typeItems
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
listTitle() {
|
||||||
|
const recommend = this.recommendOptions.find(item => item.code === this.query.recommendFlag)
|
||||||
|
const type = this.courseTypeOptions.find(item => item.id === this.query.courseTypeId)
|
||||||
|
if (recommend && type) {
|
||||||
|
return recommend.name + " · " + type.typeName
|
||||||
|
}
|
||||||
|
if (recommend) {
|
||||||
|
return recommend.name
|
||||||
|
}
|
||||||
|
if (type) {
|
||||||
|
return type.typeName
|
||||||
|
}
|
||||||
|
return "我的课堂"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
historyBack,
|
||||||
|
async loadOptions() {
|
||||||
|
const [typeResp, recommendResp] = await Promise.all([
|
||||||
|
this.$axios.post(this.apiBase + "/courseTypes"),
|
||||||
|
this.$axios.post(this.apiBase + "/recommendOptions")
|
||||||
|
])
|
||||||
|
if (typeResp.code === 0) {
|
||||||
|
this.courseTypeOptions = typeResp.data || []
|
||||||
|
}
|
||||||
|
if (recommendResp.code === 0) {
|
||||||
|
this.recommendOptions = recommendResp.data || []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRefresh() {
|
||||||
|
this.finished = false
|
||||||
|
this.pageForm.pageNumber = 1
|
||||||
|
this.courseList = []
|
||||||
|
this.onLoad()
|
||||||
|
},
|
||||||
|
onLoad() {
|
||||||
|
this.fetchCourses()
|
||||||
|
},
|
||||||
|
fetchCourses() {
|
||||||
|
this.loading = true
|
||||||
|
const requestSeq = ++this.requestSeq
|
||||||
|
this.$axios.post(this.apiBase + "/pageData", {
|
||||||
|
keyword: this.query.keyword,
|
||||||
|
courseTypeId: this.query.courseTypeId,
|
||||||
|
recommendFlag: this.query.recommendFlag,
|
||||||
|
pageNumber: this.pageForm.pageNumber,
|
||||||
|
pageSize: this.pageForm.pageSize
|
||||||
|
}).then((res) => {
|
||||||
|
if (requestSeq !== this.requestSeq) return
|
||||||
|
if (res.code === 0) {
|
||||||
|
const data = res.data || {}
|
||||||
|
const list = data.list || []
|
||||||
|
if (this.refreshing || this.pageForm.pageNumber === 1) {
|
||||||
|
this.courseList = []
|
||||||
|
}
|
||||||
|
this.courseList = this.courseList.concat(list)
|
||||||
|
this.pageForm.totalCount = data.totalCount || 0
|
||||||
|
this.pageForm.pageNumber += 1
|
||||||
|
this.finished = this.courseList.length >= this.pageForm.totalCount
|
||||||
|
} else {
|
||||||
|
this.finished = true
|
||||||
|
this.$toast(res.msg || "查询失败")
|
||||||
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
if (requestSeq === this.requestSeq) {
|
||||||
|
this.loading = false
|
||||||
|
this.refreshing = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
doSearch() {
|
||||||
|
this.pageForm.pageNumber = 1
|
||||||
|
this.pageForm.totalCount = 0
|
||||||
|
this.courseList = []
|
||||||
|
this.finished = false
|
||||||
|
this.loading = true
|
||||||
|
this.onLoad()
|
||||||
|
},
|
||||||
|
selectFilter(item) {
|
||||||
|
if (!item) return
|
||||||
|
if (item.type === "recommend") {
|
||||||
|
this.query.recommendFlag = item.value || ""
|
||||||
|
} else if (item.type === "type") {
|
||||||
|
this.query.courseTypeId = item.value || ""
|
||||||
|
}
|
||||||
|
this.doSearch()
|
||||||
|
},
|
||||||
|
isFilterActive(item) {
|
||||||
|
if (!item) return false
|
||||||
|
if (item.type === "recommend") {
|
||||||
|
return (this.query.recommendFlag || "") === (item.value || "")
|
||||||
|
}
|
||||||
|
if (item.type === "type") {
|
||||||
|
return (this.query.courseTypeId || "") === (item.value || "")
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
getCoverUrl(cover) {
|
||||||
|
if (!cover) return ""
|
||||||
|
if (Array.isArray(cover)) {
|
||||||
|
return cover.length ? (cover[0].url || (cover[0].response && cover[0].response.data) || cover[0].data || "") : ""
|
||||||
|
}
|
||||||
|
if (typeof cover === "string") {
|
||||||
|
const text = cover.trim()
|
||||||
|
if (!text) return ""
|
||||||
|
if (text.startsWith("[")) {
|
||||||
|
try {
|
||||||
|
const files = JSON.parse(text)
|
||||||
|
return files.length ? (files[0].url || (files[0].response && files[0].response.data) || files[0].data || "") : ""
|
||||||
|
} catch (e) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
openCourse(course) {
|
||||||
|
if (!course || !course.id) return
|
||||||
|
pjaxReplace("/platform/learning/course/h5/study?id=" + course.id)
|
||||||
|
},
|
||||||
|
splitFlags(flags) {
|
||||||
|
return flags ? flags.split(",").filter(Boolean) : []
|
||||||
|
},
|
||||||
|
getRecommendName(code) {
|
||||||
|
const item = this.recommendOptions.find(v => v.code === code)
|
||||||
|
return item ? item.name : code
|
||||||
|
},
|
||||||
|
firstRecommendName(flags) {
|
||||||
|
const codes = this.splitFlags(flags)
|
||||||
|
return codes.length ? this.getRecommendName(codes[0]) : ""
|
||||||
|
},
|
||||||
|
recommendIcon(index) {
|
||||||
|
const icons = ["fire-o", "star-o", "award-o", "gem-o", "flag-o", "good-job-o"]
|
||||||
|
return icons[index % icons.length]
|
||||||
|
},
|
||||||
|
typeIcon(index) {
|
||||||
|
const icons = ["video-o", "records-o", "description-o", "bookmark-o", "cluster-o", "desktop-o"]
|
||||||
|
return icons[index % icons.length]
|
||||||
|
},
|
||||||
|
palette(index) {
|
||||||
|
const colors = ["#28a6df", "#f2b84b", "#f46d5f", "#7b8fda", "#59bbb2", "#2d7db8", "#ee755b", "#f3c256", "#ef9461", "#8ba4dc"]
|
||||||
|
return colors[index % colors.length]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
await this.loadOptions()
|
||||||
|
this.onLoad()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--#
|
||||||
|
}
|
||||||
|
#-->
|
||||||
@@ -0,0 +1,944 @@
|
|||||||
|
<!--#
|
||||||
|
layout("/layouts/platform_h5.html"){
|
||||||
|
#-->
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.learning-study-page { height: 100vh; height: 100dvh; overflow-y: auto; background: #f5f8fd; color: #152238; -webkit-overflow-scrolling: touch; }
|
||||||
|
.study-hero { position: relative; min-height: 240px; background: linear-gradient(135deg, #172a49, #427ac2); overflow: hidden; }
|
||||||
|
.study-hero > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.study-hero::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, rgba(10, 19, 37, .86), rgba(10, 19, 37, .15)), linear-gradient(0deg, rgba(10, 19, 37, .75), transparent); }
|
||||||
|
.study-back { position: absolute; top: 12px; left: 14px; z-index: 2; width: 34px; height: 34px; border: 0; border-radius: 50%; background: rgba(0, 0, 0, .5); color: #fff; font-size: 24px; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.study-title-block { position: relative; z-index: 1; padding: 65px 16px 30px; color: #fff; }
|
||||||
|
.study-badge { display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px; margin-bottom: 9px; border-radius: 12px; background: rgba(154, 91, 0, .72); color: #fff4d8; font-size: 12px; }
|
||||||
|
.study-title { font-size: 23px; font-weight: 700; line-height: 1.45; overflow-wrap: anywhere; }
|
||||||
|
.study-subtitle { margin-top: 7px; color: #d4deed; font-size: 13px; line-height: 1.6; }
|
||||||
|
.study-meta { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 12px; color: #d4deed; font-size: 12px; }
|
||||||
|
.study-meta-item { display: inline-flex; align-items: center; gap: 5px; }
|
||||||
|
.study-content { position: relative; z-index: 1; margin-top: -16px; padding: 8px 14px 24px; border-radius: 20px 20px 0 0; background: #f5f8fd; }
|
||||||
|
.study-navigation { display: flex; padding: 4px; margin-bottom: 12px; border-radius: 12px; background: #fff; }
|
||||||
|
.study-navigation button { position: relative; flex: 1; padding: 8px 4px; border: 0; border-radius: 9px; background: transparent; color: #667b9a; font-size: 12px; }
|
||||||
|
.study-navigation .van-icon { display: block; margin-bottom: 4px; font-size: 20px; }
|
||||||
|
.study-navigation .active { color: #1677ff; background: #f2f7ff; font-weight: 700; }
|
||||||
|
.study-navigation .active::after { content: ""; position: absolute; bottom: 0; left: calc(50% - 12px); width: 24px; height: 3px; border-radius: 2px; background: #1677ff; }
|
||||||
|
.study-progress-card, .study-card { padding: 16px; margin-bottom: 14px; border-radius: 16px; background: #fff; }
|
||||||
|
.study-progress-card { display: flex; align-items: center; gap: 10px; padding: 12px 14px; margin-bottom: 12px; border-radius: 12px; }
|
||||||
|
.study-progress-main { flex: 1; min-width: 0; }
|
||||||
|
.study-progress-title { margin-bottom: 4px; font-size: 13px; font-weight: 700; line-height: 1.4; }
|
||||||
|
.study-progress-line { display: flex; align-items: center; gap: 8px; color: #1677ff; font-size: 16px; font-weight: 700; line-height: 1.2; }
|
||||||
|
.study-progress-line .van-progress { flex: 1; min-width: 0; overflow: hidden; }
|
||||||
|
.study-progress-line > span { flex: 0 0 3.5em; white-space: nowrap; text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.study-progress-note { margin-top: 4px; color: #7a8ca6; font-size: 10px; line-height: 1.4; }
|
||||||
|
.study-action { flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; gap: 4px; padding: 6px 10px; border: 1px solid #dbe9ff; border-radius: 20px; background: #f6faff; color: #1677ff; font-size: 12px; white-space: nowrap; }
|
||||||
|
.study-action:disabled { color: #a3afbf; background: #f5f6f8; }
|
||||||
|
.study-continue { padding: 9px 12px; border: 0; background: #e8f1ff; font-size: 12px; font-weight: 700; line-height: 1.5; }
|
||||||
|
.study-section-head { display: flex; align-items: center; gap: 8px; margin: 20px 0 14px; }
|
||||||
|
.study-section-head strong { font-size: 20px; }
|
||||||
|
.study-section-head small { color: #7a8ca6; font-size: 12px; }
|
||||||
|
.study-text-button { margin-left: auto; border: 0; padding: 4px 0; background: transparent; color: #7a8ca6; font-size: 12px; white-space: nowrap; }
|
||||||
|
.study-chapter-head { display: flex; align-items: center; gap: 9px; width: 100%; padding: 0; border: 0; background: transparent; color: #152238; text-align: left; font-size: 16px; font-weight: 700; }
|
||||||
|
.study-chapter-head > span:nth-child(2) { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.study-chapter-dot { flex-shrink: 0; width: 10px; height: 10px; box-sizing: border-box; border: 2px solid #1677ff; border-radius: 50%; box-shadow: 0 0 0 6px #edf5ff; margin: 0 4px; }
|
||||||
|
.study-chapter-description { margin: 12px 0 4px 27px; color: #7a8ca6; font-size: 13px; line-height: 1.7; white-space: pre-line; }
|
||||||
|
.study-resource { display: flex; align-items: center; gap: 10px; padding: 16px 0; border-bottom: 1px solid #edf1f7; }
|
||||||
|
.study-resource:last-child { padding-bottom: 0; border-bottom: 0; }
|
||||||
|
.study-resource-icon { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 30px; height: 34px; border-radius: 7px; color: #fff; font-size: 23px; }
|
||||||
|
.study-resource-icon.video { background: #ffb723; }
|
||||||
|
.study-resource-icon.audio { background: #33b3a6; }
|
||||||
|
.study-resource-icon.image { background: #5a94f2; }
|
||||||
|
.study-resource-icon.word { background: transparent; font-size: 34px; }
|
||||||
|
.study-resource-icon.doc { background: #ff6868; }
|
||||||
|
.study-resource-main { flex: 1; min-width: 0; }
|
||||||
|
.study-resource-title { font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||||
|
.study-resource-status { margin-top: 5px; color: #7a8ca6; font-size: 11px; line-height: 1.6; }
|
||||||
|
.study-resource-status .completed { color: #2fc78c; }
|
||||||
|
.study-download-list { padding: 0 12px; border-radius: 12px; background: #fff; }
|
||||||
|
.study-download-row { display: flex; align-items: center; gap: 12px; min-height: 72px; padding: 14px 0; box-sizing: border-box; border-bottom: 1px solid #edf1f7; }
|
||||||
|
.study-download-row:last-child { border-bottom: 0; }
|
||||||
|
.study-download-icon { flex-shrink: 0; width: 34px; height: 38px; object-fit: contain; }
|
||||||
|
.study-download-info { flex: 1; min-width: 0; }
|
||||||
|
.study-download-name { color: #26364c; font-size: 14px; font-weight: 600; line-height: 1.5; overflow-wrap: anywhere; }
|
||||||
|
.study-download-meta { display: flex; flex-wrap: wrap; gap: 4px 8px; margin-top: 4px; color: #8b9ab0; font-size: 12px; line-height: 1.5; }
|
||||||
|
.study-download-meta:empty { display: none; }
|
||||||
|
.study-download-button { flex-shrink: 0; min-width: 54px; padding: 5px 12px; border: 1px solid #cce2ff; border-radius: 18px; background: #f5faff; color: #1989fa; font-size: 12px; line-height: 1.5; }
|
||||||
|
.study-catalog-card { padding: 14px 12px; }
|
||||||
|
.study-catalog-card .study-chapter-head { gap: 10px; font-size: 15px; line-height: 1.5; }
|
||||||
|
.study-catalog-card .study-chapter-head > .van-icon { color: #7a8ca6; font-size: 14px; }
|
||||||
|
.study-chapter-body { position: relative; padding-left: 26px; }
|
||||||
|
.study-chapter-body::before { content: ""; position: absolute; top: 5px; bottom: 24px; left: 9px; width: 1px; transform: translateX(-50%); background: #e8eef7; }
|
||||||
|
.study-catalog-card .study-chapter-description { margin: 8px 0 4px; color: #8293ad; font-size: 12px; line-height: 1.6; }
|
||||||
|
.study-catalog-card .study-resource { position: relative; gap: 9px; padding: 14px 0; }
|
||||||
|
.study-catalog-card .study-resource::before { content: ""; position: absolute; top: 50%; left: -17px; width: 8px; height: 8px; box-sizing: border-box; transform: translate(-50%, -50%); border: 2px solid #96aaca; border-radius: 50%; background: #fff; }
|
||||||
|
.study-catalog-card .study-resource:last-child { padding-bottom: 10px; }
|
||||||
|
.study-catalog-card .study-resource-title { font-size: 13px; font-weight: 600; line-height: 1.5; }
|
||||||
|
.study-catalog-card .study-resource-status { margin-top: 3px; font-size: 10px; }
|
||||||
|
.study-catalog-card .study-resource-icon { width: 28px; height: 32px; font-size: 21px; }
|
||||||
|
.study-catalog-card .study-resource-icon.word, .study-catalog-card .study-resource-icon.pdf { background: transparent; font-size: 30px; }
|
||||||
|
.study-catalog-card .study-action { padding: 5px 9px; font-size: 11px; }
|
||||||
|
.study-catalog-card .completed .van-icon { margin-right: 3px; vertical-align: -1px; }
|
||||||
|
.study-intro { padding: 14px; margin-bottom: 10px; border-radius: 12px; background: #fff; color: #7a8ca6; font-size: 12px; line-height: 1.7; overflow-wrap: anywhere; }
|
||||||
|
.study-intro h3 { margin: 0 0 7px; color: #26364c; font-size: 14px; line-height: 1.5; font-weight: 700; }
|
||||||
|
.study-intro-text { white-space: pre-line; }
|
||||||
|
.study-goals { margin: 0; padding: 0; list-style: none; }
|
||||||
|
.study-goals li { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 7px; }
|
||||||
|
.study-goals li:last-child { margin-bottom: 0; }
|
||||||
|
.study-goals .van-icon { flex-shrink: 0; margin-top: 3px; color: #1989fa; font-size: 14px; }
|
||||||
|
.study-audience { display: flex; align-items: flex-start; gap: 10px; }
|
||||||
|
.study-audience > .van-icon { flex-shrink: 0; padding: 4px; border-radius: 6px; color: #1989fa; background: #edf6ff; font-size: 22px; }
|
||||||
|
.study-audience > span { padding-top: 4px; white-space: pre-line; }
|
||||||
|
@media (max-width: 359px) { .study-action { padding: 6px 8px; } }
|
||||||
|
.study-player-popup {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #000000;
|
||||||
|
}
|
||||||
|
.study-player-head {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #101828;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, .08);
|
||||||
|
}
|
||||||
|
.study-player-title {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.study-player-body {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #000000;
|
||||||
|
}
|
||||||
|
.study-media {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: none;
|
||||||
|
background: #000000;
|
||||||
|
border-radius: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.study-audio-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 220px;
|
||||||
|
border-radius: 0;
|
||||||
|
background: linear-gradient(135deg, #10233f, #0f766e);
|
||||||
|
}
|
||||||
|
.study-audio-wrap audio {
|
||||||
|
width: 88%;
|
||||||
|
}
|
||||||
|
.study-image-preview {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.study-doc-frame {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.study-file-fallback {
|
||||||
|
padding: 46px 18px;
|
||||||
|
color: #7a8798;
|
||||||
|
font-size: 15px;
|
||||||
|
text-align: center;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.study-player-exit {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 12px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.study-player-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-left: 12px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="app" class="learning-study-page" v-cloak>
|
||||||
|
<div class="study-hero">
|
||||||
|
<button type="button" class="study-back" aria-label="返回" @click="goBack"><van-icon name="arrow-left"></van-icon></button>
|
||||||
|
<img v-if="coverUrl" :src="coverUrl" :alt="course.courseName">
|
||||||
|
<div class="study-title-block">
|
||||||
|
<div v-if="courseBadge" class="study-badge"><van-icon name="fire"></van-icon>{{ courseBadge }}</div>
|
||||||
|
<div class="study-title">{{ course.courseName || '课程学习' }}</div>
|
||||||
|
<div v-if="course.courseTags" class="study-subtitle">{{ course.courseTags }}</div>
|
||||||
|
<div v-if="course.lecturerName" class="study-meta">
|
||||||
|
<span v-if="course.lecturerName" class="study-meta-item"><van-icon name="manager"></van-icon>{{ course.lecturerName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="study-content">
|
||||||
|
<nav class="study-navigation" aria-label="课程详情">
|
||||||
|
<button v-for="tab in detailTabs" :key="tab.name" type="button" :class="{ active: activeTab === tab.name }" :aria-pressed="activeTab === tab.name" @click="activeTab = tab.name">
|
||||||
|
<van-icon :name="tab.icon"></van-icon>{{ tab.title }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
<div v-if="activeTab === 'intro'" class="study-introduction">
|
||||||
|
<section class="study-intro">
|
||||||
|
<h3>课程简介</h3>
|
||||||
|
<div class="study-intro-text">{{ course.courseIntro || '暂无课程简介' }}</div>
|
||||||
|
</section>
|
||||||
|
<section class="study-intro">
|
||||||
|
<h3>学习目标</h3>
|
||||||
|
<ul v-if="learningGoals.length" class="study-goals">
|
||||||
|
<li v-for="(goal, index) in learningGoals" :key="index"><van-icon name="checked"></van-icon><span>{{ goal }}</span></li>
|
||||||
|
</ul>
|
||||||
|
<div v-else class="study-intro-text">暂无学习目标</div>
|
||||||
|
</section>
|
||||||
|
<section class="study-intro">
|
||||||
|
<h3>适用人群</h3>
|
||||||
|
<div class="study-audience"><van-icon name="manager"></van-icon><span>{{ course.suitablePeople || '暂无适用人群说明' }}</span></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div v-if="activeTab === 'catalog'">
|
||||||
|
<div v-if="Number(course.outlineCount || 0) > 0" class="study-progress-card">
|
||||||
|
<div class="study-progress-main">
|
||||||
|
<div class="study-progress-title">学习进度</div>
|
||||||
|
<div class="study-progress-line"><van-progress :percentage="totalProgress" :show-pivot="false" stroke-width="8" color="#1989fa"></van-progress><span>{{ totalProgress }}%</span></div>
|
||||||
|
<div class="study-progress-note">已完成 {{ completedChapterCount }} 个章节,共 {{ totalMinutes }} 分钟</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="study-action study-continue" :disabled="!learnableResources.length" @click="continueStudy"><van-icon name="play"></van-icon>{{ totalProgress ? '继续学习' : '开始学习' }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="study-section-head">
|
||||||
|
<strong>课程目录</strong><small>共 {{ chapterCount }} 个章节</small>
|
||||||
|
<button v-if="chapterCount" type="button" class="study-text-button" @click="toggleAllChapters">{{ allChaptersCollapsed ? '展开全部' : '收起全部' }} <van-icon :name="allChaptersCollapsed ? 'arrow-down' : 'arrow-up'"></van-icon></button>
|
||||||
|
</div>
|
||||||
|
<div v-for="chapter in catalogList" :key="chapter.id" class="study-card study-catalog-card">
|
||||||
|
<button type="button" class="study-chapter-head" :aria-expanded="!collapsedChapters[chapter.id]" @click="toggleChapter(chapter.id)">
|
||||||
|
<span class="study-chapter-dot"></span><span>{{ chapter.title }}</span><van-icon :name="collapsedChapters[chapter.id] ? 'arrow-down' : 'arrow-up'"></van-icon>
|
||||||
|
</button>
|
||||||
|
<div v-show="!collapsedChapters[chapter.id]" class="study-chapter-body">
|
||||||
|
<div v-if="chapter.description" class="study-chapter-description">{{ chapter.description }}</div>
|
||||||
|
<div v-for="resource in chapter.resources" :key="resource.id" class="study-resource">
|
||||||
|
<div class="study-resource-icon" :class="resourceIconClass(resource)"><van-icon :name="resourceIcon(resource)"></van-icon></div>
|
||||||
|
<div class="study-resource-main">
|
||||||
|
<div class="study-resource-title">{{ resource.title }}</div>
|
||||||
|
<div class="study-resource-status">{{ resourceMeta(resource) }} · <span :class="{ completed: resource.studied && resource.completeStatus === 'completed' }"><van-icon v-if="resource.completeStatus === 'completed'" name="checked"></van-icon>{{ statusText(resource) }}</span></div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="study-action" :disabled="resource.allowPreview === false" @click="openResource(resource)"><van-icon name="play"></van-icon>{{ resource.allowPreview === false ? '不可学习' : resource.studied ? '再次学习' : '学习' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<van-empty v-if="!catalogList.length" description="暂无课程目录"></van-empty>
|
||||||
|
</div>
|
||||||
|
<div v-if="activeTab === 'downloads'" class="study-downloads">
|
||||||
|
<div v-if="downloadableResources.length" class="study-download-list">
|
||||||
|
<div v-for="resource in downloadableResources" :key="resource.id" class="study-download-row">
|
||||||
|
<img class="study-download-icon" :src="downloadIcon(resource)" alt="">
|
||||||
|
<div class="study-download-info">
|
||||||
|
<div class="study-download-name">{{ downloadFileInfo(resource).name || resource.title }}</div>
|
||||||
|
<div class="study-download-meta">
|
||||||
|
<span v-if="downloadSize(resource)">{{ downloadSize(resource) }}</span>
|
||||||
|
<span v-if="downloadDate(resource)">{{ downloadDate(resource) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="study-download-button" @click="downloadResource(resource)">下载</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<van-empty v-else description="暂无可下载资料"></van-empty>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<van-popup v-model="playerVisible" position="bottom" class="study-player-popup" :overlay="false">
|
||||||
|
<div class="study-player-head">
|
||||||
|
<div class="study-player-title">{{ selectedResource.title || '学习资料' }}</div>
|
||||||
|
<div class="study-player-actions">
|
||||||
|
<span v-if="canPreview && selectedResource.resourceType === 'video'" @click="enterVideoFullscreen">横屏</span>
|
||||||
|
<span @click="closePlayer">退出</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="study-player-body">
|
||||||
|
<div v-if="!canPreview" class="study-file-fallback">该资料不允许预览</div>
|
||||||
|
<video
|
||||||
|
v-else-if="selectedResource.resourceType === 'video'"
|
||||||
|
ref="mediaPlayer"
|
||||||
|
class="study-media"
|
||||||
|
:src="mediaUrl"
|
||||||
|
controls
|
||||||
|
:controlslist="canDownload ? null : 'nodownload'"
|
||||||
|
playsinline
|
||||||
|
webkit-playsinline
|
||||||
|
x5-video-player-type="h5"
|
||||||
|
x5-video-orientation="landscape|portrait"
|
||||||
|
x-webkit-airplay="allow"
|
||||||
|
@loadedmetadata="restoreMediaPosition"
|
||||||
|
@seeked="handleMediaSeeked"
|
||||||
|
@seeking="handleMediaSeeking"
|
||||||
|
@play="startStudy"
|
||||||
|
@pause="saveMediaPosition(false)"
|
||||||
|
@timeupdate="saveProgress"
|
||||||
|
@ended="finishStudy(false, true)">
|
||||||
|
</video>
|
||||||
|
<div v-else-if="selectedResource.resourceType === 'audio'" class="study-audio-wrap">
|
||||||
|
<audio
|
||||||
|
ref="mediaPlayer"
|
||||||
|
:src="fileUrl"
|
||||||
|
preload="metadata"
|
||||||
|
controls
|
||||||
|
:controlslist="canDownload ? null : 'nodownload'"
|
||||||
|
@loadedmetadata="restoreMediaPosition"
|
||||||
|
@seeked="handleMediaSeeked"
|
||||||
|
@seeking="handleMediaSeeking"
|
||||||
|
@play="startStudy"
|
||||||
|
@pause="saveMediaPosition(false)"
|
||||||
|
@timeupdate="saveProgress"
|
||||||
|
@ended="finishStudy(false, true)">
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
<img v-else-if="selectedResource.resourceType === 'image'" class="study-image-preview" :src="fileUrl" :alt="selectedResource.title">
|
||||||
|
<iframe v-else-if="canInlinePreview(selectedResource)" class="study-doc-frame" :src="inlinePreviewUrl"></iframe>
|
||||||
|
<div v-else class="study-file-fallback">
|
||||||
|
<p>该资料暂不支持内嵌预览</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script nonce="${cspNonce!}">
|
||||||
|
new Vue({
|
||||||
|
el: "#app",
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
courseId: "",
|
||||||
|
apiBase: "/platform/learning/course/display",
|
||||||
|
recordApi: "/platform/learning/study/record",
|
||||||
|
activeTab: "catalog",
|
||||||
|
detailTabs: [
|
||||||
|
{ name: "intro", title: "课程介绍", icon: "description" },
|
||||||
|
{ name: "catalog", title: "课程目录", icon: "orders-o" },
|
||||||
|
{ name: "downloads", title: "资料下载", icon: "down" }
|
||||||
|
],
|
||||||
|
collapsedChapters: {},
|
||||||
|
course: {},
|
||||||
|
treeData: [],
|
||||||
|
downloadFiles: {},
|
||||||
|
selectedResource: {},
|
||||||
|
playerVisible: false,
|
||||||
|
startingStudy: false,
|
||||||
|
switchingResource: false,
|
||||||
|
currentSegmentId: "",
|
||||||
|
studying: false,
|
||||||
|
pendingSeconds: 0,
|
||||||
|
heartbeatTimer: null,
|
||||||
|
resumePositionSeconds: 0,
|
||||||
|
resumeSeekRetryCount: 0,
|
||||||
|
resumePositionRestored: false,
|
||||||
|
lastSavedPositionSeconds: 0,
|
||||||
|
lastPositionSyncAt: 0,
|
||||||
|
lastAllowedPositionSeconds: 0,
|
||||||
|
restoringMediaPosition: false,
|
||||||
|
pageVisibilityHandler: null,
|
||||||
|
navigatingBack: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
coverUrl() {
|
||||||
|
return this.getFileUrl(this.course.cover)
|
||||||
|
},
|
||||||
|
canPreview() { return !!this.selectedResource.id && this.selectedResource.allowPreview !== false },
|
||||||
|
canDownload() { return !!this.selectedResource.id && this.selectedResource.allowDownload === true },
|
||||||
|
fileUrl() {
|
||||||
|
if (!this.canPreview) return ""
|
||||||
|
const id = this.getFileId(this.selectedResource.fileData)
|
||||||
|
return id ? "/platform/sys/file/preview?id=" + encodeURIComponent(id) : this.getFileUrl(this.selectedResource.fileData)
|
||||||
|
},
|
||||||
|
downloadUrl() {
|
||||||
|
if (!this.canDownload) return ""
|
||||||
|
const id = this.getFileId(this.selectedResource.fileData)
|
||||||
|
return id ? "/platform/sys/file/download?id=" + encodeURIComponent(id) : this.getFileUrl(this.selectedResource.fileData)
|
||||||
|
},
|
||||||
|
mediaUrl() {
|
||||||
|
if (!this.canPreview) return ""
|
||||||
|
if (this.selectedResource.resourceType !== "video") return this.fileUrl
|
||||||
|
const fileId = this.getFileId(this.selectedResource.fileData)
|
||||||
|
// 视频必须使用支持 Range 的播放接口,否则手动拖动和历史位置恢复都会失效。
|
||||||
|
return fileId ? "/platform/sys/file/videoPlay?id=" + encodeURIComponent(fileId) : this.fileUrl
|
||||||
|
},
|
||||||
|
inlinePreviewUrl() {
|
||||||
|
if (!this.canPreview) return ""
|
||||||
|
const id = this.getFileId(this.selectedResource.fileData)
|
||||||
|
const url = id ? this.apiBase + "/pdfPreview?id=" + encodeURIComponent(id) : this.fileUrl
|
||||||
|
return this.canDownload ? url : url + "#toolbar=0"
|
||||||
|
},
|
||||||
|
catalogList() {
|
||||||
|
const result = []
|
||||||
|
;(this.treeData || []).forEach(node => this.appendCatalogNode(node, result))
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
courseBadge() {
|
||||||
|
const flags = this.course.recommendFlags || ""
|
||||||
|
return Array.isArray(flags) ? flags[0] : String(flags).split(",")[0]
|
||||||
|
},
|
||||||
|
allResources() {
|
||||||
|
return this.collectResources(this.treeData)
|
||||||
|
},
|
||||||
|
learnableResources() {
|
||||||
|
return this.allResources.filter(resource => resource.allowPreview !== false)
|
||||||
|
},
|
||||||
|
downloadableResources() {
|
||||||
|
return this.allResources.filter(resource => resource.allowDownload === true && this.getFileUrl(resource.fileData))
|
||||||
|
},
|
||||||
|
completedChapterCount() {
|
||||||
|
return Number(this.course.completedOutlineCount || 0)
|
||||||
|
},
|
||||||
|
allChaptersCollapsed() {
|
||||||
|
return this.catalogList.length > 0 && this.catalogList.every(chapter => this.collapsedChapters[chapter.id])
|
||||||
|
},
|
||||||
|
chapterCount() {
|
||||||
|
return this.catalogList.filter(chapter => chapter.resources.some(resource => resource.allowPreview !== false)).length
|
||||||
|
},
|
||||||
|
totalMinutes() {
|
||||||
|
const seconds = this.learnableResources.reduce((sum, item) => sum + Number(item.durationSeconds || 0), 0)
|
||||||
|
return Math.max(0, Math.ceil(seconds / 60))
|
||||||
|
},
|
||||||
|
totalProgress() {
|
||||||
|
return Number(this.course.progressPercent || 0)
|
||||||
|
},
|
||||||
|
learningGoals() {
|
||||||
|
return String(this.course.learningGoal || "").split(/[\r\n;;]+/).map(goal => goal.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getQuery(name) {
|
||||||
|
return new URLSearchParams(window.location.search).get(name) || ""
|
||||||
|
},
|
||||||
|
async loadCourse() {
|
||||||
|
const resp = await this.$axios.post(this.apiBase + "/courseInfo", { id: this.courseId })
|
||||||
|
if (resp.code === 0) {
|
||||||
|
this.course = resp.data || {}
|
||||||
|
} else {
|
||||||
|
this.$toast(resp.msg || "课程不存在")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadTree() {
|
||||||
|
const resp = await this.$axios.post(this.apiBase + "/studyTree", { courseId: this.courseId })
|
||||||
|
if (resp.code === 0) {
|
||||||
|
this.treeData = resp.data || []
|
||||||
|
this.loadDownloadFiles()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
appendCatalogNode(node, result) {
|
||||||
|
if (!node) return
|
||||||
|
if (node.type === "resource") return
|
||||||
|
const resources = this.collectResources(node.children || [])
|
||||||
|
if (node.nodeType === "chapter" || resources.length) {
|
||||||
|
result.push({
|
||||||
|
id: node.id,
|
||||||
|
title: node.title,
|
||||||
|
description: node.description,
|
||||||
|
resources: resources
|
||||||
|
})
|
||||||
|
}
|
||||||
|
;(node.children || []).forEach(child => {
|
||||||
|
if (child.type !== "resource" && child.nodeType === "chapter") {
|
||||||
|
this.appendCatalogNode(child, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
collectResources(nodes) {
|
||||||
|
const resources = []
|
||||||
|
;(nodes || []).forEach(node => {
|
||||||
|
if (node.type === "resource") {
|
||||||
|
resources.push(node)
|
||||||
|
} else {
|
||||||
|
resources.push.apply(resources, this.collectResources(node.children || []))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return resources
|
||||||
|
},
|
||||||
|
toggleChapter(id) {
|
||||||
|
this.$set(this.collapsedChapters, id, !this.collapsedChapters[id])
|
||||||
|
},
|
||||||
|
toggleAllChapters() {
|
||||||
|
const collapsed = !this.allChaptersCollapsed
|
||||||
|
this.catalogList.forEach(chapter => this.$set(this.collapsedChapters, chapter.id, collapsed))
|
||||||
|
},
|
||||||
|
continueStudy() {
|
||||||
|
const resources = this.learnableResources
|
||||||
|
const resource = resources.find(item => item.studied && item.completeStatus !== "completed")
|
||||||
|
|| resources.find(item => !item.studied)
|
||||||
|
|| resources.find(item => item.completeStatus !== "completed")
|
||||||
|
|| resources[0]
|
||||||
|
if (resource) this.openResource(resource)
|
||||||
|
},
|
||||||
|
resourceMeta(resource) {
|
||||||
|
const types = { video: "视频", audio: "音频", image: "图片", word: "Word文档", pdf: "PDF文档", ppt: "演示文稿" }
|
||||||
|
const type = types[resource.resourceType] || ((resource.fileExt || "").toUpperCase() + " 文档").trim()
|
||||||
|
const seconds = Number(resource.durationSeconds || 0)
|
||||||
|
if (seconds > 0) return type + " · " + Math.floor(seconds / 60) + ":" + String(seconds % 60).padStart(2, "0")
|
||||||
|
return type
|
||||||
|
},
|
||||||
|
async loadDownloadFiles() {
|
||||||
|
const ids = [...new Set(this.downloadableResources.map(resource => this.getFileId(resource.fileData)).filter(id => id && !this.downloadFiles[id]))]
|
||||||
|
if (!ids.length) return
|
||||||
|
try {
|
||||||
|
const resp = await this.$axios.post("/platform/sys/file/previewFileData", { ids: JSON.stringify(ids) })
|
||||||
|
if (resp.code === 0) {
|
||||||
|
;(resp.data || []).forEach(file => this.$set(this.downloadFiles, file.id, file))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.$toast("文件信息加载失败")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
downloadFileInfo(resource) {
|
||||||
|
const file = this.downloadFiles[this.getFileId(resource.fileData)]
|
||||||
|
if (file) return file
|
||||||
|
const attachment = this.getFileEntry(resource.fileData)
|
||||||
|
return attachment && typeof attachment === "object" ? attachment : {}
|
||||||
|
},
|
||||||
|
downloadIcon(resource) {
|
||||||
|
const file = this.downloadFileInfo(resource)
|
||||||
|
const ext = (file.suffix || resource.fileExt || (file.name || "").split(".").pop() || resource.resourceType || "").toLowerCase()
|
||||||
|
const icons = { pdf: "pdf", doc: "docx", docx: "docx", word: "docx", xls: "xlsx", xlsx: "xlsx", csv: "xlsx", ppt: "pptx", pptx: "pptx", zip: "zip", rar: "zip", "7z": "zip", mp3: "mp3", audio: "mp3", mp4: "mp4", video: "mp4" }
|
||||||
|
return "/assets/platform/img/svg/" + (icons[ext] || "txt") + ".svg"
|
||||||
|
},
|
||||||
|
downloadSize(resource) {
|
||||||
|
const file = this.downloadFileInfo(resource)
|
||||||
|
if (file.sizeInfo) return file.sizeInfo
|
||||||
|
const bytes = file.size != null ? Number(file.size) : file.sizeKb != null ? Number(file.sizeKb) * 1024 : NaN
|
||||||
|
if (!Number.isFinite(bytes) || bytes < 0) return ""
|
||||||
|
if (bytes < 1024) return bytes + "B"
|
||||||
|
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + "KB"
|
||||||
|
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + "MB"
|
||||||
|
return (bytes / 1073741824).toFixed(1) + "GB"
|
||||||
|
},
|
||||||
|
downloadDate(resource) {
|
||||||
|
const value = this.downloadFileInfo(resource).createdAt
|
||||||
|
if (!value) return ""
|
||||||
|
const date = new Date(/^\d+$/.test(String(value)) ? Number(value) : value)
|
||||||
|
if (Number.isNaN(date.getTime())) return ""
|
||||||
|
return date.getFullYear() + "-" + String(date.getMonth() + 1).padStart(2, "0") + "-" + String(date.getDate()).padStart(2, "0")
|
||||||
|
},
|
||||||
|
downloadResource(resource) {
|
||||||
|
if (resource.allowDownload !== true) return
|
||||||
|
const id = this.getFileId(resource.fileData)
|
||||||
|
const url = id ? "/platform/sys/file/download?id=" + encodeURIComponent(id) : this.getFileUrl(resource.fileData)
|
||||||
|
if (url) window.open(url)
|
||||||
|
},
|
||||||
|
progressKey(resource) {
|
||||||
|
const target = resource || this.selectedResource
|
||||||
|
return "learning-progress-" + this.courseId + "-" + (target.id || "")
|
||||||
|
},
|
||||||
|
getResumePosition(resource) {
|
||||||
|
// 仅以当前登录人的服务端学习记录续播,避免共用设备中的本地缓存导致首次学习被错误快进。
|
||||||
|
const serverPosition = Number(resource.lastPositionSeconds || 0)
|
||||||
|
return Math.max(0, serverPosition)
|
||||||
|
},
|
||||||
|
async openResource(resource) {
|
||||||
|
if (!resource || !resource.id || this.switchingResource || this.startingStudy) return
|
||||||
|
this.switchingResource = true
|
||||||
|
try {
|
||||||
|
this.pauseMedia()
|
||||||
|
await this.finishStudy(false)
|
||||||
|
resource = this.allResources.find(item => item.id === resource.id) || resource
|
||||||
|
this.selectedResource = resource
|
||||||
|
this.resumePositionSeconds = this.getResumePosition(resource)
|
||||||
|
this.resumeSeekRetryCount = 0
|
||||||
|
this.resumePositionRestored = this.resumePositionSeconds <= 0
|
||||||
|
this.lastSavedPositionSeconds = 0
|
||||||
|
this.lastPositionSyncAt = 0
|
||||||
|
this.lastAllowedPositionSeconds = Number(resource.maxPositionSeconds || 0)
|
||||||
|
this.restoringMediaPosition = false
|
||||||
|
this.playerVisible = true
|
||||||
|
if (!this.canPreview) return
|
||||||
|
if (!["video", "audio"].includes(resource.resourceType)) {
|
||||||
|
this.$nextTick(() => this.startStudy())
|
||||||
|
} else {
|
||||||
|
this.$nextTick(() => this.restoreMediaPosition())
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.switchingResource = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
restoreMediaPosition() {
|
||||||
|
const player = this.$refs.mediaPlayer
|
||||||
|
if (!player || !this.selectedResource.id || this.resumePositionRestored || this.resumePositionSeconds <= 0 || player.readyState < 1) return
|
||||||
|
let targetPosition = this.resumePositionSeconds
|
||||||
|
if (Number.isFinite(player.duration) && player.duration > 0) {
|
||||||
|
// 已完成或接近结束的记录回退两秒,防止恢复后立即触发播放结束。
|
||||||
|
targetPosition = Math.min(this.resumePositionSeconds, Math.max(0, Math.floor(player.duration) - 2))
|
||||||
|
}
|
||||||
|
if (targetPosition <= 0) {
|
||||||
|
this.lastAllowedPositionSeconds = 0
|
||||||
|
this.restoringMediaPosition = false
|
||||||
|
this.resumePositionRestored = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.resumePositionSeconds = targetPosition
|
||||||
|
this.restoringMediaPosition = true
|
||||||
|
player.currentTime = targetPosition
|
||||||
|
},
|
||||||
|
verifyMediaPosition() {
|
||||||
|
const player = this.$refs.mediaPlayer
|
||||||
|
if (!player || this.resumePositionRestored || this.resumePositionSeconds <= 0) return
|
||||||
|
const actualPosition = Number(player.currentTime || 0)
|
||||||
|
if (Math.abs(actualPosition - this.resumePositionSeconds) <= 1) {
|
||||||
|
this.lastAllowedPositionSeconds = actualPosition
|
||||||
|
this.restoringMediaPosition = false
|
||||||
|
this.resumePositionRestored = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.resumeSeekRetryCount < 1) {
|
||||||
|
this.resumeSeekRetryCount += 1
|
||||||
|
// 部分移动端容器首次定位会被播放器重置,收到 seeked 后再校验并重试一次。
|
||||||
|
setTimeout(() => this.restoreMediaPosition(), 100)
|
||||||
|
} else {
|
||||||
|
this.lastAllowedPositionSeconds = actualPosition
|
||||||
|
this.restoringMediaPosition = false
|
||||||
|
this.resumePositionRestored = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 原生播放器点击进度条会在 seeking 后再次写入时间,因此开始定位和完成定位均需限制向前跳转;回退复习不受影响。
|
||||||
|
restoreBlockedMediaPosition() {
|
||||||
|
const player = this.$refs.mediaPlayer
|
||||||
|
if (!player || this.selectedResource.allowDrag !== false || this.restoringMediaPosition) return false
|
||||||
|
const allowedPosition = Math.max(0, Number(this.lastAllowedPositionSeconds || 0))
|
||||||
|
if (Number(player.currentTime || 0) <= allowedPosition + 0.25) return false
|
||||||
|
player.currentTime = allowedPosition
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this.restoringMediaPosition && this.selectedResource.allowDrag === false && Number(player.currentTime || 0) > allowedPosition + 0.25) {
|
||||||
|
player.currentTime = allowedPosition
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
handleMediaSeeking() {
|
||||||
|
this.restoreBlockedMediaPosition()
|
||||||
|
},
|
||||||
|
handleMediaSeeked() {
|
||||||
|
this.restoreBlockedMediaPosition()
|
||||||
|
this.verifyMediaPosition()
|
||||||
|
},
|
||||||
|
async startStudy() {
|
||||||
|
if (!this.canPreview || this.studying || this.startingStudy) return
|
||||||
|
this.startingStudy = true
|
||||||
|
const resourceId = this.selectedResource.id
|
||||||
|
try {
|
||||||
|
const resp = await this.$axios.post(this.recordApi + "/start", {
|
||||||
|
courseId: this.courseId,
|
||||||
|
resourceId: resourceId,
|
||||||
|
positionSeconds: this.resumePositionRestored ? this.getMediaPosition() : this.resumePositionSeconds
|
||||||
|
})
|
||||||
|
if (resp.code !== 0) {
|
||||||
|
this.$toast(resp.msg || "开始学习失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.playerVisible || this.selectedResource.id !== resourceId) {
|
||||||
|
await this.$axios.post(this.recordApi + "/finish", { segmentId: resp.data.segmentId, activeSeconds: 0 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.currentSegmentId = resp.data.segmentId
|
||||||
|
this.studying = true
|
||||||
|
this.pendingSeconds = 0
|
||||||
|
this.applyRecordState(resp.data)
|
||||||
|
this.startHeartbeatTimer()
|
||||||
|
} finally {
|
||||||
|
this.startingStudy = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async heartbeat() {
|
||||||
|
if (!this.currentSegmentId || this.pendingSeconds <= 0) return
|
||||||
|
const seconds = this.pendingSeconds
|
||||||
|
this.pendingSeconds = 0
|
||||||
|
const resp = await this.$axios.post(this.recordApi + "/heartbeat", {
|
||||||
|
segmentId: this.currentSegmentId,
|
||||||
|
activeSeconds: seconds,
|
||||||
|
positionSeconds: this.getMediaPosition()
|
||||||
|
})
|
||||||
|
if (resp.code === 0) {
|
||||||
|
this.applyRecordState(resp.data)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// force 用于页面销毁时静默结束;completed 仅在视频或音频自然播放结束时传 true。
|
||||||
|
async finishStudy(force, completed) {
|
||||||
|
this.pauseMedia()
|
||||||
|
if (!this.currentSegmentId) return
|
||||||
|
const segmentId = this.currentSegmentId
|
||||||
|
const seconds = this.pendingSeconds
|
||||||
|
this.pendingSeconds = 0
|
||||||
|
this.stopHeartbeatTimer()
|
||||||
|
this.studying = false
|
||||||
|
this.currentSegmentId = ""
|
||||||
|
const resp = await this.$axios.post(this.recordApi + "/finish", {
|
||||||
|
segmentId: segmentId,
|
||||||
|
activeSeconds: seconds,
|
||||||
|
positionSeconds: this.getMediaPosition(),
|
||||||
|
completed: !!completed
|
||||||
|
})
|
||||||
|
if (resp.code === 0) this.applyRecordState(resp.data)
|
||||||
|
if (force) return
|
||||||
|
await this.loadTree()
|
||||||
|
},
|
||||||
|
startHeartbeatTimer() {
|
||||||
|
this.stopHeartbeatTimer()
|
||||||
|
this.heartbeatTimer = setInterval(() => {
|
||||||
|
if (!this.studying || document.hidden) return
|
||||||
|
if (["video", "audio"].includes(this.selectedResource.resourceType)) {
|
||||||
|
const player = this.$refs.mediaPlayer
|
||||||
|
if (!player || player.paused || player.ended) return
|
||||||
|
}
|
||||||
|
this.pendingSeconds += 1
|
||||||
|
if (this.pendingSeconds >= 15) {
|
||||||
|
this.heartbeat()
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
},
|
||||||
|
stopHeartbeatTimer() {
|
||||||
|
if (this.heartbeatTimer) {
|
||||||
|
clearInterval(this.heartbeatTimer)
|
||||||
|
this.heartbeatTimer = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
saveProgress() {
|
||||||
|
const position = this.getMediaPosition()
|
||||||
|
if (!position || !this.selectedResource.id) return
|
||||||
|
if (this.selectedResource.allowDrag === false && !this.restoringMediaPosition && position > Number(this.lastAllowedPositionSeconds || 0) + 2) {
|
||||||
|
this.restoreBlockedMediaPosition()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.lastAllowedPositionSeconds = Math.max(Number(this.lastAllowedPositionSeconds || 0), position)
|
||||||
|
this.$set(this.selectedResource, "lastPositionSeconds", position)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(this.progressKey(), String(position))
|
||||||
|
} catch (e) {
|
||||||
|
// 无痕模式或受限容器可能禁用本地存储,继续使用服务端记录。
|
||||||
|
}
|
||||||
|
if (Date.now() - this.lastPositionSyncAt >= 5000) {
|
||||||
|
this.saveServerPosition(position, false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
saveMediaPosition(useBeacon) {
|
||||||
|
const position = this.getMediaPosition()
|
||||||
|
if (!this.selectedResource.id || position <= 0) return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(this.progressKey(), String(position))
|
||||||
|
} catch (e) {
|
||||||
|
// 无痕模式或受限容器可能禁用本地存储,继续使用服务端记录。
|
||||||
|
}
|
||||||
|
this.saveServerPosition(position, useBeacon)
|
||||||
|
},
|
||||||
|
saveServerPosition(position, useBeacon) {
|
||||||
|
if (!this.selectedResource.id || !["video", "audio"].includes(this.selectedResource.resourceType)) return
|
||||||
|
const seconds = Math.max(0, Math.floor(Number(position || 0)))
|
||||||
|
this.lastPositionSyncAt = Date.now()
|
||||||
|
this.lastSavedPositionSeconds = seconds
|
||||||
|
if (useBeacon && navigator.sendBeacon) {
|
||||||
|
const formData = new URLSearchParams()
|
||||||
|
formData.append("courseId", this.courseId)
|
||||||
|
formData.append("resourceId", this.selectedResource.id)
|
||||||
|
formData.append("positionSeconds", String(seconds))
|
||||||
|
const body = new Blob([formData.toString()], { type: "application/x-www-form-urlencoded;charset=UTF-8" })
|
||||||
|
navigator.sendBeacon(this.recordApi + "/position", body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 播放中每五秒、暂停时立即同步,确保服务端续播位置不会只依赖学习心跳。
|
||||||
|
this.$axios.post(this.recordApi + "/position", {
|
||||||
|
courseId: this.courseId,
|
||||||
|
resourceId: this.selectedResource.id,
|
||||||
|
positionSeconds: seconds
|
||||||
|
}).then((resp) => {
|
||||||
|
if (resp.code !== 0) {
|
||||||
|
this.lastSavedPositionSeconds = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
applyRecordState(data) {
|
||||||
|
if (data && data.courseProgress) Object.keys(data.courseProgress).forEach(key => this.$set(this.course, key, data.courseProgress[key]))
|
||||||
|
if (!data || !data.resourceId) return
|
||||||
|
const resources = this.allResources.filter(item => item.id === data.resourceId)
|
||||||
|
if (this.selectedResource.id === data.resourceId && !resources.includes(this.selectedResource)) resources.push(this.selectedResource)
|
||||||
|
resources.forEach(resource => {
|
||||||
|
this.$set(resource, "studied", data.studied !== false)
|
||||||
|
this.$set(resource, "progressPercent", Number(data.progressPercent || 0))
|
||||||
|
this.$set(resource, "studySeconds", Number(data.studySeconds || 0))
|
||||||
|
this.$set(resource, "completeStatus", data.completeStatus || "studying")
|
||||||
|
if (data.lastPositionSeconds !== undefined) this.$set(resource, "lastPositionSeconds", Number(data.lastPositionSeconds || 0))
|
||||||
|
if (data.maxPositionSeconds !== undefined) this.$set(resource, "maxPositionSeconds", Number(data.maxPositionSeconds || 0))
|
||||||
|
})
|
||||||
|
if (this.selectedResource.id === data.resourceId && data.maxPositionSeconds !== undefined) {
|
||||||
|
this.lastAllowedPositionSeconds = Math.max(Number(this.lastAllowedPositionSeconds || 0), Number(data.maxPositionSeconds || 0))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getMediaPosition() {
|
||||||
|
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return 0
|
||||||
|
const player = this.$refs.mediaPlayer
|
||||||
|
return player ? Math.floor(player.currentTime || 0) : Number(this.selectedResource.lastPositionSeconds || 0)
|
||||||
|
},
|
||||||
|
statusText(resource) {
|
||||||
|
if (!resource || !resource.studied) return "未学习"
|
||||||
|
if (resource.completeStatus === "completed") return "已完成"
|
||||||
|
return "学习中 " + Number(resource.progressPercent || 0) + "%"
|
||||||
|
},
|
||||||
|
resourceIcon(resource) {
|
||||||
|
if (resource.resourceType === "pdf" || (resource.fileExt || "").toLowerCase() === "pdf") return "/assets/platform/img/svg/pdf.svg"
|
||||||
|
if (resource.resourceType === "word" || ["doc", "docx"].includes((resource.fileExt || "").toLowerCase())) return "/assets/platform/img/svg/docx.svg"
|
||||||
|
if (resource.resourceType === "video") return "video"
|
||||||
|
if (resource.resourceType === "audio") return "music-o"
|
||||||
|
if (resource.resourceType === "image") return "photo-o"
|
||||||
|
return "description"
|
||||||
|
},
|
||||||
|
resourceIconClass(resource) {
|
||||||
|
if (resource.resourceType === "pdf" || (resource.fileExt || "").toLowerCase() === "pdf") return "pdf"
|
||||||
|
if (resource.resourceType === "word" || ["doc", "docx"].includes((resource.fileExt || "").toLowerCase())) return "word"
|
||||||
|
if (resource.resourceType === "video") return "video"
|
||||||
|
if (resource.resourceType === "audio") return "audio"
|
||||||
|
if (resource.resourceType === "image") return "image"
|
||||||
|
return "doc"
|
||||||
|
},
|
||||||
|
getFileEntry(value) {
|
||||||
|
if (!value) return null
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const text = value.trim()
|
||||||
|
if (!text) return null
|
||||||
|
if (["[", "{", '"'].includes(text.charAt(0))) {
|
||||||
|
try { return this.getFileEntry(JSON.parse(text)) } catch (e) { return null }
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(item => this.getFileEntry(item)).find(item => this.getFileUrl(item)) || null
|
||||||
|
}
|
||||||
|
return typeof value === "object" ? value : null
|
||||||
|
},
|
||||||
|
getFileUrl(value) {
|
||||||
|
const file = this.getFileEntry(value)
|
||||||
|
if (!file) return ""
|
||||||
|
if (typeof file === "string") {
|
||||||
|
return /^[a-zA-Z0-9_-]+$/.test(file) ? "/platform/sys/file/download?id=" + encodeURIComponent(file) : file
|
||||||
|
}
|
||||||
|
return this.getFileUrl(file.url)
|
||||||
|
|| this.getFileUrl(file.downloadPath)
|
||||||
|
|| this.getFileUrl(file.response && file.response.data)
|
||||||
|
|| this.getFileUrl(file.data)
|
||||||
|
|| this.getFileUrl(file.id || file.fileId)
|
||||||
|
},
|
||||||
|
getFileId(value) {
|
||||||
|
const url = this.getFileUrl(value)
|
||||||
|
if (!url) return ""
|
||||||
|
const matched = url.match(/[?&]id=([^&]+)/)
|
||||||
|
if (matched) return decodeURIComponent(matched[1])
|
||||||
|
if (!url.includes("/") && !url.includes(".")) return url
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
canInlinePreview(resource) {
|
||||||
|
const type = resource.resourceType || ""
|
||||||
|
const ext = (resource.fileExt || "").toLowerCase()
|
||||||
|
return ["pdf", "ppt", "word"].includes(type) || ["pdf", "ppt", "pptx", "doc", "docx"].includes(ext)
|
||||||
|
},
|
||||||
|
openFile() {
|
||||||
|
if (!this.canPreview) return
|
||||||
|
const url = this.canInlinePreview(this.selectedResource) ? this.inlinePreviewUrl : this.mediaUrl
|
||||||
|
if (url) window.open(url)
|
||||||
|
},
|
||||||
|
downloadFile() {
|
||||||
|
if (this.canDownload && this.downloadUrl) window.open(this.downloadUrl)
|
||||||
|
},
|
||||||
|
pauseMedia() {
|
||||||
|
const player = this.$refs.mediaPlayer
|
||||||
|
if (player && player.pause) {
|
||||||
|
player.pause()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enterVideoFullscreen() {
|
||||||
|
const player = this.$refs.mediaPlayer
|
||||||
|
if (!player) return
|
||||||
|
const requestFullscreen = player.requestFullscreen || player.webkitRequestFullscreen || player.mozRequestFullScreen || player.msRequestFullscreen
|
||||||
|
if (requestFullscreen) {
|
||||||
|
requestFullscreen.call(player)
|
||||||
|
} else if (player.webkitEnterFullscreen) {
|
||||||
|
player.webkitEnterFullscreen()
|
||||||
|
}
|
||||||
|
if (screen.orientation && screen.orientation.lock) {
|
||||||
|
screen.orientation.lock("landscape").catch(() => {})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
closePlayer() {
|
||||||
|
this.pauseMedia()
|
||||||
|
this.finishStudy(false)
|
||||||
|
this.playerVisible = false
|
||||||
|
},
|
||||||
|
async goBack() {
|
||||||
|
if (this.navigatingBack) return
|
||||||
|
this.navigatingBack = true
|
||||||
|
try {
|
||||||
|
await this.finishStudy(true)
|
||||||
|
} finally {
|
||||||
|
this.navigateBack()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigateBack() {
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
window.history.back()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$.pjax({
|
||||||
|
url: "/platform/learning/course/h5",
|
||||||
|
container: "#container",
|
||||||
|
maxCacheLength: 0,
|
||||||
|
push: false,
|
||||||
|
replace: true,
|
||||||
|
fragment: "#container",
|
||||||
|
timeout: 8000
|
||||||
|
})
|
||||||
|
},
|
||||||
|
handlePageVisibility(event) {
|
||||||
|
if (document.hidden || (event && event.type === "pagehide")) {
|
||||||
|
this.saveMediaPosition(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
this.courseId = this.getQuery("id")
|
||||||
|
if (!this.courseId) {
|
||||||
|
this.$toast("请选择课程")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await this.loadCourse()
|
||||||
|
await this.loadTree()
|
||||||
|
this.pageVisibilityHandler = (event) => this.handlePageVisibility(event)
|
||||||
|
document.addEventListener("visibilitychange", this.pageVisibilityHandler)
|
||||||
|
window.addEventListener("pagehide", this.pageVisibilityHandler)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.saveMediaPosition(true)
|
||||||
|
this.finishStudy(true)
|
||||||
|
this.stopHeartbeatTimer()
|
||||||
|
if (this.pageVisibilityHandler) {
|
||||||
|
document.removeEventListener("visibilitychange", this.pageVisibilityHandler)
|
||||||
|
window.removeEventListener("pagehide", this.pageVisibilityHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--#
|
||||||
|
}
|
||||||
|
#-->
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
<!--#
|
||||||
|
layout("/layouts/platform_h5.html"){
|
||||||
|
#-->
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#app.my-study-page { min-height: 100%; height: auto; box-sizing: border-box; padding-bottom: 24px; padding-bottom: calc(24px + env(safe-area-inset-bottom, 0px)); background: #f7fbff linear-gradient(180deg, #cfe8ff 0, #e5f3ff 160px, #f1f8ff 320px, #f7fbff 480px) no-repeat; color: #26364c; }
|
||||||
|
.my-study-hero { padding: 24px 16px 18px; background: transparent; }
|
||||||
|
.my-study-head { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.my-study-back { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 36px; width: 36px; height: 36px; padding: 0; border: 0; border-radius: 50%; background: #fff; color: #1989fa; font-size: 20px; }
|
||||||
|
.my-study-heading { margin: 0 0 0 auto; text-align: right; font-size: 22px; line-height: 1.5; font-weight: 700; }
|
||||||
|
.my-study-subtitle { margin: 4px 0 18px; text-align: right; color: #8192a9; font-size: 12px; }
|
||||||
|
.my-study-overview { padding: 18px 16px 14px; border-radius: 16px; background: #fff; box-shadow: 0 6px 20px rgba(45, 111, 188, .04); }
|
||||||
|
.my-study-time-heading { position: relative; min-height: 80px; padding-right: 100px; }
|
||||||
|
.my-study-book { position: absolute; top: 0; right: 0; width: 92px; height: 80px; background-color: #c9e5ff; -webkit-mask: url("${base!}/assets/platform/img/svg/ant-design_read-filled.svg") center / contain no-repeat; mask: url("${base!}/assets/platform/img/svg/ant-design_read-filled.svg") center / contain no-repeat; transform: rotate(-15deg); pointer-events: none; }
|
||||||
|
.my-study-time-heading .my-study-time { flex-wrap: wrap; }
|
||||||
|
.my-study-time-label { display: flex; align-items: center; gap: 6px; color: #7a8ca6; font-size: 12px; }
|
||||||
|
.my-study-time-label .van-icon { color: #1989fa; font-size: 16px; }
|
||||||
|
.my-study-time { display: flex; align-items: baseline; gap: 6px; margin-top: 8px; color: #8192a9; font-size: 12px; }
|
||||||
|
.my-study-time strong { color: #1989fa; font-size: 30px; font-weight: 700; line-height: 1.3; font-variant-numeric: tabular-nums; }
|
||||||
|
.my-study-time strong:not(:first-child) { margin-left: 8px; }
|
||||||
|
.my-study-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); padding-top: 16px; margin-top: 16px; border-top: 1px solid #edf2f8; text-align: center; }
|
||||||
|
.my-study-stat + .my-study-stat { border-left: 1px solid #edf2f8; }
|
||||||
|
.my-study-stat-value { font-size: 20px; line-height: 1.3; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
|
.my-study-stat-label { margin-top: 4px; color: #8192a9; font-size: 11px; }
|
||||||
|
.my-study-main { padding: 0 16px; }
|
||||||
|
.my-study-list-header { position: sticky; top: 0; z-index: 10; margin: 0 -16px; padding: 12px 16px 14px; background: #f7fbff; }
|
||||||
|
.my-study-list-header .my-study-tabs { margin-bottom: 0; }
|
||||||
|
.my-study-section-head { display: flex; align-items: baseline; justify-content: space-between; margin: 2px 0 12px; }
|
||||||
|
.my-study-section-title { margin: 0; font-size: 17px; font-weight: 700; }
|
||||||
|
.my-study-section-count { color: #8b9ab0; font-size: 12px; }
|
||||||
|
.my-study-tabs { display: flex; gap: 8px; margin-bottom: 14px; }
|
||||||
|
.my-study-tab { flex: 1; padding: 8px 4px; border: 0; border-radius: 9px; background: #eaf1fa; color: #7a8ca6; font-size: 12px; line-height: 1.5; }
|
||||||
|
.my-study-tab.active { background: #1989fa; color: #fff; font-weight: 600; }
|
||||||
|
.my-study-list-card { margin-bottom: 12px; padding: 12px; border-radius: 14px; background: #fff; }
|
||||||
|
.my-study-list-card:last-child { margin-bottom: 0; }
|
||||||
|
.my-study-course-top { display: flex; align-items: flex-start; gap: 12px; width: 100%; padding: 0; border: 0; background: transparent; text-align: left; color: inherit; }
|
||||||
|
.my-study-list-cover { position: relative; flex: 0 0 112px; width: 112px; height: 84px; aspect-ratio: 4 / 3; border-radius: 8px; overflow: hidden; background: #edf5ff; }
|
||||||
|
.my-study-list-cover img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.my-study-cover-empty { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; color: #98bde9; font-size: 30px; }
|
||||||
|
.my-study-list-info { min-width: 0; flex: 1; }
|
||||||
|
.my-study-list-title { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; color: #26364c; font-size: 14px; font-weight: 600; line-height: 1.5; }
|
||||||
|
.my-study-status { display: inline-flex; align-items: center; gap: 3px; margin-top: 6px; padding: 2px 6px; border-radius: 5px; color: #1989fa; background: #edf6ff; font-size: 10px; }
|
||||||
|
.my-study-status.completed { color: #28b583; background: #eafaf3; }
|
||||||
|
.my-study-progress { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
|
||||||
|
.my-study-progress .van-progress { flex: 1; }
|
||||||
|
.my-study-progress-value { flex-shrink: 0; color: #1989fa; font-size: 12px; font-weight: 600; }
|
||||||
|
.my-study-course-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 10px; }
|
||||||
|
.my-study-list-meta { min-width: 0; color: #8b9ab0; font-size: 11px; line-height: 1.7; }
|
||||||
|
.my-study-list-meta span { display: block; }
|
||||||
|
.my-study-continue { display: inline-flex; align-items: center; gap: 4px; flex-shrink: 0; padding: 7px 12px; border: 0; border-radius: 16px; background: #edf6ff; color: #1989fa; font-size: 12px; line-height: 1.5; }
|
||||||
|
.my-study-loading { padding: 40px 0; text-align: center; }
|
||||||
|
.my-study-page button:focus-visible { outline: 2px solid #1989fa; outline-offset: 2px; }
|
||||||
|
@media (max-width: 359px) { .my-study-main { padding: 0 12px; } .my-study-list-header { margin-right: -12px; margin-left: -12px; padding-right: 12px; padding-left: 12px; } .my-study-list-cover { flex-basis: 96px; width: 96px; height: 72px; } .my-study-course-top { gap: 9px; } }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="app" class="my-study-page" v-cloak>
|
||||||
|
<div class="my-study-hero">
|
||||||
|
<div class="my-study-head">
|
||||||
|
<button type="button" class="my-study-back" aria-label="返回" @click="goBack"><van-icon name="arrow-left"></van-icon></button>
|
||||||
|
<h1 class="my-study-heading">我的学习</h1>
|
||||||
|
</div>
|
||||||
|
<p class="my-study-subtitle">记录学习点滴,见证每一次进步</p>
|
||||||
|
<div class="my-study-overview">
|
||||||
|
<div class="my-study-time-heading">
|
||||||
|
<div class="my-study-time-label"><van-icon name="clock-o"></van-icon>累计学习时长</div>
|
||||||
|
<div class="my-study-time"><strong>{{ summary.studyHour || 0 }}</strong>小时<strong>{{ summary.studyMinute || 0 }}</strong>分钟</div>
|
||||||
|
<span class="my-study-book" aria-hidden="true"></span>
|
||||||
|
</div>
|
||||||
|
<div class="my-study-stats">
|
||||||
|
<div v-for="item in statItems" :key="item.label" class="my-study-stat">
|
||||||
|
<div class="my-study-stat-value">{{ item.value }}</div>
|
||||||
|
<div class="my-study-stat-label">{{ item.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="my-study-main">
|
||||||
|
<div class="my-study-list-header">
|
||||||
|
<div class="my-study-section-head"><h2 class="my-study-section-title">我学习的课程</h2><span class="my-study-section-count">共 {{ courses.length }} 门</span></div>
|
||||||
|
<nav class="my-study-tabs" aria-label="学习状态">
|
||||||
|
<button v-for="tab in statusTabs" :key="tab.value" type="button" class="my-study-tab" :class="{ active: activeStatus === tab.value }" :aria-pressed="activeStatus === tab.value" @click="activeStatus = tab.value">{{ tab.label }}</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="my-study-loading"><van-loading size="22px" color="#1989fa">加载中…</van-loading></div>
|
||||||
|
<van-empty v-else-if="loadError" description="学习记录加载失败"><van-button round size="small" type="info" @click="loadData">重新加载</van-button></van-empty>
|
||||||
|
<div v-else class="my-study-list">
|
||||||
|
<article v-for="course in visibleCourses" :key="course.courseId" class="my-study-list-card">
|
||||||
|
<button type="button" class="my-study-course-top" @click="openCourse(course)">
|
||||||
|
<div class="my-study-list-cover">
|
||||||
|
<img v-if="getCoverUrl(course.cover)" :src="getCoverUrl(course.cover)" :alt="course.courseName">
|
||||||
|
<div v-else class="my-study-cover-empty"><van-icon name="description"></van-icon></div>
|
||||||
|
</div>
|
||||||
|
<div class="my-study-list-info">
|
||||||
|
<div class="my-study-list-title">{{ course.courseName || '未命名课程' }}</div>
|
||||||
|
<span class="my-study-status" :class="{ completed: isCompleted(course) }"><van-icon :name="isCompleted(course) ? 'checked' : 'clock-o'"></van-icon>{{ isCompleted(course) ? '已完成' : '学习中' }}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div v-if="Number(course.outlineCount || 0) > 0" class="my-study-progress"><van-progress :percentage="courseProgress(course)" :show-pivot="false" stroke-width="5" :color="isCompleted(course) ? '#2fc78c' : '#1989fa'"></van-progress><span class="my-study-progress-value">{{ courseProgress(course) }}%</span></div>
|
||||||
|
<div class="my-study-course-foot">
|
||||||
|
<div class="my-study-list-meta"><span>已完成 {{ course.completedOutlineCount || 0 }} 个章节 · 已学 {{ course.studyTimeText || '0秒' }}</span><span v-if="studyDate(course.latestStudyTime)">最近学习 {{ studyDate(course.latestStudyTime) }}</span></div>
|
||||||
|
<button type="button" class="my-study-continue" @click="openCourse(course)"><van-icon name="play"></van-icon>{{ isCompleted(course) ? '再次学习' : '继续学习' }}</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<van-empty v-if="!visibleCourses.length" :description="activeStatus === 'completed' ? '暂无已完成课程' : activeStatus === 'studying' ? '暂无学习中的课程' : '暂无学习课程'"></van-empty>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script nonce="${cspNonce!}">
|
||||||
|
new Vue({
|
||||||
|
el: "#app",
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
apiBase: "/platform/learning/study/record",
|
||||||
|
summary: {},
|
||||||
|
courses: [],
|
||||||
|
loading: false,
|
||||||
|
loadError: false,
|
||||||
|
activeStatus: "all",
|
||||||
|
statusTabs: [
|
||||||
|
{ value: "all", label: "全部" },
|
||||||
|
{ value: "studying", label: "学习中" },
|
||||||
|
{ value: "completed", label: "已完成" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
completedCount() {
|
||||||
|
return this.courses.filter(course => this.isCompleted(course)).length
|
||||||
|
},
|
||||||
|
statItems() {
|
||||||
|
return [
|
||||||
|
{ label: "已学课程", value: this.courses.length },
|
||||||
|
{ label: "学习中", value: this.courses.length - this.completedCount },
|
||||||
|
{ label: "已完成", value: this.completedCount }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
visibleCourses() {
|
||||||
|
if (this.activeStatus === "completed") return this.courses.filter(course => this.isCompleted(course))
|
||||||
|
if (this.activeStatus === "studying") return this.courses.filter(course => !this.isCompleted(course))
|
||||||
|
return this.courses
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
goBack() {
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
window.history.back()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pjaxReplace("/platform/learning/course/h5")
|
||||||
|
},
|
||||||
|
async loadData() {
|
||||||
|
this.loading = true
|
||||||
|
this.loadError = false
|
||||||
|
try {
|
||||||
|
const [summaryResp, courseResp] = await Promise.all([
|
||||||
|
this.$axios.post(this.apiBase + "/h5Summary"),
|
||||||
|
this.$axios.post(this.apiBase + "/h5Courses")
|
||||||
|
])
|
||||||
|
if (summaryResp.code !== 0 || courseResp.code !== 0) {
|
||||||
|
this.loadError = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.summary = summaryResp.data || {}
|
||||||
|
this.courses = courseResp.data || []
|
||||||
|
} catch (e) {
|
||||||
|
this.loadError = true
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
courseProgress(course) {
|
||||||
|
const value = Number(course.progressPercent || 0)
|
||||||
|
return Number.isFinite(value) ? Math.max(0, Math.min(100, Math.round(value))) : 0
|
||||||
|
},
|
||||||
|
isCompleted(course) {
|
||||||
|
return this.courseProgress(course) === 100 && Number(course.outlineCount || 0) > 0
|
||||||
|
&& Number(course.completedOutlineCount || 0) >= Number(course.outlineCount)
|
||||||
|
},
|
||||||
|
studyDate(value) {
|
||||||
|
if (!value) return ""
|
||||||
|
const date = new Date(/^\d+$/.test(String(value)) ? Number(value) : String(value).replace(/-/g, "/"))
|
||||||
|
if (Number.isNaN(date.getTime())) return ""
|
||||||
|
return date.getFullYear() + "-" + String(date.getMonth() + 1).padStart(2, "0") + "-" + String(date.getDate()).padStart(2, "0")
|
||||||
|
},
|
||||||
|
getCoverUrl(cover) {
|
||||||
|
if (!cover) return ""
|
||||||
|
if (Array.isArray(cover)) {
|
||||||
|
return cover.length ? (cover[0].url || (cover[0].response && cover[0].response.data) || cover[0].data || "") : ""
|
||||||
|
}
|
||||||
|
if (typeof cover === "string") {
|
||||||
|
const text = cover.trim()
|
||||||
|
if (!text) return ""
|
||||||
|
if (text.startsWith("[")) {
|
||||||
|
try {
|
||||||
|
const files = JSON.parse(text)
|
||||||
|
return files.length ? (files[0].url || (files[0].response && files[0].response.data) || files[0].data || "") : ""
|
||||||
|
} catch (e) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
openCourse(course) {
|
||||||
|
if (!course || !course.courseId) return
|
||||||
|
pjaxReplace("/platform/learning/course/h5/study?id=" + encodeURIComponent(course.courseId))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.loadData()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--#
|
||||||
|
}
|
||||||
|
#-->
|
||||||
@@ -2,21 +2,21 @@ const home = {
|
|||||||
template: /*language=HTML*/ `
|
template: /*language=HTML*/ `
|
||||||
<div>
|
<div>
|
||||||
<van-nav-bar title="首页" class="nav-bar" placeholder fixed></van-nav-bar>
|
<van-nav-bar title="首页" class="nav-bar" placeholder fixed></van-nav-bar>
|
||||||
<van-swipe :autoplay="100000" :height="200" class="home__swipe">
|
<!-- <van-swipe :autoplay="100000" :height="200" class="home__swipe">-->
|
||||||
<van-swipe-item>
|
<!-- <van-swipe-item>-->
|
||||||
<img src="/assets/mobile/img/home/home1.png" alt="">
|
<!-- <img src="/assets/mobile/img/home/home1.png" alt="">-->
|
||||||
</van-swipe-item>
|
<!-- </van-swipe-item>-->
|
||||||
<van-swipe-item>
|
<!-- <van-swipe-item>-->
|
||||||
<img src="/assets/mobile/img/home/home2.png" alt="">
|
<!-- <img src="/assets/mobile/img/home/home2.png" alt="">-->
|
||||||
</van-swipe-item>
|
<!-- </van-swipe-item>-->
|
||||||
<van-swipe-item>
|
<!-- <van-swipe-item>-->
|
||||||
<img src="/assets/mobile/img/home/home3.png" alt="">
|
<!-- <img src="/assets/mobile/img/home/home3.png" alt="">-->
|
||||||
</van-swipe-item>
|
<!-- </van-swipe-item>-->
|
||||||
</van-swipe>
|
<!-- </van-swipe>-->
|
||||||
|
|
||||||
<!--快捷入口-->
|
<!--快捷入口-->
|
||||||
<div class="quick-grid">
|
<div class="quick-grid">
|
||||||
<div class="section-title">快速入口</div>
|
<!-- <div class="section-title">快速入口</div>-->
|
||||||
<van-grid :column-num="4" icon-size="46" square clickable :border="false">
|
<van-grid :column-num="4" icon-size="46" square clickable :border="false">
|
||||||
<van-grid-item v-for="item in quickEntries"
|
<van-grid-item v-for="item in quickEntries"
|
||||||
@click="menuClick(item)"
|
@click="menuClick(item)"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const work = {
|
|||||||
</div>
|
</div>
|
||||||
<div class="module-content">
|
<div class="module-content">
|
||||||
<van-grid :column-num="3">
|
<van-grid :column-num="3">
|
||||||
<van-grid-item icon="photo-o" :text="menu.name" v-for="menu in item.menus" :key="menu.id"
|
<van-grid-item icon="photo-o" :text="menu.name" v-for="menu in getDisplayMenus(item.menus)" :key="menu.id"
|
||||||
@click="menuClick(menu)">
|
@click="menuClick(menu)">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<img :src="menu.icon" alt="" style="width: 40px;height: 40px">
|
<img :src="menu.icon" alt="" style="width: 40px;height: 40px">
|
||||||
@@ -24,6 +24,14 @@ const work = {
|
|||||||
`,
|
`,
|
||||||
store,
|
store,
|
||||||
methods: {
|
methods: {
|
||||||
|
getDisplayMenus(menus) {
|
||||||
|
return (menus || []).flatMap(menu => {
|
||||||
|
if (menu.permission === "h5.learning" && menu.children && menu.children.length > 0) {
|
||||||
|
return menu.children
|
||||||
|
}
|
||||||
|
return [menu]
|
||||||
|
})
|
||||||
|
},
|
||||||
menuClick(item) {
|
menuClick(item) {
|
||||||
this.$pjaxReplace(item.href)
|
this.$pjaxReplace(item.href)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,603 @@
|
|||||||
|
<!--#
|
||||||
|
layout("/layouts/platform_h5.html"){
|
||||||
|
#-->
|
||||||
|
<style>
|
||||||
|
/* 页面限定在可用视口内,顶部信息不收缩,剩余高度交给人员列表。 */
|
||||||
|
#app.selection-situation-page { height: 100%; height: 100dvh; min-height: 0; flex-direction: column; overflow: hidden; box-sizing: border-box; background: #f4f6fa; color: #1c293a; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
|
||||||
|
#app.selection-situation-page:not([v-cloak]) { display: flex; }
|
||||||
|
.selection-situation-page > .van-nav-bar,
|
||||||
|
.selection-situation-page > .selection-filters,
|
||||||
|
.selection-situation-page > .selection-result-count { flex-shrink: 0; }
|
||||||
|
/* van-list 会监听此滚动容器;到达边界时不把滚动继续传给外层页面。 */
|
||||||
|
.selection-person-scroll { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior-y: contain; -webkit-overflow-scrolling: touch; padding: 16px 0 calc(20px + env(safe-area-inset-bottom)); }
|
||||||
|
/* 导航栏沿用福利列表的 Vant 默认样式:白底、深色标题、蓝色返回文字和箭头。 */
|
||||||
|
/* 项目独占一行以容纳长名称;两个布尔筛选等宽排列,并分别标明业务含义。 */
|
||||||
|
.selection-filters { padding: 12px 16px; background: #fff; }
|
||||||
|
.selection-filter-row { display: flex; align-items: center; gap: 10px; margin-top: 10px; }
|
||||||
|
.selection-filter-value { display: flex; align-items: center; justify-content: space-between; flex: 1 1 0; min-width: 0; min-height: 48px; padding: 8px 12px; border: 1px solid #e2e7ef; border-radius: 8px; background: #f8fafc; color: #1c293a; font-family: inherit; font-size: 14px; text-align: left; cursor: pointer; }
|
||||||
|
.selection-filter-project { background: #f0f7ff; border-color: #d0e5fc; }
|
||||||
|
.selection-filter-value.is-active { background: #f0f7ff; border-color: #a9d1f2; }
|
||||||
|
.selection-filter-copy { display: flex; flex-direction: column; flex: 1; min-width: 0; gap: 3px; }
|
||||||
|
.selection-filter-label { color: #667085; font-size: 12px; line-height: 16px; }
|
||||||
|
.selection-filter-selected { font-weight: 500; line-height: 20px; }
|
||||||
|
.selection-filter-project .selection-filter-selected,
|
||||||
|
.selection-filter-value.is-active .selection-filter-selected { color: #006dbb; }
|
||||||
|
.selection-filter-value span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
|
.selection-filter-value .van-icon { flex-shrink: 0; margin-left: 8px; color: #52627a; }
|
||||||
|
.selection-filter-value:focus-visible { outline: 2px solid #006dbb; outline-offset: 2px; }
|
||||||
|
.selection-search-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.selection-search-row .van-search { flex: 1; min-width: 0; padding: 0; background: transparent; }
|
||||||
|
.selection-search-row .van-search__content { box-sizing: border-box; height: 40px; align-items: center; border: 1px solid #e2e7ef; border-radius: 8px; background: #f4f6f9; }
|
||||||
|
.selection-search-row .van-field__control { font-size: 15px; }
|
||||||
|
.selection-search-row .van-button { flex-shrink: 0; min-width: 68px; height: 40px; padding: 0 14px; border-radius: 8px; background: #006dbb; border-color: #006dbb; font-size: 14px; font-weight: 500; }
|
||||||
|
.selection-result-count { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 44px; padding: 0 16px; border-top: 1px solid #edf0f5; background: #fff; color: #667085; font-size: 13px; }
|
||||||
|
.selection-result-title { color: #1c293a; font-size: 15px; font-weight: 600; }
|
||||||
|
.selection-result-count strong { color: #006dbb; font-size: 16px; font-weight: 600; }
|
||||||
|
/* 卡片按身份、所属组织、福利与操作分为三行;长名称自然换行,避免信息被裁切。 */
|
||||||
|
.selection-person-card { margin: 0 12px 12px; padding: 12px 14px; background: #fff; border-radius: 10px; }
|
||||||
|
.selection-person-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; min-height: 24px; }
|
||||||
|
.selection-person-identity { display: flex; align-items: baseline; flex-wrap: wrap; gap: 2px 12px; flex: 1; min-width: 0; line-height: 24px; }
|
||||||
|
.selection-person-name { color: #1c293a; font-size: 18px; font-weight: 600; overflow-wrap: anywhere; }
|
||||||
|
.selection-person-number { color: #667085; font-size: 13px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
||||||
|
.selection-person-status { display: inline-flex; align-items: center; flex-shrink: 0; gap: 6px; color: #138b42; font-size: 13px; line-height: 24px; white-space: nowrap; }
|
||||||
|
.selection-person-status .fa { font-size: 10px; }
|
||||||
|
.selection-person-status.is-unselected { color: #ad7900; }
|
||||||
|
.selection-person-organization { margin-top: 6px; color: #667085; font-size: 13px; line-height: 22px; overflow-wrap: anywhere; }
|
||||||
|
.selection-person-separator { padding: 0 5px; color: #98a2b3; }
|
||||||
|
.selection-person-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 6px; }
|
||||||
|
.selection-person-benefit { display: flex; align-items: baseline; flex: 1; min-width: 0; gap: 8px; font-size: 14px; line-height: 22px; }
|
||||||
|
.selection-person-benefit-label { flex-shrink: 0; color: #8792a3; }
|
||||||
|
.selection-person-benefit-value { min-width: 0; color: #1c293a; font-weight: 500; overflow-wrap: anywhere; }
|
||||||
|
.selection-person-benefit-value.is-empty { color: #98a2b3; font-weight: 400; }
|
||||||
|
.selection-person-footer .van-button { flex-shrink: 0; min-width: 68px; height: 34px; padding: 0 12px; border: none; border-radius: 6px; background: #e7f3ff; color: #006dbb; font-size: 14px; font-weight: 500; }
|
||||||
|
.selection-empty { padding-top: 25px; }
|
||||||
|
/* 弹框随内容撑开并限制最大高度,标题和操作按钮保留在滚动内容之外。 */
|
||||||
|
.selection-situation-page .selection-popup { border-radius: 18px 18px 0 0; }
|
||||||
|
.selection-sheet-head { display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; gap: 12px; min-height: 56px; padding: 0 12px 0 20px; border-bottom: 1px solid #edf0f5; background: #fff; color: #1c293a; font-size: 17px; font-weight: 600; }
|
||||||
|
.selection-sheet-close { display: flex; align-items: center; justify-content: center; flex: 0 0 40px; width: 40px; height: 40px; padding: 0; border: none; background: transparent; color: #8792a3; font-size: 20px; cursor: pointer; }
|
||||||
|
.selection-picker { display: flex; flex-direction: column; max-height: 75vh; max-height: 75dvh; background: #fff; }
|
||||||
|
.selection-picker-description { flex-shrink: 0; padding: 16px 20px 8px; color: #8792a3; font-size: 13px; line-height: 20px; }
|
||||||
|
.selection-picker-options { min-height: 0; overflow-y: auto; overscroll-behavior-y: contain; padding: 12px 16px; }
|
||||||
|
.selection-picker-option { display: flex; align-items: center; justify-content: space-between; gap: 14px; box-sizing: border-box; width: 100%; min-height: 52px; padding: 12px; border: none; border-bottom: 1px solid #f0f2f5; background: #fff; color: #1c293a; font-family: inherit; font-size: 14px; text-align: left; cursor: pointer; }
|
||||||
|
.selection-picker-option.is-active { border-bottom-color: transparent; border-radius: 6px; background: #eef7ff; }
|
||||||
|
.selection-picker-option-label { min-width: 0; line-height: 22px; overflow-wrap: anywhere; }
|
||||||
|
.selection-picker-option-label .fa { margin-right: 8px; color: #138b42; font-size: 10px; }
|
||||||
|
.selection-picker-option-label .fa.is-unselected { color: #ad7900; }
|
||||||
|
.selection-picker-option > .van-icon { flex-shrink: 0; color: #c3cbd6; font-size: 20px; }
|
||||||
|
.selection-picker-option.is-active > .van-icon { color: #006dbb; }
|
||||||
|
.selection-picker-footer { display: flex; flex-shrink: 0; gap: 10px; padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); border-top: 1px solid #edf0f5; background: #fff; }
|
||||||
|
.selection-picker-footer .van-button { height: 44px; border-radius: 6px; font-size: 14px; font-weight: 500; }
|
||||||
|
.selection-picker-cancel { flex: 2; color: #006dbb; border-color: #cad7e7; }
|
||||||
|
.selection-picker-confirm { flex: 3; background: #006dbb; border-color: #006dbb; }
|
||||||
|
.selection-proxy { display: flex; flex-direction: column; max-height: 85vh; max-height: 85dvh; background: #f4f6fa; }
|
||||||
|
.selection-proxy-content { flex: 0 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior-y: contain; padding: 14px 16px; }
|
||||||
|
.selection-proxy-person { display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px 12px; margin-bottom: 14px; color: #1c293a; line-height: 24px; }
|
||||||
|
.selection-proxy-person-name { font-size: 17px; font-weight: 600; overflow-wrap: anywhere; }
|
||||||
|
.selection-proxy-person-number { color: #667085; font-size: 13px; overflow-wrap: anywhere; }
|
||||||
|
.selection-proxy-section { margin-bottom: 12px; padding: 14px; border-radius: 10px; background: #fff; }
|
||||||
|
.selection-proxy-section:last-child { margin-bottom: 0; }
|
||||||
|
.selection-proxy-title { margin-bottom: 8px; color: #1c293a; font-size: 15px; font-weight: 600; line-height: 22px; overflow-wrap: anywhere; }
|
||||||
|
.selection-proxy-description { margin-bottom: 12px; color: #8792a3; font-size: 13px; line-height: 20px; }
|
||||||
|
.selection-option { display: flex; align-items: center; justify-content: space-between; box-sizing: border-box; min-height: 44px; margin-top: 8px; padding: 10px 12px; border: 1px solid #e2e7ef; border-radius: 6px; font-size: 14px; line-height: 22px; }
|
||||||
|
.selection-option.is-active { border-color: #006dbb; background: #eef7ff; }
|
||||||
|
.selection-option-radio { cursor: pointer; }
|
||||||
|
.selection-option-name { flex: 1; min-width: 0; padding-right: 12px; overflow-wrap: anywhere; }
|
||||||
|
.selection-option .van-stepper { flex-shrink: 0; }
|
||||||
|
.selection-proxy-section .van-cell { align-items: center; padding: 8px 0; }
|
||||||
|
.selection-proxy-section .van-field__label { width: 72px; color: #667085; font-size: 13px; }
|
||||||
|
.selection-proxy-section .van-field__body { padding: 7px 10px; border: 1px solid #e2e7ef; border-radius: 6px; background: #f4f6f9; }
|
||||||
|
.selection-proxy-hint { display: flex; align-items: center; gap: 6px; margin-top: 10px; color: #8792a3; font-size: 12px; line-height: 18px; }
|
||||||
|
.selection-proxy-hint .van-icon { flex-shrink: 0; color: #006dbb; font-size: 14px; }
|
||||||
|
.selection-proxy-loading { min-height: 160px; padding: 48px 16px; box-sizing: border-box; text-align: center; }
|
||||||
|
.selection-proxy-footer { flex-shrink: 0; padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); border-top: 1px solid #edf0f5; background: #fff; }
|
||||||
|
.selection-proxy-footer .van-button { height: 44px; border-radius: 6px; background: #006dbb; border-color: #006dbb; font-size: 15px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="app" v-cloak class="selection-situation-page">
|
||||||
|
<van-nav-bar title="选择情况" left-text="返回" left-arrow @click-left="historyBack"></van-nav-bar>
|
||||||
|
|
||||||
|
<div class="selection-filters">
|
||||||
|
<div class="selection-search-row">
|
||||||
|
<van-search v-model="searchKeyword" placeholder="工号或姓名" @search="doSearch"></van-search>
|
||||||
|
<van-button type="info" @click="doSearch">查询</van-button>
|
||||||
|
</div>
|
||||||
|
<div class="selection-filter-row">
|
||||||
|
<button type="button" class="selection-filter-value selection-filter-project" aria-label="选择福利项目"
|
||||||
|
:title="currentProjectName || '请选择福利项目'" @click="openPicker('project')">
|
||||||
|
<span class="selection-filter-copy">
|
||||||
|
<span class="selection-filter-label">福利项目</span>
|
||||||
|
<span class="selection-filter-selected">{{ currentProjectName || '请选择福利项目' }}</span>
|
||||||
|
</span>
|
||||||
|
<van-icon name="arrow-down"></van-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="selection-filter-row">
|
||||||
|
<button type="button" class="selection-filter-value" :class="{'is-active': pageForm.isSelect !== null}"
|
||||||
|
aria-label="选择状态" @click="openPicker('status')">
|
||||||
|
<span class="selection-filter-copy">
|
||||||
|
<span class="selection-filter-label">选择状态</span>
|
||||||
|
<span class="selection-filter-selected">{{ currentStatusName }}</span>
|
||||||
|
</span>
|
||||||
|
<van-icon name="arrow-down"></van-icon>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="selection-filter-value" :class="{'is-active': pageForm.isSelectByAdmin !== null}"
|
||||||
|
aria-label="管理员代选" @click="openPicker('admin')">
|
||||||
|
<span class="selection-filter-copy">
|
||||||
|
<span class="selection-filter-label">管理员代选</span>
|
||||||
|
<span class="selection-filter-selected">{{ currentAdminName }}</span>
|
||||||
|
</span>
|
||||||
|
<van-icon name="arrow-down"></van-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="selection-result-count" v-if="pageForm.projectId">
|
||||||
|
<span class="selection-result-title">人员名单</span>
|
||||||
|
<span>共 <strong>{{ pageForm.totalCount }}</strong> 人</span>
|
||||||
|
</div>
|
||||||
|
<div ref="personScroll" class="selection-person-scroll">
|
||||||
|
<van-list v-if="pageForm.projectId" v-model="tableLoading" :finished="finished" finished-text="没有更多了" @load="pageData">
|
||||||
|
<div class="selection-person-card" v-for="row in tableData" :key="row.id">
|
||||||
|
<div class="selection-person-head">
|
||||||
|
<div class="selection-person-identity">
|
||||||
|
<span class="selection-person-name">{{ row.userName || '-' }}</span>
|
||||||
|
<span class="selection-person-number" :aria-label="'工号 ' + (row.loginName || '-')">{{ row.loginName || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="selection-person-status" :class="{'is-unselected': !row.selectedOptions}">
|
||||||
|
<i class="fa fa-circle" aria-hidden="true"></i>
|
||||||
|
{{ row.selectedOptions ? '已选择' : '未选择' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="selection-person-organization">
|
||||||
|
<span :title="'所属工会:' + (row.welfareUnionName || '-')">{{ row.welfareUnionName || '-' }}</span><span class="selection-person-separator" aria-hidden="true">·</span><span :title="'所属单位:' + (row.welfareUnitName || '-')">{{ row.welfareUnitName || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="selection-person-footer">
|
||||||
|
<div class="selection-person-benefit">
|
||||||
|
<span class="selection-person-benefit-label">福利</span>
|
||||||
|
<span class="selection-person-benefit-value" :class="{'is-empty': !row.selectedOptions}">{{ row.selectedOptions || '暂未选择' }}</span>
|
||||||
|
</div>
|
||||||
|
<van-button type="info" size="small" @click="openProxy(row)">代选</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<van-empty class="selection-empty" v-if="!tableLoading && tableData.length === 0" description="暂无符合条件的人员"></van-empty>
|
||||||
|
</van-list>
|
||||||
|
<div class="selection-empty" v-else-if="projectLoading" style="text-align:center"><van-loading>加载福利项目...</van-loading></div>
|
||||||
|
<van-empty class="selection-empty" v-else description="暂无福利项目"></van-empty>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<van-popup v-model="showProjectPicker" class="selection-popup" position="bottom" round :close-on-click-overlay="false">
|
||||||
|
<div class="selection-picker">
|
||||||
|
<div class="selection-sheet-head">
|
||||||
|
<span>选择福利项目</span>
|
||||||
|
<button type="button" class="selection-sheet-close" aria-label="关闭福利项目选择" @click="closePopup('project')"><van-icon name="cross"></van-icon></button>
|
||||||
|
</div>
|
||||||
|
<div class="selection-picker-description">请选择本次查询的福利项目</div>
|
||||||
|
<div class="selection-picker-options" role="group" aria-label="福利项目">
|
||||||
|
<button v-for="item in projectColumns" :key="item.value" type="button" class="selection-picker-option"
|
||||||
|
:class="{'is-active': draftProjectId === item.value}" :aria-pressed="draftProjectId === item.value"
|
||||||
|
@click="selectPickerOption('project', item.value)">
|
||||||
|
<span class="selection-picker-option-label">{{ item.text }}</span>
|
||||||
|
<van-icon :name="draftProjectId === item.value ? 'checked' : 'circle'" aria-hidden="true"></van-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="selection-picker-footer">
|
||||||
|
<van-button class="selection-picker-cancel" @click="closePopup('project')">取消</van-button>
|
||||||
|
<van-button class="selection-picker-confirm" type="info" :disabled="!draftProjectId" @click="chooseProject">确认选择</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
<van-popup v-model="showStatusPicker" class="selection-popup" position="bottom" round :close-on-click-overlay="false">
|
||||||
|
<div class="selection-picker">
|
||||||
|
<div class="selection-sheet-head">
|
||||||
|
<span>选择状态</span>
|
||||||
|
<button type="button" class="selection-sheet-close" aria-label="关闭选择状态" @click="closePopup('status')"><van-icon name="cross"></van-icon></button>
|
||||||
|
</div>
|
||||||
|
<div class="selection-picker-options" role="group" aria-label="选择状态">
|
||||||
|
<button v-for="item in statusColumns" :key="item.text" type="button" class="selection-picker-option"
|
||||||
|
:class="{'is-active': draftStatus === item.value}" :aria-pressed="draftStatus === item.value"
|
||||||
|
@click="selectPickerOption('status', item.value)">
|
||||||
|
<span class="selection-picker-option-label"><i v-if="item.value !== null" class="fa fa-circle" :class="{'is-unselected': item.value === false}" aria-hidden="true"></i>{{ item.text }}</span>
|
||||||
|
<van-icon :name="draftStatus === item.value ? 'checked' : 'circle'" aria-hidden="true"></van-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="selection-picker-footer">
|
||||||
|
<van-button class="selection-picker-cancel" @click="closePopup('status')">取消</van-button>
|
||||||
|
<van-button class="selection-picker-confirm" type="info" @click="chooseStatus">确认选择</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<van-popup v-model="showAdminPicker" class="selection-popup" position="bottom" round :close-on-click-overlay="false">
|
||||||
|
<div class="selection-picker">
|
||||||
|
<div class="selection-sheet-head">
|
||||||
|
<span>管理员代选</span>
|
||||||
|
<button type="button" class="selection-sheet-close" aria-label="关闭管理员代选筛选" @click="closePopup('admin')"><van-icon name="cross"></van-icon></button>
|
||||||
|
</div>
|
||||||
|
<div class="selection-picker-options" role="group" aria-label="管理员代选">
|
||||||
|
<button v-for="item in adminColumns" :key="item.text" type="button" class="selection-picker-option"
|
||||||
|
:class="{'is-active': draftAdmin === item.value}" :aria-pressed="draftAdmin === item.value"
|
||||||
|
@click="selectPickerOption('admin', item.value)">
|
||||||
|
<span class="selection-picker-option-label">{{ item.text }}</span>
|
||||||
|
<van-icon :name="draftAdmin === item.value ? 'checked' : 'circle'" aria-hidden="true"></van-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="selection-picker-footer">
|
||||||
|
<van-button class="selection-picker-cancel" @click="closePopup('admin')">取消</van-button>
|
||||||
|
<van-button class="selection-picker-confirm" type="info" @click="chooseAdmin">确认选择</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<van-popup v-model="showProxy" class="selection-popup" position="bottom" round :close-on-click-overlay="false">
|
||||||
|
<div class="selection-proxy">
|
||||||
|
<div class="selection-sheet-head">
|
||||||
|
<span>代选福利</span>
|
||||||
|
<button type="button" class="selection-sheet-close" aria-label="关闭代选福利" @click="closePopup('proxy')"><van-icon name="cross"></van-icon></button>
|
||||||
|
</div>
|
||||||
|
<div class="selection-proxy-content" v-if="!proxyLoading">
|
||||||
|
<div class="selection-proxy-person">
|
||||||
|
<span class="selection-proxy-person-name">{{ proxyRow.userName || '-' }}</span>
|
||||||
|
<span class="selection-proxy-person-number">工号 {{ proxyRow.loginName || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="selection-proxy-section">
|
||||||
|
<div class="selection-proxy-title">{{ proxyProject.name || '福利选项' }}</div>
|
||||||
|
<div class="selection-proxy-description">{{ proxyProject.isCheckBox === 'radio' ? '请选择一项福利' : '请选择福利及份数' }}</div>
|
||||||
|
<van-radio-group v-if="proxyProject.isCheckBox === 'radio'" v-model="selectedRadioId">
|
||||||
|
<div class="selection-option selection-option-radio" v-for="option in proxyProject.options" :key="option.id"
|
||||||
|
:class="{'is-active': selectedRadioId === option.id}" @click="chooseRadio(option.id)">
|
||||||
|
<span class="selection-option-name">{{ option.optionName }}</span>
|
||||||
|
<van-radio :name="option.id" checked-color="#006dbb"></van-radio>
|
||||||
|
</div>
|
||||||
|
</van-radio-group>
|
||||||
|
<template v-else>
|
||||||
|
<div class="selection-option" v-for="option in proxyProject.options" :key="option.id" :class="{'is-active': option.selectNum > 0}">
|
||||||
|
<span class="selection-option-name">{{ option.optionName }}</span>
|
||||||
|
<van-stepper :value="option.selectNum" :min="0" :max="optionMax(option)" integer disable-input
|
||||||
|
@change="updateOptionNum(option, $event)"></van-stepper>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="selection-proxy-section">
|
||||||
|
<div class="selection-proxy-title">联系信息</div>
|
||||||
|
<van-field v-model="proxyForm.welfareMobile" type="tel" maxlength="11" label="福利电话" placeholder="请输入11位手机号" :border="false" required></van-field>
|
||||||
|
<van-field v-if="proxyProject.provideMode === 3" v-model="proxyForm.receiveAddress" type="textarea"
|
||||||
|
maxlength="100" rows="2" autosize label="收货地址" placeholder="请输入被代选人的收货地址" :border="false" required></van-field>
|
||||||
|
<div class="selection-proxy-hint"><van-icon name="info-o"></van-icon><span>请核对人员信息后提交</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="selection-proxy-section" v-if="proxyProject.signMode === 2">
|
||||||
|
<div class="selection-proxy-title">签字确认</div>
|
||||||
|
<h5-signature v-model="proxyForm.userSign"></h5-signature>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="selection-proxy-content selection-proxy-loading" v-else><van-loading>加载中...</van-loading></div>
|
||||||
|
<div class="selection-proxy-footer">
|
||||||
|
<van-button block type="info" :loading="formLoading" :disabled="proxyLoading || !hasSelection" @click="submitProxy">
|
||||||
|
{{ hasSubmittedBefore ? '确认修改' : '确认代选' }}
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
new Vue({
|
||||||
|
el: "#app",
|
||||||
|
store,
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
pageForm: {pageNumber: 1, pageSize: 10, projectId: null, isSelect: null, isSelectByAdmin: null, totalCount: 0},
|
||||||
|
searchKeyword: "",
|
||||||
|
projectColumns: [],
|
||||||
|
projectLoading: true,
|
||||||
|
statusColumns: [
|
||||||
|
{text: "全部", value: null},
|
||||||
|
{text: "已选择", value: true},
|
||||||
|
{text: "未选择", value: false}
|
||||||
|
],
|
||||||
|
adminColumns: [
|
||||||
|
{text: "全部", value: null},
|
||||||
|
{text: "是", value: true},
|
||||||
|
{text: "否", value: false}
|
||||||
|
],
|
||||||
|
tableData: [],
|
||||||
|
tableLoading: false,
|
||||||
|
listInFlight: false,
|
||||||
|
finished: false,
|
||||||
|
queryVersion: 0,
|
||||||
|
showProjectPicker: false,
|
||||||
|
showStatusPicker: false,
|
||||||
|
showAdminPicker: false,
|
||||||
|
draftProjectId: null,
|
||||||
|
draftStatus: null,
|
||||||
|
draftAdmin: null,
|
||||||
|
showProxy: false,
|
||||||
|
historyOwner: "",
|
||||||
|
proxyRow: {},
|
||||||
|
proxyProject: {},
|
||||||
|
proxyLoading: false,
|
||||||
|
proxyRequestVersion: 0,
|
||||||
|
selectedRadioId: null,
|
||||||
|
proxyForm: {welfareMobile: "", receiveAddress: "", userSign: ""},
|
||||||
|
hasSubmittedBefore: false,
|
||||||
|
formLoading: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
currentProjectName() {
|
||||||
|
const current = this.projectColumns.find((item) => item.value === this.pageForm.projectId)
|
||||||
|
return current ? current.text : ""
|
||||||
|
},
|
||||||
|
currentStatusName() {
|
||||||
|
const current = this.statusColumns.find((item) => item.value === this.pageForm.isSelect)
|
||||||
|
return current ? current.text : "全部"
|
||||||
|
},
|
||||||
|
// 将管理员代选的 null/true/false 显示为全部/是/否,保留 false 的有效含义。
|
||||||
|
currentAdminName() {
|
||||||
|
const current = this.adminColumns.find((item) => item.value === this.pageForm.isSelectByAdmin)
|
||||||
|
return current ? current.text : "全部"
|
||||||
|
},
|
||||||
|
selectedOptions() {
|
||||||
|
if (!Array.isArray(this.proxyProject.options)) return []
|
||||||
|
if (this.proxyProject.isCheckBox === "radio") {
|
||||||
|
return this.proxyProject.options.filter((item) => item.id === this.selectedRadioId)
|
||||||
|
}
|
||||||
|
return this.proxyProject.options.filter((item) => Number(item.selectNum) > 0)
|
||||||
|
},
|
||||||
|
hasSelection() {
|
||||||
|
return this.selectedOptions.length > 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.historyOwner = "welfare-selection-situation-" + this._uid
|
||||||
|
this.loadProjects()
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.queryVersion++
|
||||||
|
this.proxyRequestVersion++
|
||||||
|
h5PopupHistory.unregisterOwner(this.historyOwner)
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
// 项目列表不传年份,供移动端直接选择各年度福利项目。
|
||||||
|
loadProjects() {
|
||||||
|
this.$axios.post("/platform/welfare/common/list", {}).then((res) => {
|
||||||
|
if (res.code !== 0) {
|
||||||
|
this.$toast.fail(res.msg || "福利项目加载失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const columns = (res.data || []).map((item) => ({text: item.name, value: item.id}))
|
||||||
|
this.$set(this, "projectColumns", columns)
|
||||||
|
if (columns.length > 0) {
|
||||||
|
this.$set(this.pageForm, "projectId", columns[0].value)
|
||||||
|
this.doSearch()
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
this.$toast.fail("福利项目加载失败")
|
||||||
|
}).finally(() => {
|
||||||
|
this.$set(this, "projectLoading", false)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
// type 为 project/status/admin;打开对应筛选并注册全局历史回调,无返回值。
|
||||||
|
openPicker(type) {
|
||||||
|
if (type === "project" && this.projectColumns.length === 0) return
|
||||||
|
const picker = {
|
||||||
|
project: {visible: "showProjectPicker", draft: "draftProjectId", field: "projectId"},
|
||||||
|
status: {visible: "showStatusPicker", draft: "draftStatus", field: "isSelect"},
|
||||||
|
admin: {visible: "showAdminPicker", draft: "draftAdmin", field: "isSelectByAdmin"}
|
||||||
|
}[type]
|
||||||
|
if (!picker) return
|
||||||
|
// 点选仅更新弹框草稿;重新打开时还原已确认条件,取消和返回均不发起查询。
|
||||||
|
this.$set(this, picker.draft, this.pageForm[picker.field])
|
||||||
|
h5PopupHistory.open(this.historyOwner + "-" + type, this.historyOwner, () => {
|
||||||
|
this.$set(this, picker.visible, false)
|
||||||
|
})
|
||||||
|
this.$set(this, picker.visible, true)
|
||||||
|
},
|
||||||
|
// 仅由按钮发起一次历史回退,弹框显隐由全局历史回调同步,取消不触发查询。
|
||||||
|
closePopup(type) {
|
||||||
|
return h5PopupHistory.close(this.historyOwner + "-" + type)
|
||||||
|
},
|
||||||
|
// type 为 project/status/admin,value 为项目 ID 或 null/true/false;仅修改草稿,无返回值、不查询。
|
||||||
|
selectPickerOption(type, value) {
|
||||||
|
const draft = {project: "draftProjectId", status: "draftStatus", admin: "draftAdmin"}[type]
|
||||||
|
if (draft) this.$set(this, draft, value)
|
||||||
|
},
|
||||||
|
// 确认时从项目列表校验草稿值,仅变更有效项目后查询;重复点击由历史栈拦截。
|
||||||
|
chooseProject() {
|
||||||
|
const item = this.projectColumns.find((option) => option.value === this.draftProjectId)
|
||||||
|
if (!item || !this.closePopup("project")) return
|
||||||
|
if (this.pageForm.projectId === item.value) return
|
||||||
|
this.$set(this.pageForm, "projectId", item.value)
|
||||||
|
this.doSearch()
|
||||||
|
},
|
||||||
|
// 状态值 null/true/false 分别代表全部、已选择、未选择,false 也是有效筛选值。
|
||||||
|
chooseStatus() {
|
||||||
|
const item = this.statusColumns.find((option) => option.value === this.draftStatus)
|
||||||
|
if (!item || !this.closePopup("status")) return
|
||||||
|
if (this.pageForm.isSelect === item.value) return
|
||||||
|
this.$set(this.pageForm, "isSelect", item.value)
|
||||||
|
this.doSearch()
|
||||||
|
},
|
||||||
|
// 全部不限制代选来源;是/否分别传 true/false,确认变更后复用现有分页重置逻辑。
|
||||||
|
chooseAdmin() {
|
||||||
|
const item = this.adminColumns.find((option) => option.value === this.draftAdmin)
|
||||||
|
if (!item || !this.closePopup("admin")) return
|
||||||
|
if (this.pageForm.isSelectByAdmin === item.value) return
|
||||||
|
this.$set(this.pageForm, "isSelectByAdmin", item.value)
|
||||||
|
this.doSearch()
|
||||||
|
},
|
||||||
|
// 现有接口分别按姓名或工号过滤;含中文的关键词作为姓名,其余作为工号。
|
||||||
|
doSearch() {
|
||||||
|
this.queryVersion++
|
||||||
|
this.$set(this.pageForm, "pageNumber", 1)
|
||||||
|
this.$set(this.pageForm, "totalCount", 0)
|
||||||
|
this.$set(this, "tableData", [])
|
||||||
|
this.$set(this, "finished", false)
|
||||||
|
this.$set(this, "listInFlight", false)
|
||||||
|
this.$set(this, "tableLoading", false)
|
||||||
|
// 切换筛选条件或重新查询时从第一张卡片开始,加载下一页时保持当前位置。
|
||||||
|
if (this.$refs.personScroll) this.$refs.personScroll.scrollTop = 0
|
||||||
|
if (this.pageForm.projectId) this.pageData()
|
||||||
|
},
|
||||||
|
// pageForm 传项目 ID、分页信息及选择状态;管理员代选为 true/false,“全部”省略该字段。
|
||||||
|
// 关键词按姓名或工号提交;响应 data 为 {list, totalCount},分别用于追加人员和判断分页结束。
|
||||||
|
pageData() {
|
||||||
|
if (!this.pageForm.projectId || this.finished || this.listInFlight) return
|
||||||
|
const version = this.queryVersion
|
||||||
|
const pageForm = Object.assign({}, this.pageForm)
|
||||||
|
// “全部”省略该查询参数;false 必须原样传递,表示已有选择且非管理员代选。
|
||||||
|
if (pageForm.isSelectByAdmin === null || pageForm.isSelectByAdmin === undefined || pageForm.isSelectByAdmin === "") {
|
||||||
|
delete pageForm.isSelectByAdmin
|
||||||
|
}
|
||||||
|
const keyword = this.searchKeyword.trim()
|
||||||
|
if (keyword) {
|
||||||
|
if (/[\u3400-\u9fff]/.test(keyword)) pageForm.userName = keyword
|
||||||
|
else pageForm.loginName = keyword
|
||||||
|
}
|
||||||
|
this.$set(this, "listInFlight", true)
|
||||||
|
this.$set(this, "tableLoading", true)
|
||||||
|
this.$axios.post("/platform/welfare/selection/situation/pageData", {pageForm: JSON.stringify(pageForm)})
|
||||||
|
.then((res) => {
|
||||||
|
if (version !== this.queryVersion) return
|
||||||
|
if (res.code !== 0) {
|
||||||
|
this.$toast.fail(res.msg || "查询失败")
|
||||||
|
this.$set(this, "finished", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = res.data || {}
|
||||||
|
const rows = data.list || []
|
||||||
|
this.$set(this, "tableData", this.tableData.concat(rows))
|
||||||
|
this.$set(this.pageForm, "totalCount", data.totalCount || 0)
|
||||||
|
this.$set(this.pageForm, "pageNumber", pageForm.pageNumber + 1)
|
||||||
|
this.$set(this, "finished", rows.length === 0 || this.tableData.length >= this.pageForm.totalCount)
|
||||||
|
}).catch(() => {
|
||||||
|
if (version === this.queryVersion) {
|
||||||
|
this.$toast.fail("查询失败,请重试")
|
||||||
|
this.$set(this, "finished", true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (version !== this.queryVersion) return
|
||||||
|
this.$set(this, "listInFlight", false)
|
||||||
|
this.$set(this, "tableLoading", false)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
// 打开单人代选,回显当前项目配置、已有选择和被代选人的福利电话。
|
||||||
|
openProxy(row) {
|
||||||
|
this.proxyRequestVersion++
|
||||||
|
const version = this.proxyRequestVersion
|
||||||
|
this.$set(this, "proxyRow", row)
|
||||||
|
this.$set(this, "proxyProject", {})
|
||||||
|
this.$set(this, "selectedRadioId", null)
|
||||||
|
this.$set(this, "proxyForm", {welfareMobile: "", receiveAddress: "", userSign: ""})
|
||||||
|
this.$set(this, "proxyLoading", true)
|
||||||
|
this.$set(this, "hasSubmittedBefore", false)
|
||||||
|
h5PopupHistory.open(this.historyOwner + "-proxy", this.historyOwner, () => {
|
||||||
|
this.proxyRequestVersion++
|
||||||
|
this.$set(this, "showProxy", false)
|
||||||
|
})
|
||||||
|
this.$set(this, "showProxy", true)
|
||||||
|
Promise.all([
|
||||||
|
this.$axios.post("/platform/welfare/common/projectInfo", {id: row.projectId}),
|
||||||
|
this.$axios.post("/platform/welfare/selection/situation/getUserSelection", {projectId: row.projectId, userId: row.userId}),
|
||||||
|
this.$axios.post("/platform/welfare/selection/situation/getWelfareMobileByUserId", {userId: row.userId})
|
||||||
|
]).then((results) => {
|
||||||
|
if (version !== this.proxyRequestVersion) return
|
||||||
|
if (results.some((item) => item.code !== 0)) {
|
||||||
|
this.$toast.fail("代选信息加载失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const project = results[0].data || {}
|
||||||
|
const selections = results[1].data || []
|
||||||
|
const options = project.options || []
|
||||||
|
options.forEach((option) => this.$set(option, "selectNum", 0))
|
||||||
|
this.$set(this, "proxyProject", project)
|
||||||
|
this.$set(this, "hasSubmittedBefore", selections.length > 0)
|
||||||
|
this.$set(this.proxyForm, "welfareMobile", results[2].data || "")
|
||||||
|
if (selections.length > 0) {
|
||||||
|
this.$set(this.proxyForm, "receiveAddress", selections[0].receiveAddress || "")
|
||||||
|
this.$set(this.proxyForm, "userSign", selections[0].userSign || "")
|
||||||
|
}
|
||||||
|
if (project.isCheckBox === "radio" && selections.length > 0) {
|
||||||
|
this.$set(this, "selectedRadioId", selections[0].selectOptionId)
|
||||||
|
} else {
|
||||||
|
selections.forEach((selection) => {
|
||||||
|
const option = options.find((item) => item.id === selection.selectOptionId)
|
||||||
|
if (option) this.$set(option, "selectNum", selection.selectNum || 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
if (version === this.proxyRequestVersion) this.$toast.fail("代选信息加载失败")
|
||||||
|
}).finally(() => {
|
||||||
|
if (version === this.proxyRequestVersion) this.$set(this, "proxyLoading", false)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
chooseRadio(id) {
|
||||||
|
this.$set(this, "selectedRadioId", id)
|
||||||
|
},
|
||||||
|
optionMax(option) {
|
||||||
|
if (option.maxNum != null) return Number(option.maxNum)
|
||||||
|
if (this.proxyProject.singleOptionSupportMultipleSelection) return Number(this.proxyProject.multiSelectNum) || 1
|
||||||
|
return 1
|
||||||
|
},
|
||||||
|
// 单项上限和项目总份数共同限制代选数量。
|
||||||
|
updateOptionNum(option, value) {
|
||||||
|
const count = Number(value) || 0
|
||||||
|
const others = (this.proxyProject.options || []).reduce((sum, item) => {
|
||||||
|
return sum + (item.id === option.id ? 0 : Number(item.selectNum || 0))
|
||||||
|
}, 0)
|
||||||
|
const maxTotal = Number(this.proxyProject.multiSelectNum) || 1
|
||||||
|
if (others + count > maxTotal) {
|
||||||
|
this.$toast("此项目最多选择" + maxTotal + "份")
|
||||||
|
this.$set(option, "selectNum", Math.max(0, maxTotal - others))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.$set(option, "selectNum", count)
|
||||||
|
},
|
||||||
|
// 仅提交被代选人、已选选项及必要联系信息,不改变现有后端规则。
|
||||||
|
submitProxy() {
|
||||||
|
if (!this.hasSelection || this.formLoading) return
|
||||||
|
const mobile = (this.proxyForm.welfareMobile || "").trim()
|
||||||
|
if (!/^1[3456789]\d{9}$/.test(mobile)) {
|
||||||
|
this.$toast.fail("请输入正确的福利电话")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.proxyProject.provideMode === 3 && !(this.proxyForm.receiveAddress || "").trim()) {
|
||||||
|
this.$toast.fail("请输入收货地址")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.proxyProject.signMode === 2 && !this.proxyForm.userSign) {
|
||||||
|
this.$toast.fail("请完成签字确认")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const selections = this.selectedOptions.map((option) => ({
|
||||||
|
selectOptionId: option.id,
|
||||||
|
selectNum: this.proxyProject.isCheckBox === "radio" ? 1 : Number(option.selectNum),
|
||||||
|
receiveAddress: this.proxyForm.receiveAddress,
|
||||||
|
userSign: this.proxyForm.userSign
|
||||||
|
}))
|
||||||
|
this.$set(this, "formLoading", true)
|
||||||
|
this.$axios.post("/platform/welfare/selection/situation/confirmSelect", {
|
||||||
|
projectId: this.proxyRow.projectId,
|
||||||
|
userId: this.proxyRow.userId,
|
||||||
|
welfareMobile: mobile,
|
||||||
|
selections: JSON.stringify(selections)
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.code !== 0) {
|
||||||
|
this.$toast.fail(res.msg || "代选失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.$toast.success("代选成功")
|
||||||
|
h5PopupHistory.clearOwner(this.historyOwner)
|
||||||
|
this.doSearch()
|
||||||
|
}).catch(() => {
|
||||||
|
this.$toast.fail("代选失败,请重试")
|
||||||
|
}).finally(() => {
|
||||||
|
this.$set(this, "formLoading", false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<!--#
|
||||||
|
}
|
||||||
|
#-->
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
<!--#
|
||||||
|
layout("/layouts/platform_h5.html"){
|
||||||
|
#-->
|
||||||
|
<style>
|
||||||
|
/* 固定视口使用 flex 分配剩余空间,首次进入和刷新均不依赖延时计算高度。 */
|
||||||
|
#app.welfare-statistics-page { height: 100%; height: 100dvh; min-height: 0; overflow: hidden; flex-direction: column; background: #f4f7fb; color: #182538; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
|
||||||
|
#app.welfare-statistics-page:not([v-cloak]) { display: flex; }
|
||||||
|
.welfare-statistics-page * { box-sizing: border-box; }
|
||||||
|
.welfare-statistics-page button { font-family: inherit; cursor: pointer; }
|
||||||
|
.welfare-statistics-page button:focus-visible { outline: 2px solid #0074d9; outline-offset: 2px; }
|
||||||
|
.welfare-statistics-page > .van-nav-bar, .ws-filters { flex-shrink: 0; background: #fff; }
|
||||||
|
.welfare-statistics-page .van-nav-bar__title { color: #182538; font-weight: 600; }
|
||||||
|
.welfare-statistics-page .van-nav-bar__text, .welfare-statistics-page .van-nav-bar .van-icon { color: #0074d9; }
|
||||||
|
.ws-filters { padding: 10px 12px 12px; border-bottom: 1px solid #e7edf5; }
|
||||||
|
.ws-filter-row { display: flex; gap: 8px; }
|
||||||
|
.ws-filter-row + .ws-filter-row { margin-top: 8px; }
|
||||||
|
.ws-filter { display: flex; align-items: center; justify-content: space-between; flex: 1; min-width: 0; height: 38px; padding: 0 10px; border: 1px solid #dde6f0; border-radius: 7px; background: #fff; color: #24364d; font-size: 14px; text-align: left; }
|
||||||
|
.ws-filter span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.ws-filter .van-icon { flex-shrink: 0; margin-left: 8px; color: #607895; }
|
||||||
|
.ws-filter-year { flex: 0 0 94px; }
|
||||||
|
.ws-filter-project { background: #f0f7ff; border-color: #d9e9fa; }
|
||||||
|
.ws-filter:disabled { opacity: .55; cursor: default; }
|
||||||
|
/* 顶部之外仅此容器滚动,边界滚动不传递给页面外层。 */
|
||||||
|
.ws-content { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior-y: contain; -webkit-overflow-scrolling: touch; padding: 12px 12px calc(20px + env(safe-area-inset-bottom)); }
|
||||||
|
.ws-summary, .ws-union { padding: 16px 12px; background: #fff; border-radius: 10px; }
|
||||||
|
.ws-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); text-align: center; }
|
||||||
|
.ws-metric { min-width: 0; }
|
||||||
|
.ws-metric + .ws-metric { border-left: 1px solid #e8eef6; }
|
||||||
|
.ws-metric-label { display: block; margin-bottom: 5px; color: #718198; font-size: 12px; }
|
||||||
|
.ws-metric strong { display: block; font-size: 23px; line-height: 28px; font-variant-numeric: tabular-nums; }
|
||||||
|
.ws-blue { color: #0074d9; }
|
||||||
|
.ws-orange { color: #dc7600; }
|
||||||
|
.ws-summary .van-progress { margin-top: 18px; }
|
||||||
|
.ws-rate { margin-top: 8px; text-align: right; color: #718198; font-size: 12px; }
|
||||||
|
.ws-rate strong { margin-left: 6px; color: #0074d9; }
|
||||||
|
.ws-note { margin: 10px 0 0; color: #718198; font-size: 12px; line-height: 18px; }
|
||||||
|
.ws-section-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 18px 2px 10px; }
|
||||||
|
.ws-section-title h2 { margin: 0; font-size: 17px; }
|
||||||
|
.ws-section-title span { color: #718198; font-size: 12px; }
|
||||||
|
.ws-union + .ws-union { margin-top: 10px; }
|
||||||
|
.ws-union-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 13px; }
|
||||||
|
.ws-union-head h3 { margin: 0; font-size: 16px; line-height: 23px; overflow-wrap: anywhere; }
|
||||||
|
.ws-union-head span { flex-shrink: 0; padding-top: 2px; color: #0074d9; font-size: 14px; }
|
||||||
|
.ws-count-button { width: 100%; min-height: 48px; padding: 0; border: 0; background: transparent; }
|
||||||
|
.ws-count-button strong { display: inline; font-size: 20px; }
|
||||||
|
.ws-count-button .van-icon { font-size: 13px; vertical-align: 1px; }
|
||||||
|
.ws-union .ws-metric > strong { font-size: 20px; }
|
||||||
|
.ws-options { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px 0; margin-top: 13px; padding: 10px 0; background: #f4f8fc; border-radius: 7px; text-align: center; }
|
||||||
|
.ws-option { padding: 0 6px; min-width: 0; font-size: 13px; line-height: 19px; overflow-wrap: anywhere; }
|
||||||
|
.ws-option:not(:nth-child(3n+1)) { border-left: 1px solid #dde7f3; }
|
||||||
|
.ws-option span { display: block; color: #718198; }
|
||||||
|
.ws-option strong { font-weight: 500; }
|
||||||
|
.ws-loading { padding: 60px 0; text-align: center; }
|
||||||
|
.ws-error { padding: 35px 15px; text-align: center; color: #718198; font-size: 14px; }
|
||||||
|
.ws-error .van-button { margin-top: 12px; }
|
||||||
|
/* 弹框也使用 flex;标题、筛选和查询固定,名单与选项列表分别独立滚动。 */
|
||||||
|
.welfare-statistics-page .ws-sheet { display: flex; flex-direction: column; overflow: hidden; padding-bottom: env(safe-area-inset-bottom); background: #fff; }
|
||||||
|
.ws-filter-sheet { max-height: 75%; max-height: 75dvh; }
|
||||||
|
.ws-sheet-toolbar { display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; min-height: 54px; padding: 0 10px; border-bottom: 1px solid #edf1f7; }
|
||||||
|
.ws-sheet-toolbar strong { font-size: 16px; }
|
||||||
|
.ws-sheet-toolbar button { min-height: 44px; padding: 0 10px; border: 0; background: transparent; color: #0074d9; font-size: 14px; }
|
||||||
|
.ws-sheet-toolbar button:first-child { color: #718198; }
|
||||||
|
.ws-choices { min-height: 0; overflow-y: auto; overscroll-behavior-y: contain; padding: 8px 16px 16px; }
|
||||||
|
.ws-choice { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; min-height: 48px; padding: 12px; border: 0; border-radius: 7px; background: #fff; color: #24364d; text-align: left; font-size: 14px; line-height: 21px; }
|
||||||
|
.ws-choice.is-selected { background: #edf6ff; color: #0074d9; }
|
||||||
|
.ws-choice .van-icon { flex-shrink: 0; font-size: 18px; }
|
||||||
|
.ws-detail-sheet { height: 82%; height: 82dvh; }
|
||||||
|
.ws-detail-head { flex-shrink: 0; display: flex; justify-content: space-between; gap: 12px; padding: 20px 16px 14px; }
|
||||||
|
.ws-detail-head h2 { margin: 0 0 5px; font-size: 20px; line-height: 27px; overflow-wrap: anywhere; }
|
||||||
|
.ws-detail-head p { margin: 0; color: #718198; font-size: 13px; line-height: 20px; overflow-wrap: anywhere; }
|
||||||
|
.ws-close { flex-shrink: 0; align-self: flex-start; width: 36px; height: 36px; margin: -4px -8px 0 0; border: 0; background: transparent; color: #718198; font-size: 22px; }
|
||||||
|
.ws-tabs { flex-shrink: 0; display: flex; border-bottom: 1px solid #e7edf5; }
|
||||||
|
.ws-tab { position: relative; flex: 1; min-height: 44px; padding: 8px 4px; border: 0; background: #fff; color: #718198; font-size: 15px; }
|
||||||
|
.ws-tab.is-selected { color: #0074d9; font-weight: 600; }
|
||||||
|
.ws-tab.ws-tab-unselected.is-selected { color: #dc7600; }
|
||||||
|
.ws-tab.is-selected:after { position: absolute; right: 25%; bottom: 0; left: 25%; height: 3px; border-radius: 3px; background: currentColor; content: ""; }
|
||||||
|
.ws-search { display: flex; align-items: center; gap: 8px; flex-shrink: 0; padding: 12px 16px 6px; }
|
||||||
|
.ws-search .van-search { flex: 1; min-width: 0; padding: 0; }
|
||||||
|
.ws-search .van-search__content { border: 1px solid #e1e9f3; border-radius: 7px; background: #f4f7fb; }
|
||||||
|
.ws-search .van-button { height: 36px; border-radius: 6px; }
|
||||||
|
.ws-detail-count { flex-shrink: 0; padding: 5px 16px 10px; color: #718198; font-size: 12px; text-align: right; }
|
||||||
|
.ws-person-scroll { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior-y: contain; -webkit-overflow-scrolling: touch; padding: 0 16px 16px; }
|
||||||
|
.ws-person { margin-bottom: 8px; padding: 12px; border: 1px solid #e7edf5; border-radius: 8px; }
|
||||||
|
.ws-person-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
|
||||||
|
.ws-person-head > div { min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.ws-person-name { font-size: 16px; font-weight: 600; line-height: 24px; }
|
||||||
|
.ws-person-number { display: inline-block; margin-left: 6px; color: #718198; font-size: 13px; }
|
||||||
|
.ws-person-status { flex-shrink: 0; padding-top: 3px; font-size: 12px; }
|
||||||
|
.ws-person p { margin: 5px 0 0; color: #718198; font-size: 13px; line-height: 20px; overflow-wrap: anywhere; }
|
||||||
|
.ws-person .ws-person-option { color: #395570; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="app" class="welfare-statistics-page" v-cloak>
|
||||||
|
<van-nav-bar title="福利统计" left-text="返回" left-arrow @click-left="historyBack"></van-nav-bar>
|
||||||
|
<div class="ws-filters">
|
||||||
|
<div class="ws-filter-row">
|
||||||
|
<button class="ws-filter ws-filter-year" :disabled="bootLoading || !!bootError" @click="openFilter('year')" aria-label="选择年份"><span>{{ filters.year }}年</span><van-icon name="arrow-down"></van-icon></button>
|
||||||
|
<button class="ws-filter ws-filter-project" :disabled="bootLoading || !!bootError" @click="openFilter('projectId')" aria-label="选择福利项目"><span>{{ projectName || '选择福利项目' }}</span><van-icon name="arrow-down"></van-icon></button>
|
||||||
|
</div>
|
||||||
|
<div class="ws-filter-row">
|
||||||
|
<button class="ws-filter" :disabled="bootLoading || !!bootError || branchOnly" @click="openFilter('unionId')" aria-label="选择工会"><span>{{ unionName }}</span><van-icon name="arrow-down"></van-icon></button>
|
||||||
|
<button class="ws-filter" :disabled="bootLoading || !!bootError" @click="openFilter('method')" aria-label="选择统计方式"><span>{{ methodName }}</span><van-icon name="arrow-down"></van-icon></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<main class="ws-content" ref="contentScroll" aria-label="福利统计内容">
|
||||||
|
<div v-if="bootLoading || tableLoading" class="ws-loading"><van-loading size="24px" vertical>正在加载统计</van-loading></div>
|
||||||
|
<div v-else-if="bootError || tableError" class="ws-error"><div>{{ bootError || tableError }}</div><van-button size="small" plain type="info" @click="retryStatistics">重新加载</van-button></div>
|
||||||
|
<van-empty v-else-if="!filters.projectId" description="该年份暂无福利项目"></van-empty>
|
||||||
|
<template v-else>
|
||||||
|
<section class="ws-summary" aria-label="人数汇总">
|
||||||
|
<div class="ws-metrics">
|
||||||
|
<div class="ws-metric"><span class="ws-metric-label">福利人数</span><strong>{{ totals.teacherSum }}</strong></div>
|
||||||
|
<div class="ws-metric"><span class="ws-metric-label">已选择</span><strong class="ws-blue">{{ totals.selectedNum }}</strong></div>
|
||||||
|
<div class="ws-metric"><span class="ws-metric-label">未选择</span><strong class="ws-orange">{{ totals.unSelectedNum }}</strong></div>
|
||||||
|
</div>
|
||||||
|
<van-progress :percentage="percentage(totals)" :show-pivot="false" stroke-width="8" color="#0074d9" track-color="#e7f1fa"></van-progress>
|
||||||
|
<div class="ws-rate">已选比例<strong>{{ percentage(totals) }}%</strong></div>
|
||||||
|
<p v-if="filters.method !== ''" class="ws-note">已选按{{ methodName }}统计,未选为尚未选择人员。</p>
|
||||||
|
</section>
|
||||||
|
<div class="ws-section-title"><h2>分工会统计</h2><span>点击已选、未选人数查看名单</span></div>
|
||||||
|
<van-empty v-if="!tableData.length" description="暂无工会统计"></van-empty>
|
||||||
|
<article v-for="row in tableData" :key="row.id" class="ws-union">
|
||||||
|
<div class="ws-union-head"><h3>{{ row.name }}</h3><span>{{ percentage(row) }}%</span></div>
|
||||||
|
<div class="ws-metrics">
|
||||||
|
<div class="ws-metric"><span class="ws-metric-label">总人数</span><strong>{{ row.teacherSum || 0 }}</strong></div>
|
||||||
|
<div class="ws-metric"><button class="ws-count-button ws-blue" @click="openDetail(row, 'selected')" :aria-label="row.name + '已选择人员'"><span class="ws-metric-label">已选</span><strong>{{ row.selectedNum || 0 }}</strong><van-icon name="arrow"></van-icon></button></div>
|
||||||
|
<div class="ws-metric"><button class="ws-count-button ws-orange" @click="openDetail(row, 'unselected')" :aria-label="row.name + '未选择人员'"><span class="ws-metric-label">未选</span><strong>{{ row.unSelectedNum || 0 }}</strong><van-icon name="arrow"></van-icon></button></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="optionColumns.length" class="ws-options">
|
||||||
|
<div v-for="column in optionColumns" :key="column.prop" class="ws-option"><span>{{ column.label }}</span><strong>{{ row[column.prop] || 0 }}人</strong></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<van-popup :value="showFilter" position="bottom" round class="ws-sheet ws-filter-sheet" :close-on-click-overlay="false" @click-overlay="closeFilter">
|
||||||
|
<div class="ws-sheet-toolbar"><button @click="closeFilter">取消</button><strong>{{ filterTitle }}</strong><button @click="confirmFilter">确认</button></div>
|
||||||
|
<div class="ws-choices" role="radiogroup" :aria-label="filterTitle">
|
||||||
|
<button v-for="option in filterOptions" :key="String(option.value)" class="ws-choice" :class="{'is-selected': draftValue === option.value}" role="radio" :aria-checked="draftValue === option.value" @click="chooseDraft(option.value)"><span>{{ option.text }}</span><van-icon :name="draftValue === option.value ? 'checked' : 'circle'"></van-icon></button>
|
||||||
|
<van-empty v-if="!filterOptions.length" description="暂无可选项目"></van-empty>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<van-popup :value="showDetail" position="bottom" round class="ws-sheet ws-detail-sheet" :close-on-click-overlay="false" @click-overlay="closeDetail" @opened="checkDetailList">
|
||||||
|
<div class="ws-detail-head"><div><h2>{{ detailUnion.name }}</h2><p>{{ projectName }}</p></div><button class="ws-close" aria-label="关闭人员名单" @click="closeDetail"><van-icon name="cross"></van-icon></button></div>
|
||||||
|
<div class="ws-tabs" role="tablist" aria-label="人员选择状态">
|
||||||
|
<button class="ws-tab" :class="{'is-selected': detailTab === 'selected'}" role="tab" :aria-selected="detailTab === 'selected'" @click="changeDetailTab('selected')">已选择 {{ detailUnion.selectedNum || 0 }}</button>
|
||||||
|
<button class="ws-tab ws-tab-unselected" :class="{'is-selected': detailTab === 'unselected'}" role="tab" :aria-selected="detailTab === 'unselected'" @click="changeDetailTab('unselected')">未选择 {{ detailUnion.unSelectedNum || 0 }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="ws-search"><van-search :value="searchInput" placeholder="工号或姓名" @input="setSearchInput" @search="searchDetail" @clear="clearSearch"></van-search><van-button type="info" @click="searchDetail">查询</van-button></div>
|
||||||
|
<div class="ws-detail-count">{{ detailLoading && !detailRows.length ? '正在查询…' : '共 ' + detailTotal + ' 人' }}</div>
|
||||||
|
<div class="ws-person-scroll" ref="personScroll">
|
||||||
|
<van-list ref="personList" :loading="detailLoading" :finished="detailFinished" :immediate-check="false" :error="!!detailError" finished-text="没有更多了" @load="loadDetail">
|
||||||
|
<article v-for="(person, index) in detailRows" :key="String(person.loginName) + '-' + index" class="ws-person">
|
||||||
|
<div class="ws-person-head"><div><span class="ws-person-name">{{ person.userName || '未填写姓名' }}</span><span class="ws-person-number">{{ person.loginName }}</span></div><span class="ws-person-status" :class="detailTab === 'selected' ? 'ws-blue' : 'ws-orange'">● {{ detailTab === 'selected' ? '已选择' : '未选择' }}</span></div>
|
||||||
|
<p>{{ person.welfareUnitName || '未填写单位' }}</p>
|
||||||
|
<p v-if="detailTab === 'selected'" class="ws-person-option">{{ person.selectOptionName || '—' }}<span v-if="person.selectByAdminName"> · {{ person.selectByAdminName }}</span></p>
|
||||||
|
</article>
|
||||||
|
<template #error><div @click.stop="loadDetail">{{ detailError }},点击重试</div></template>
|
||||||
|
</van-list>
|
||||||
|
<van-empty v-if="!detailLoading && !detailError && !detailRows.length" description="暂无符合条件的人员"></van-empty>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
new Vue({
|
||||||
|
el: '#app',
|
||||||
|
store: store,
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
filters: {year: new Date().getFullYear(), projectId: '', unionId: '', method: ''},
|
||||||
|
projects: [], unions: [], branchOnly: false,
|
||||||
|
bootLoading: false, bootError: '', bootVersion: 0,
|
||||||
|
tableData: [], optionColumns: [], tableLoading: false, tableError: '', tableVersion: 0,
|
||||||
|
popupOwner: 'h5-welfare-statistics', showFilter: false, filterType: '', draftValue: '',
|
||||||
|
showDetail: false, detailUnion: {}, detailTab: 'selected', searchInput: '', searchKeyword: '',
|
||||||
|
detailRows: [], detailTotal: 0, detailPage: 1, detailLoading: false, detailFinished: false, detailError: '', detailVersion: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
yearProjects() { return this.projects.filter((item) => Number(item.year) === this.filters.year) },
|
||||||
|
projectName() { return (this.projects.find((item) => item.id === this.filters.projectId) || {}).name || '' },
|
||||||
|
unionName() { return (this.unions.find((item) => item.id === this.filters.unionId) || {}).name || '全部工会' },
|
||||||
|
methodName() { return this.filters.method === '' ? '全部方式' : (this.filters.method ? '管理员代选' : '个人选择') },
|
||||||
|
filterTitle() { return {year: '选择年份', projectId: '选择福利项目', unionId: '选择工会', method: '选择方式'}[this.filterType] || '' },
|
||||||
|
filterOptions() {
|
||||||
|
if (this.filterType === 'year') {
|
||||||
|
const years = Array.from(new Set([new Date().getFullYear()].concat(this.projects.map((item) => Number(item.year)).filter((year) => year > 0))))
|
||||||
|
return years.sort((a, b) => b - a).map((year) => ({value: year, text: year + '年'}))
|
||||||
|
}
|
||||||
|
if (this.filterType === 'projectId') return this.yearProjects.map((item) => ({value: item.id, text: item.name}))
|
||||||
|
if (this.filterType === 'unionId') return (this.branchOnly ? [] : [{value: '', text: '全部工会'}]).concat(this.unions.map((item) => ({value: item.id, text: item.name})))
|
||||||
|
return [{value: '', text: '全部方式'}, {value: false, text: '个人选择'}, {value: true, text: '管理员代选'}]
|
||||||
|
},
|
||||||
|
totals() {
|
||||||
|
return this.tableData.reduce((total, row) => {
|
||||||
|
Object.keys(total).forEach((key) => { total[key] += Number(row[key]) || 0 })
|
||||||
|
return total
|
||||||
|
}, {teacherSum: 0, selectedNum: 0, unSelectedNum: 0})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() { this.init() },
|
||||||
|
beforeDestroy() {
|
||||||
|
// 失效所有未完成请求,并移除本页面的弹框历史回调,防止离页后写回旧组件。
|
||||||
|
this.$set(this, 'bootVersion', this.bootVersion + 1)
|
||||||
|
this.$set(this, 'tableVersion', this.tableVersion + 1)
|
||||||
|
this.$set(this, 'detailVersion', this.detailVersion + 1)
|
||||||
|
h5PopupHistory.unregisterOwner(this.popupOwner)
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
percentage(row) {
|
||||||
|
const total = Number(row.teacherSum) || 0
|
||||||
|
return total > 0 ? Math.min(100, Math.max(0, Math.round((Number(row.selectedNum) || 0) / total * 1000) / 10)) : 0
|
||||||
|
},
|
||||||
|
// 项目接口返回 id/name/year 数组;工会接口沿用 PC 分工会主席的默认范围。
|
||||||
|
init() {
|
||||||
|
const version = this.bootVersion + 1
|
||||||
|
this.$set(this, 'bootVersion', version)
|
||||||
|
this.$set(this, 'bootLoading', true)
|
||||||
|
this.$set(this, 'bootError', '')
|
||||||
|
const branchOnly = !this.$auth.hasRoleOr(['SYSADMIN', 'SCHOOL_UNION_ADMIN', 'SCHOOL_UNION_WELFARE_ADMIN']) && this.$auth.hasRoleOr(['BRANCH_UNION_CHAIRMAN'])
|
||||||
|
const userUnion = this.$store.state.user.union || {}
|
||||||
|
this.$set(this, 'branchOnly', branchOnly)
|
||||||
|
// 无所属工会时不能退化为全工会查询,显示可重试提示。
|
||||||
|
if (branchOnly && !userUnion.id) {
|
||||||
|
this.$set(this, 'bootError', '当前账号未配置所属工会,请联系管理员')
|
||||||
|
this.$set(this, 'bootLoading', false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Promise.all([
|
||||||
|
this.$axios.post('/platform/h5/welfare/statistics/projects', {}),
|
||||||
|
this.$axios.post('/open/common/unionOptions', branchOnly ? {unionId: userUnion.id} : {})
|
||||||
|
]).then((results) => {
|
||||||
|
if (version !== this.bootVersion) return
|
||||||
|
if (results.some((res) => res.code !== 0)) throw new Error('筛选条件加载失败,请重试')
|
||||||
|
this.$set(this, 'projects', results[0].data || [])
|
||||||
|
this.$set(this, 'unions', results[1].data || [])
|
||||||
|
this.$set(this.filters, 'unionId', branchOnly ? userUnion.id : '')
|
||||||
|
this.$set(this.filters, 'projectId', this.yearProjects.length ? this.yearProjects[0].id : '')
|
||||||
|
this.loadStatistics()
|
||||||
|
}).catch((error) => {
|
||||||
|
if (version === this.bootVersion) this.$set(this, 'bootError', error.message || '筛选条件加载失败,请重试')
|
||||||
|
}).finally(() => {
|
||||||
|
if (version === this.bootVersion) this.$set(this, 'bootLoading', false)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
retryStatistics() { if (this.bootError) this.init(); else this.loadStatistics() },
|
||||||
|
// 筛选使用独立草稿;取消、遮罩和返回键只关闭弹框,不改变查询条件。
|
||||||
|
openFilter(type) {
|
||||||
|
if (this.showFilter || this.showDetail || this.bootLoading || this.bootError) return
|
||||||
|
this.$set(this, 'filterType', type)
|
||||||
|
this.$set(this, 'draftValue', this.filters[type])
|
||||||
|
this.$set(this, 'showFilter', true)
|
||||||
|
h5PopupHistory.open(this.popupOwner + '-filter', this.popupOwner, () => { this.$set(this, 'showFilter', false) })
|
||||||
|
},
|
||||||
|
chooseDraft(value) { this.$set(this, 'draftValue', value) },
|
||||||
|
closeFilter() { h5PopupHistory.close(this.popupOwner + '-filter') },
|
||||||
|
confirmFilter() {
|
||||||
|
if (!this.filterOptions.some((item) => item.value === this.draftValue)) { this.closeFilter(); return }
|
||||||
|
if (!h5PopupHistory.close(this.popupOwner + '-filter')) return
|
||||||
|
if (this.filters[this.filterType] === this.draftValue) return
|
||||||
|
this.$set(this.filters, this.filterType, this.draftValue)
|
||||||
|
// 年份变化后只选该年份的第一个项目;普通项目切换绝不重新覆盖为默认值。
|
||||||
|
if (this.filterType === 'year') this.$set(this.filters, 'projectId', this.yearProjects.length ? this.yearProjects[0].id : '')
|
||||||
|
this.loadStatistics()
|
||||||
|
},
|
||||||
|
// 参数 projectId 必填、unionId 可选;method 空时省略,false 必须保留为个人选择。
|
||||||
|
statisticsParams() {
|
||||||
|
const params = {projectId: this.filters.projectId, unionId: this.filters.unionId}
|
||||||
|
if (this.filters.method !== '') params.isSelectByAdmin = this.filters.method
|
||||||
|
return params
|
||||||
|
},
|
||||||
|
loadStatistics() {
|
||||||
|
const version = this.tableVersion + 1
|
||||||
|
this.$set(this, 'tableVersion', version)
|
||||||
|
this.$set(this, 'tableData', [])
|
||||||
|
this.$set(this, 'optionColumns', [])
|
||||||
|
this.$set(this, 'tableError', '')
|
||||||
|
if (this.$refs.contentScroll) this.$refs.contentScroll.scrollTop = 0
|
||||||
|
if (!this.filters.projectId) { this.$set(this, 'tableLoading', false); return }
|
||||||
|
this.$set(this, 'tableLoading', true)
|
||||||
|
this.$axios.post('/platform/h5/welfare/statistics/pageData', this.statisticsParams()).then((res) => {
|
||||||
|
if (version !== this.tableVersion) return
|
||||||
|
if (res.code !== 0) throw new Error(res.msg || '统计加载失败')
|
||||||
|
this.$set(this, 'tableData', res.data.tableList || [])
|
||||||
|
const fixed = ['name', 'teacherSum', 'selectedNum', 'unSelectedNum']
|
||||||
|
this.$set(this, 'optionColumns', (res.data.tableColumn || []).filter((column) => fixed.indexOf(column.prop) === -1))
|
||||||
|
}).catch((error) => {
|
||||||
|
if (version === this.tableVersion) this.$set(this, 'tableError', error.message || '统计加载失败,请重试')
|
||||||
|
}).finally(() => {
|
||||||
|
if (version === this.tableVersion) this.$set(this, 'tableLoading', false)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
openDetail(row, tab) {
|
||||||
|
if (this.showDetail || this.showFilter) return
|
||||||
|
this.$set(this, 'detailUnion', Object.assign({}, row))
|
||||||
|
this.$set(this, 'detailTab', tab)
|
||||||
|
this.$set(this, 'searchInput', '')
|
||||||
|
this.$set(this, 'searchKeyword', '')
|
||||||
|
this.$set(this, 'showDetail', true)
|
||||||
|
h5PopupHistory.open(this.popupOwner + '-detail', this.popupOwner, () => {
|
||||||
|
this.$set(this, 'showDetail', false)
|
||||||
|
this.$set(this, 'detailVersion', this.detailVersion + 1)
|
||||||
|
this.$set(this, 'detailLoading', false)
|
||||||
|
})
|
||||||
|
this.resetDetail()
|
||||||
|
},
|
||||||
|
closeDetail() { h5PopupHistory.close(this.popupOwner + '-detail') },
|
||||||
|
changeDetailTab(tab) {
|
||||||
|
if (tab === this.detailTab) return
|
||||||
|
this.$set(this, 'detailTab', tab)
|
||||||
|
this.resetDetail()
|
||||||
|
},
|
||||||
|
setSearchInput(value) { this.$set(this, 'searchInput', value) },
|
||||||
|
searchDetail() {
|
||||||
|
this.$set(this, 'searchKeyword', this.searchInput.trim())
|
||||||
|
this.resetDetail()
|
||||||
|
},
|
||||||
|
clearSearch() { this.$set(this, 'searchInput', ''); this.searchDetail() },
|
||||||
|
resetDetail() {
|
||||||
|
// 切换工会、状态或查询词时使旧请求失效,并从第一页重新读取。
|
||||||
|
this.$set(this, 'detailVersion', this.detailVersion + 1)
|
||||||
|
this.$set(this, 'detailRows', [])
|
||||||
|
this.$set(this, 'detailTotal', 0)
|
||||||
|
this.$set(this, 'detailPage', 1)
|
||||||
|
this.$set(this, 'detailFinished', false)
|
||||||
|
this.$set(this, 'detailLoading', false)
|
||||||
|
this.$set(this, 'detailError', '')
|
||||||
|
if (this.$refs.personScroll) this.$refs.personScroll.scrollTop = 0
|
||||||
|
this.loadDetail()
|
||||||
|
},
|
||||||
|
checkDetailList() { if (this.showDetail && this.$refs.personList) this.$refs.personList.check() },
|
||||||
|
// 两个接口均返回 Pagination 的 list/totalCount;未选接口不传选择方式。
|
||||||
|
loadDetail() {
|
||||||
|
if (!this.showDetail || this.detailLoading || this.detailFinished) return
|
||||||
|
const version = this.detailVersion
|
||||||
|
const params = {projectId: this.filters.projectId, unionId: this.detailUnion.id, pageNumber: this.detailPage, pageSize: 50, searchKeyword: this.searchKeyword}
|
||||||
|
const selected = this.detailTab === 'selected'
|
||||||
|
if (selected && this.filters.method !== '') params.isSelectByAdmin = this.filters.method
|
||||||
|
this.$set(this, 'detailLoading', true)
|
||||||
|
this.$set(this, 'detailError', '')
|
||||||
|
this.$axios.post('/platform/h5/welfare/statistics/' + (selected ? 'selectedUnionUserPageData' : 'unSelectedUnionUserPageData'), params).then((res) => {
|
||||||
|
if (version !== this.detailVersion) return
|
||||||
|
if (res.code !== 0) throw new Error(res.msg || '人员加载失败')
|
||||||
|
const rows = res.data.list || []
|
||||||
|
this.$set(this, 'detailRows', this.detailRows.concat(rows))
|
||||||
|
this.$set(this, 'detailTotal', Number(res.data.totalCount) || 0)
|
||||||
|
this.$set(this, 'detailFinished', rows.length === 0 || this.detailRows.length >= this.detailTotal)
|
||||||
|
this.$set(this, 'detailPage', this.detailPage + 1)
|
||||||
|
}).catch((error) => {
|
||||||
|
if (version === this.detailVersion) this.$set(this, 'detailError', error.message || '人员加载失败')
|
||||||
|
}).finally(() => {
|
||||||
|
if (version !== this.detailVersion) return
|
||||||
|
this.$set(this, 'detailLoading', false)
|
||||||
|
// 首屏不足一屏时继续检查;失败等待手动重试,避免自动请求循环。
|
||||||
|
if (!this.detailError) this.$nextTick(() => { this.checkDetailList() })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<!--#
|
||||||
|
}
|
||||||
|
#-->
|
||||||
Reference in New Issue
Block a user