Compare commits
23
Commits
486934f3b6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51b1a6cf27 | ||
|
|
743e6c5428 | ||
|
|
09e955fb38 | ||
|
|
7f578e1f9e | ||
|
|
d8f6298dd8 | ||
|
|
6b0586b638 | ||
|
|
15aef647d1 | ||
|
|
f3021514fd | ||
|
|
4856cd5ddb | ||
|
|
a09c6ae2c9 | ||
|
|
d197c78b9c | ||
|
|
dbca87f09c | ||
|
|
38b1a09e11 | ||
|
|
5cecb3b185 | ||
|
|
fc727c690a | ||
|
|
063e22491a | ||
|
|
48ebcc148d | ||
|
|
50f82e05e5 | ||
|
|
f700d0c6fb | ||
|
|
94fa74af53 | ||
|
|
d717330657 | ||
|
|
3c3e64f2be | ||
|
|
a18f77cd68 |
@@ -3,6 +3,7 @@ package com.budwk.app.base.utils;
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import com.aspose.cells.PdfSaveOptions;
|
||||
import com.aspose.cells.Workbook;
|
||||
import com.aspose.slides.Presentation;
|
||||
import com.aspose.words.Document;
|
||||
import com.aspose.words.SaveFormat;
|
||||
import com.budwk.app.base.exception.BaseException;
|
||||
@@ -30,11 +31,34 @@ public class OfficePlusUtil {
|
||||
wordConvertPdf(sourcePath, targetPath);
|
||||
} else if (suffix.equals("xls") || suffix.equals("xlsx")) {
|
||||
excelConvertPdf(sourcePath, targetPath);
|
||||
} else if (suffix.equalsIgnoreCase("ppt") || suffix.equalsIgnoreCase("pptx")) {
|
||||
presentationConvertPdf(sourcePath, targetPath);
|
||||
} else {
|
||||
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
|
||||
*
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.budwk.app.sys.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckLogin;
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.budwk.app.base.annotation.SLog;
|
||||
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.services.SysConfigService;
|
||||
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.ApiOperation;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.nutz.dao.Cnd;
|
||||
import org.nutz.ioc.loader.annotation.Inject;
|
||||
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.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.util.List;
|
||||
|
||||
@IocBean
|
||||
@At("/platform/sys/file")
|
||||
@Ok("json:full")
|
||||
@Api(tags = "文件管理")
|
||||
@Slf4j
|
||||
public class SysFileController {
|
||||
|
||||
@Inject
|
||||
@@ -37,6 +44,9 @@ public class SysFileController {
|
||||
@Inject
|
||||
private SysConfigService sysConfigService;
|
||||
|
||||
@Inject
|
||||
private LearningFileAccessService learningFileAccessService;
|
||||
|
||||
@At("")
|
||||
@Ok("beetl:/platform/sys/file/index.html")
|
||||
@SaCheckPermission("sys.manager.file")
|
||||
@@ -95,6 +105,7 @@ public class SysFileController {
|
||||
@Ok("void")
|
||||
@SaCheckLogin
|
||||
public void download(String id, HttpServletRequest request, HttpServletResponse response) throws IOException {
|
||||
if (!learningFileAccessService.check(id, true, response)) return;
|
||||
sysFileService.download(id, request, response);
|
||||
}
|
||||
|
||||
@@ -103,6 +114,7 @@ public class SysFileController {
|
||||
@SaCheckLogin
|
||||
@ApiOperation("文件管理-转换PDF")
|
||||
public void convertPDF(String id, HttpServletRequest request, HttpServletResponse response) throws IOException {
|
||||
if (!learningFileAccessService.check(id, true, response)) return;
|
||||
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")
|
||||
public Result listHomeActivity() {
|
||||
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();
|
||||
List<Sys_home_activity> allowActivityList = new ArrayList<>();
|
||||
// 今天的时间
|
||||
|
||||
@@ -25,6 +25,8 @@ public interface SysFileService extends BaseService<Sys_file> {
|
||||
*/
|
||||
String uploadReturnUrl(String engine, TempFile file);
|
||||
|
||||
long sizeBytes(Sys_file file);
|
||||
|
||||
/**
|
||||
* 分页
|
||||
*/
|
||||
|
||||
@@ -97,8 +97,8 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
|
||||
Map<String, Date> doctoralData = pullPostDoctoral();
|
||||
|
||||
Date nowDate = new Date();
|
||||
String appId = "1926887238437818370";
|
||||
String secret = "32df31fcf4fc43f9a647ee1f47134f36";
|
||||
String appId = "XXXXXXXXXXXXXXXXXXXXX";
|
||||
String secret = "XXXXXXXXXXXXXXXXXXXX";
|
||||
|
||||
List<JSONObject> rawDataList = new ArrayList<>();
|
||||
int skip = 0;
|
||||
@@ -280,8 +280,8 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
|
||||
|
||||
@Override
|
||||
public Map<String, Date> pullPostDoctoral() {
|
||||
String appId = "1926887238437818370";
|
||||
String secret = "32df31fcf4fc43f9a647ee1f47134f36";
|
||||
String appId = "XXXXXXXXXXXXXXXXXXXXXX";
|
||||
String secret = "XXXXXXXXXXXXXXXXXXXXXX";
|
||||
|
||||
List<JSONObject> allUsers = new ArrayList<>();
|
||||
int skip = 0;
|
||||
|
||||
@@ -4,6 +4,7 @@ import cn.hutool.core.convert.Convert;
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import cn.hutool.core.img.ImgUtil;
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.core.io.IoUtil;
|
||||
import cn.hutool.core.util.CharsetUtil;
|
||||
import cn.hutool.core.util.NumberUtil;
|
||||
@@ -65,6 +66,26 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
|
||||
return this.storageFile(engine, file, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long sizeBytes(Sys_file file) {
|
||||
JSONObject metadata = file.getExtJson();
|
||||
if (metadata != null && metadata.getLong("sizeBytes") != null) {
|
||||
return metadata.getLong("sizeBytes");
|
||||
}
|
||||
if (SysFileEngineTypeEnum.LOCAL.getValue().equals(file.getEngine())) {
|
||||
File storedFile = FileUtil.file(file.getStoragePath());
|
||||
if (!storedFile.isFile()) {
|
||||
throw new BaseException("文件不存在");
|
||||
}
|
||||
return storedFile.length();
|
||||
}
|
||||
if (SysFileEngineTypeEnum.MINIO.getValue().equals(file.getEngine())) {
|
||||
String objectKey = StrUtil.removePrefix(file.getStoragePath(), "/");
|
||||
return SysFileMinIoUtil.getFileSize(file.getBucket(), objectKey);
|
||||
}
|
||||
throw new BaseException("不支持的文件引擎");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Pagination page(Sys_file file) {
|
||||
return null;
|
||||
@@ -147,7 +168,11 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
|
||||
|
||||
@Override
|
||||
public Sys_file detail(String id) {
|
||||
return null;
|
||||
Sys_file file = this.fetch(id);
|
||||
if (file == null) {
|
||||
throw new BaseException("文件记录不存在");
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -246,6 +271,7 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
|
||||
sys_file.setSizeKb(Convert.toStr(NumberUtil.div(new BigDecimal(file.getSize()), BigDecimal.valueOf(1024))
|
||||
.setScale(0, RoundingMode.HALF_UP)));
|
||||
sys_file.setSizeInfo(FileUtil.readableFileSize(file.getSize()));
|
||||
sys_file.setExtJson(new JSONObject().set("sizeBytes", file.getSize()));
|
||||
sys_file.setObjName(ObjectUtil.isNotEmpty(sys_file.getSuffix()) ? fileId + StrUtil.DOT + sys_file.getSuffix() : null);
|
||||
// 如果是图片,则压缩生成缩略图
|
||||
if (ObjectUtil.isNotEmpty(suffix)) {
|
||||
|
||||
@@ -103,6 +103,15 @@ public class SysFileMinIoUtil {
|
||||
return client;
|
||||
}
|
||||
|
||||
public static long getFileSize(String bucketName, String key) {
|
||||
try {
|
||||
initClient();
|
||||
return client.statObject(StatObjectArgs.builder().bucket(bucketName).object(key).build()).size();
|
||||
} catch (Exception e) {
|
||||
throw new BaseException("文件大小读取失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询存储桶是否存在
|
||||
* 例如:传入参数examplebucket-1250000000,返回true代表存在此桶
|
||||
|
||||
+1
-5
@@ -112,11 +112,7 @@ public class ActivityCultureUserStatisticsController {
|
||||
if (Strings.isNotBlank(page.getSearchName()) && Strings.isNotBlank(page.getSearchKeyword())) {
|
||||
cnd.where().andLike(page.getSearchName(), page.getSearchKeyword());
|
||||
}
|
||||
if (StrUtil.isNotBlank(page.getPageOrderName()) && StrUtil.isNotBlank(page.getPageOrderBy())) {
|
||||
cnd.orderBy(page.getPageOrderName(), page.getPageOrderBy().equalsIgnoreCase("ascending") ? "ASC" : "DESC");
|
||||
} else {
|
||||
cnd.desc("atp.applyDateTime");
|
||||
}
|
||||
activityCultureApplyUserService.applyUserStatisticsSort(cnd, page);
|
||||
sql.setCondition(cnd);
|
||||
Pagination pagination = activityCultureApplyUserService.listPageMap(page.getPageNumber(), page.getPageSize(), sql);
|
||||
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.service.BaseService;
|
||||
import com.budwk.app.zhgh.activity.culture.models.ActivityTissuePerson;
|
||||
import org.nutz.dao.Cnd;
|
||||
import org.nutz.lang.util.NutMap;
|
||||
|
||||
import java.util.List;
|
||||
@@ -19,6 +20,14 @@ public interface ActivityCultureApplyUserService extends BaseService<ActivityTis
|
||||
boolean isEnrolled,
|
||||
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;
|
||||
}
|
||||
|
||||
@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
|
||||
public void signUp(String activityId, List<String> personIds, JSONObject dynamicFormParam) {
|
||||
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
|
||||
@ApiOperation("活动报名")
|
||||
@SaCheckPermission(value = {"family.apply", "h5.family.apply"}, mode = SaMode.OR)
|
||||
@@ -529,37 +535,7 @@ public class FamilyActivityApplyController {
|
||||
public Result doSignUp(FamilyUser familyUser) {
|
||||
try {
|
||||
lock.lock();
|
||||
FamilyActivity activity = dao.fetch(FamilyActivity.class, familyUser.getActivityId());
|
||||
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,"该活动您所在的分工会名额不足");
|
||||
}
|
||||
// 家属、名额和时间冲突由service在数据库锁及事务内统一校验,避免普通报名与快速报名规则分叉。
|
||||
FamilyUser finishUser = familyActivityService.doSignUp(familyUser);
|
||||
if(finishUser.getState() == 2) {
|
||||
return Result.success("正式报名已满,您已列为替补,如有老师放弃报名,将会按先后顺序依次替补。请您自行关注后续结果。谢谢!");
|
||||
|
||||
+9
@@ -13,6 +13,8 @@ import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
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.Dao;
|
||||
import org.nutz.dao.Sqls;
|
||||
@@ -144,11 +146,18 @@ public class FamilyActivityController {
|
||||
return Result.success().addData(dataMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存或发布活动配置,业务校验由service执行。
|
||||
* @param activity 活动及子活动列表;familyCountScope为1按类型、2按活动,timeConflictCheck为重叠校验开关,
|
||||
* onlyKey为各类型共有且必填的字段编码,familyMaxCount为活动累计上限
|
||||
* @return Result JSON,code为0表示保存成功,配置不完整时返回具体业务错误
|
||||
*/
|
||||
@At
|
||||
@Ok("json:full")
|
||||
@ApiOperation("亲子活动新增/修改")
|
||||
@SaCheckPermission("family.manage")
|
||||
@SLog(tag = "亲子活动-活动管理", msg = "新增/修改活动")
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public Result doHandle(FamilyActivity activity) {
|
||||
if (StrUtil.isBlank(activity.getId())) {
|
||||
familyActivityManageService.add(activity, null);
|
||||
|
||||
+28
-42
@@ -1,8 +1,10 @@
|
||||
package com.budwk.app.zhgh.activity.family.controller.manage;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
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.param.PageForm;
|
||||
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.Param;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -118,59 +119,44 @@ public class FamilyAdjustController {
|
||||
return Result.success(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param activityId 当前活动ID
|
||||
* @param oldCourseId 人员原报名子活动ID
|
||||
* @param newCourseId 同活动内的目标子活动ID
|
||||
* @param userId 被调整教职工ID,按该人员而非管理员校验家属和时间规则
|
||||
* @return Result JSON;code为0表示调整成功,失败时msg说明原因且保留原报名
|
||||
*/
|
||||
@At
|
||||
@ApiOperation("人员调整")
|
||||
@SaCheckPermission("family.adjust")
|
||||
@SLog(tag = "亲子活动-人员调整", msg = "人员调整")
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public Result adjust(String activityId, String oldCourseId, String newCourseId, String userId) {
|
||||
|
||||
Cnd oldCnd = Cnd.where("activityId", "=", activityId)
|
||||
.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);
|
||||
try {
|
||||
familyActivityManageService.adjustSignUp(activityId, oldCourseId, newCourseId, userId);
|
||||
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
|
||||
@ApiOperation("删除报名人员")
|
||||
@SaCheckPermission("family.adjust")
|
||||
@SLog(tag = "亲子活动-人员调整", msg = "删除报名人员")
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public Result deleteSignUser(String activityId, String courseId, String 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);
|
||||
try {
|
||||
familyActivityManageService.deleteSignUser(activityId, courseId, userId);
|
||||
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)
|
||||
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
|
||||
@Comment("判断家属数量的唯一标识")
|
||||
@ColDefine(type = ColType.VARCHAR, width = 50)
|
||||
|
||||
+22
-2
@@ -76,6 +76,25 @@ public interface FamilyActivityService extends BaseService<FamilyActivity> {
|
||||
*/
|
||||
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);
|
||||
|
||||
/**
|
||||
* 校验当前用户是否可以修改指定课程的报名信息。
|
||||
* 打开编辑表单前校验当前用户报名归属、报名时间和人员范围。
|
||||
* 家属、人数和时间冲突在保存新数据时重新校验,允许历史冲突记录进入表单整改。
|
||||
*
|
||||
* @param courseId 课程ID;当前用户必须已经报名该课程
|
||||
* @return 无返回值;校验不通过时抛出包含具体原因的业务异常
|
||||
@@ -166,7 +186,7 @@ public interface FamilyActivityService extends BaseService<FamilyActivity> {
|
||||
boolean isSignFull(FamilyCourse course, Integer currentFamilyNumber);
|
||||
|
||||
/**
|
||||
* 校验本次提交的家属数量、唯一标识及分场冲突。
|
||||
* 校验本次提交的类型家属上限、活动累计家属上限、唯一标识及分场冲突。
|
||||
* 当参数包含报名记录ID时,会排除该记录后再校验,用于修改报名。
|
||||
*
|
||||
* @param user 待校验的报名信息
|
||||
|
||||
+385
-29
@@ -48,10 +48,74 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
||||
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
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public void add(FamilyActivity activity, FamilyCourse course) {
|
||||
|
||||
validateActivityRuleConfig(activity, null);
|
||||
dao().insert(activity);
|
||||
|
||||
//插入类型限制
|
||||
@@ -81,7 +145,7 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public void edit(FamilyActivity activity) {
|
||||
|
||||
validateActivityRuleConfig(activity, dao().fetch(FamilyActivity.class, activity.getId()));
|
||||
//修改活动
|
||||
update(activity);
|
||||
|
||||
@@ -155,6 +219,8 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
||||
}
|
||||
}
|
||||
|
||||
/** 将页面的课程日期与时分合成为实际上课时间,保存到同一事务的课程时段表。 */
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
private void setCourseTimeAndInsert(FamilyCourse course) {
|
||||
course.getCourseTimeList().forEach(courseTime -> {
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
@@ -254,7 +320,13 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public FamilyUser doSignUp(FamilyUser familyUser) throws Exception {
|
||||
String userId = SecurityUtil.getUserId();
|
||||
|
||||
if (familyUser == null) {
|
||||
throw new BaseException("报名信息不能为空");
|
||||
}
|
||||
// 先锁教职工,再锁课程;跨课程报名也必须串行校验同一活动的家属总数和时间。
|
||||
lockSignUpUser(userId);
|
||||
familyUser.setId(null);
|
||||
familyUser.setUserId(userId);
|
||||
// 对课程行加数据库锁,使名额校验和正式写入在多节点部署时仍按顺序执行。
|
||||
lockCourseForSignUp(familyUser.getCourseId());
|
||||
//查询课程
|
||||
@@ -272,6 +344,10 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
||||
FamilyActivity activity = dao().fetch(FamilyActivity.class, course.getActivityId());
|
||||
validateSignUpTimeAndScope(activity, userId, false);
|
||||
familyUser.setActivityId(course.getActivityId());
|
||||
if (!Boolean.TRUE.equals(course.getCourseIsLimitApply())) {
|
||||
// 非独立时段课程必须参加全部时段,忽略客户端多传的单个时段ID。
|
||||
familyUser.setActivityCourseId(null);
|
||||
}
|
||||
if (getSignUpInfo(course.getId(), userId) != null) {
|
||||
throw new BaseException("抱歉,您已经报名");
|
||||
}
|
||||
@@ -298,6 +374,7 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
||||
: "您选择的类型已达上限,不能再报该类型的了");
|
||||
}
|
||||
validateCourseTime(course, familyUser.getActivityCourseId(), null);
|
||||
validateTimeConflict(familyUser, course, activity, userId);
|
||||
//如果这个课程的预留名额方式为报名人数不变
|
||||
if (course.getReserveMode() == 2) {
|
||||
//如果当前报名+已报小于这个课程限制人数
|
||||
@@ -345,6 +422,7 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
||||
throw new BaseException("取消报名参数不完整");
|
||||
}
|
||||
|
||||
lockSignUpUser(SecurityUtil.getUserId());
|
||||
// 与正式报名共用课程行锁,保证删除、重新统计和候补转正在同一串行区间内完成。
|
||||
lockCourseForSignUp(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) {
|
||||
FamilyType type = dao().fetch(FamilyType.class, course.getCourseType());
|
||||
if (type == null) {
|
||||
@@ -459,6 +538,78 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
||||
&& 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
|
||||
public FamilyApplyPrefill getPrefill(String courseId, String userId) {
|
||||
if (StrUtil.isBlank(courseId) || StrUtil.isBlank(userId)) {
|
||||
@@ -581,7 +732,13 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
||||
if (currentSignUp == null) {
|
||||
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
|
||||
@@ -592,11 +749,31 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
||||
}
|
||||
|
||||
String currentUserId = SecurityUtil.getUserId();
|
||||
lockSignUpUser(currentUserId);
|
||||
lockCourseForSignUp(familyUser.getCourseId());
|
||||
FamilyUser currentSignUp = getSignUpInfo(familyUser.getCourseId(), currentUserId);
|
||||
if (currentSignUp == null || !Objects.equals(currentSignUp.getId(), familyUser.getId())) {
|
||||
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();
|
||||
currentSignUp.setMobile(familyUser.getMobile());
|
||||
@@ -604,22 +781,20 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
||||
currentSignUp.setActivityCourseId(familyUser.getActivityCourseId());
|
||||
dao().update(currentSignUp);
|
||||
syncUpdatedUserCourse(currentSignUp, oldActivityCourseId);
|
||||
// 保存减员及其时段记录后,在同一课程锁内按最新占用人数执行递补。
|
||||
if (releasedFormalSlots && Objects.equals(course.getReserveMode(), 2) && isWithinSignUpTime(activity)) {
|
||||
promoteWaitingUsers(course);
|
||||
}
|
||||
return currentSignUp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改按钮预校验与最终保存共用本方法,确保两处业务规则不会产生偏差。
|
||||
* 保存修改前校验家属信息、报名名额与时段,人数及冲突校验须排除本人的原记录。
|
||||
*/
|
||||
private void validateUpdateSignUpData(FamilyUser familyUser, FamilyUser currentSignUp) {
|
||||
FamilyCourse course = dao().fetch(FamilyCourse.class, familyUser.getCourseId());
|
||||
if (course == null || !Objects.equals(course.getActivityId(), currentSignUp.getActivityId())) {
|
||||
throw new BaseException("报名课程不存在或已变更");
|
||||
}
|
||||
FamilyActivity activity = dao().fetch(FamilyActivity.class, course.getActivityId());
|
||||
validateSignUpTimeAndScope(activity, SecurityUtil.getUserId(), true);
|
||||
FamilyType type = dao().fetch(FamilyType.class, course.getCourseType());
|
||||
if (type == null) {
|
||||
throw new BaseException("报名类型不存在或已变更");
|
||||
private void validateUpdateSignUpData(FamilyUser familyUser, FamilyUser currentSignUp,
|
||||
FamilyCourse course, FamilyActivity activity, FamilyType type) {
|
||||
if (!Boolean.TRUE.equals(course.getCourseIsLimitApply())) {
|
||||
familyUser.setActivityCourseId(null);
|
||||
}
|
||||
|
||||
if (Lang.isEmpty(familyUser.getMobileColumnsValue())) {
|
||||
@@ -651,6 +826,7 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
||||
: "您选择的类型已达上限,不能再报该类型的了");
|
||||
}
|
||||
validateCourseTime(course, familyUser.getActivityCourseId(), currentSignUp);
|
||||
validateTimeConflict(familyUser, course, activity, SecurityUtil.getUserId());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -701,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("报名人员不存在或已删除");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预填新增、修改和删除共用时间及人员范围校验。
|
||||
* 预填只允许发生在报名开始时间之前,到达开始时间后数据立即冻结。
|
||||
@@ -775,6 +967,14 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
||||
for (FamilyMobileSignColumn config : columnConfigs) {
|
||||
NutMap submittedColumn = submittedValueMap.get(config.getColumnCode());
|
||||
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);
|
||||
|
||||
NutMap normalizedColumn = NutMap.NEW();
|
||||
@@ -986,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) {
|
||||
if (Objects.equals(oldActivityCourseId, currentSignUp.getActivityCourseId())) {
|
||||
return;
|
||||
}
|
||||
rebuildUserCourseTimes(currentSignUp);
|
||||
}
|
||||
|
||||
/** 修改所选时段或后台调班后,按本次报名实际参加的时段重建课程记录。 */
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
private void rebuildUserCourseTimes(FamilyUser currentSignUp) {
|
||||
dao().clear(FamilyUserCourse.class, Cnd.where(FamilyUserCourse::getActivityId, "=", currentSignUp.getActivityId())
|
||||
.and(FamilyUserCourse::getCourseId, "=", currentSignUp.getCourseId())
|
||||
.and(FamilyUserCourse::getUserId, "=", currentSignUp.getUserId()));
|
||||
@@ -1023,6 +1343,7 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
||||
|
||||
@Async
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public void asyncInsertUserCourse(String activityId, String courseId, String userId) {
|
||||
log.info("异步插入{}的上课信息,课程ID为{},活动ID为{}", userId, courseId, activityId);
|
||||
insertUserCourse(activityId, courseId, userId);
|
||||
@@ -1031,6 +1352,7 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
||||
/**
|
||||
* 同步写入报名人员的全部课程时段,用于保证正式报名主记录与时段记录处于同一事务。
|
||||
*/
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
private void insertUserCourse(String activityId, String courseId, String userId) {
|
||||
List<FamilyActivityCourse> courseList = dao().query(FamilyActivityCourse.class, Cnd.where("courseId", "=", courseId));
|
||||
List<FamilyUserCourse> list = new ArrayList<>();
|
||||
@@ -1112,6 +1434,11 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
||||
|
||||
@Override
|
||||
public Map<Boolean, String> validFamilyCount(FamilyUser user) {
|
||||
return validFamilyCount(user, SecurityUtil.getUserId());
|
||||
}
|
||||
|
||||
/** 按实际报名用户校验家属集合,后台调班不能使用当前管理员身份查询报名。 */
|
||||
private Map<Boolean, String> validFamilyCount(FamilyUser user, String userId) {
|
||||
String activityId = user.getActivityId();
|
||||
FamilyActivity activity = dao().fetch(FamilyActivity.class, activityId);
|
||||
if (activity == null) {
|
||||
@@ -1137,7 +1464,11 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
||||
|
||||
// 查询本人本活动的其他报名,修改报名时排除当前记录,后续冲突校验仍覆盖全部子活动类型。
|
||||
Cnd signUpCnd = Cnd.where(FamilyUser::getActivityId, "=", activityId)
|
||||
.and(FamilyUser::getUserId, "=", SecurityUtil.getUserId());
|
||||
.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());
|
||||
}
|
||||
@@ -1146,6 +1477,31 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
||||
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()
|
||||
.filter(item -> Objects.equals(currentCourse.getCourseType(), courseMap.get(item.getCourseId())))
|
||||
@@ -1157,11 +1513,7 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
||||
allMobileColumnsValue.addAll(user.getMobileColumnsValue());
|
||||
|
||||
long count = allMobileColumnsValue.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.flatMap(List::stream)
|
||||
.filter(Objects::nonNull)
|
||||
.filter(nutMap -> Objects.equals(activity.getOnlyKey(), nutMap.getString("columnCode")))
|
||||
.map(nutMap -> nutMap.getString("columnValue"))
|
||||
.map(family -> getFamilyIdentity(family, activity))
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.count();
|
||||
@@ -1170,8 +1522,9 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
||||
return Map.of(false, activity.getKeyWord() + "人数最多为" + currentType.getFamilyMaxCount());
|
||||
}
|
||||
|
||||
List<FamilyType> typeList = dao().query(FamilyType.class, Cnd.NEW());
|
||||
Map<String, String> typeMap = typeList.stream().collect(Collectors.toMap(FamilyType::getId, FamilyType::getTypeName));
|
||||
// 冲突提示显示历史报名对应的具体子活动名称,不使用类型名称或本次尝试报名的名称。
|
||||
Map<String, String> courseNameMap = courseList.stream().collect(Collectors.toMap(FamilyCourse::getId,
|
||||
course -> StrUtil.isBlank(course.getCourseName()) ? "原子活动" : course.getCourseName()));
|
||||
|
||||
List<List<NutMap>> currentValue = user.getMobileColumnsValue();
|
||||
|
||||
@@ -1190,7 +1543,7 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
||||
continue;
|
||||
}
|
||||
// 家属匹配唯一标识的值
|
||||
String onlyKeyValue = nutMap.getString("columnValue");
|
||||
String onlyKeyValue = getFamilyIdentity(listMap, activity);
|
||||
if(Objects.equals(tempValue, onlyKeyValue)) {
|
||||
return Map.of(false, activity.getKeyWord() + "不能重复报同一分场");
|
||||
}
|
||||
@@ -1212,18 +1565,21 @@ public class FamilyActivityServiceImpl extends BaseServiceImpl<FamilyActivity> i
|
||||
if(hisNutMap == null) {
|
||||
continue;
|
||||
}
|
||||
String hisKeyValue = hisNutMap.getString("columnValue");
|
||||
String hisKeyValue = getFamilyIdentity(hisValue, activity);
|
||||
// 判断是不是同一个孩子
|
||||
if(Objects.equals(onlyKeyValue, hisKeyValue)) {
|
||||
oneFamilyCount ++;
|
||||
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())) {
|
||||
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())) {
|
||||
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.ioc.loader.annotation.IocBean;
|
||||
import org.nutz.lang.Lang;
|
||||
import org.nutz.lang.Strings;
|
||||
import org.nutz.lang.util.NutMap;
|
||||
|
||||
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_black_list tsubl on tsubl.userId = tsuu.userId
|
||||
$condition
|
||||
ORDER BY FIELD( tsuu.state, 1, 3, 2, 4 )
|
||||
""");
|
||||
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();
|
||||
varCnd.where().setTop(false);
|
||||
varCnd.where().andEX("activityId", "=", activityId);
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package com.budwk.app.zhgh.activity.workscollection.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.dev33.satoken.annotation.SaMode;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.budwk.app.base.result.Result;
|
||||
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
|
||||
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.dao.Chain;
|
||||
import org.nutz.dao.Cnd;
|
||||
import org.nutz.ioc.aop.Aop;
|
||||
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.POST;
|
||||
import org.nutz.mvc.annotation.Param;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
@IocBean
|
||||
@At("/platform/activity/worksCollection/addressManage")
|
||||
@Ok("json:full")
|
||||
public class ActivityWorksCollectionAddressController {
|
||||
|
||||
private static final String PERMISSION = "activity.workscollection.addressManage";
|
||||
|
||||
@Inject
|
||||
private WelfareAddressManageService addressService;
|
||||
|
||||
@At("")
|
||||
@Ok("beetl:/platform/zhgh/welfare/addressManage/index.html")
|
||||
@SaCheckPermission(PERMISSION)
|
||||
public void index() {
|
||||
}
|
||||
|
||||
@At
|
||||
@SaCheckPermission(value = {PERMISSION, "activity.workscollection.upload", "h5.activity.workscollection.upload"}, mode = SaMode.OR)
|
||||
public Result selectUserAddress() {
|
||||
return Result.success(addressService.query(currentUserAddresses().desc("isDefault")));
|
||||
}
|
||||
|
||||
@At
|
||||
@POST
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
@SaCheckPermission(value = {PERMISSION, "activity.workscollection.upload", "h5.activity.workscollection.upload"}, mode = SaMode.OR)
|
||||
public Result doSaveUserAddress(@Param("data") WelfareMemberAddress form) {
|
||||
if (form == null || StrUtil.hasBlank(form.getUserName(), form.getTel(), form.getProvince(),
|
||||
form.getCity(), form.getAreaCode(), form.getAddressDetail())) {
|
||||
return Result.error("请填写完整的收货地址");
|
||||
}
|
||||
|
||||
form.setTel(form.getTel().trim());
|
||||
if (!form.getTel().matches("1[3-9][0-9]{9}")) {
|
||||
return Result.error("请输入正确的手机号码");
|
||||
}
|
||||
|
||||
boolean editing = StrUtil.isNotBlank(form.getId());
|
||||
Cnd condition = currentUserAddresses().and("id", "=", form.getId());
|
||||
if (editing && addressService.count(condition) == 0) {
|
||||
return Result.error("收货地址不存在");
|
||||
}
|
||||
|
||||
if (form.isDefault()) {
|
||||
addressService.dao().update(WelfareMemberAddress.class, Chain.make("isDefault", false), currentUserAddresses());
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
Chain values = Chain.make("userName", form.getUserName())
|
||||
.add("tel", form.getTel())
|
||||
.add("province", form.getProvince())
|
||||
.add("city", form.getCity())
|
||||
.add("county", form.getCounty())
|
||||
.add("areaCode", form.getAreaCode())
|
||||
.add("addressDetail", form.getAddressDetail())
|
||||
.add("isDefault", form.isDefault())
|
||||
.add("updatedBy", SecurityUtil.getUserId())
|
||||
.add("updatedAt", System.currentTimeMillis());
|
||||
addressService.dao().update(WelfareMemberAddress.class, values, condition);
|
||||
} else {
|
||||
addressService.insert(new WelfareMemberAddress()
|
||||
.setUserId(SecurityUtil.getUserId())
|
||||
.setUserName(form.getUserName())
|
||||
.setTel(form.getTel())
|
||||
.setProvince(form.getProvince())
|
||||
.setCity(form.getCity())
|
||||
.setCounty(form.getCounty())
|
||||
.setAreaCode(form.getAreaCode())
|
||||
.setAddressDetail(form.getAddressDetail())
|
||||
.setDefault(form.isDefault()));
|
||||
}
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
@At
|
||||
@POST
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
@SaCheckPermission(value = {PERMISSION, "activity.workscollection.upload", "h5.activity.workscollection.upload"}, mode = SaMode.OR)
|
||||
public Result doDefault(@Param("id") String id) {
|
||||
Cnd condition = currentUserAddresses().and("id", "=", id);
|
||||
if (addressService.count(condition) == 0) {
|
||||
return Result.error("收货地址不存在");
|
||||
}
|
||||
addressService.dao().update(WelfareMemberAddress.class, Chain.make("isDefault", false), currentUserAddresses());
|
||||
addressService.dao().update(WelfareMemberAddress.class, Chain.make("isDefault", true), condition);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
@At
|
||||
@POST
|
||||
@SaCheckPermission(value = {PERMISSION, "h5.activity.workscollection.upload"}, mode = SaMode.OR)
|
||||
public Result deleteAddress(@Param("id") String id) {
|
||||
int deleted = addressService.dao().clear(WelfareMemberAddress.class, currentUserAddresses().and("id", "=", id));
|
||||
return deleted > 0 ? Result.success() : Result.error("收货地址不存在");
|
||||
}
|
||||
|
||||
@At
|
||||
@Ok("void")
|
||||
@SaCheckPermission(PERMISSION)
|
||||
public void exportAllAddress(HttpServletResponse response) {
|
||||
StpUtil.checkRole("SYSADMIN");
|
||||
addressService.exportAllAddress(response);
|
||||
}
|
||||
|
||||
private Cnd currentUserAddresses() {
|
||||
return Cnd.where("userId", "=", SecurityUtil.getUserId());
|
||||
}
|
||||
}
|
||||
+24
-2
@@ -1,6 +1,10 @@
|
||||
package com.budwk.app.zhgh.activity.workscollection.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import com.budwk.app.base.constant.RoleConstant;
|
||||
import com.budwk.app.web.commons.auth.utils.AuthUtil;
|
||||
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
|
||||
import com.budwk.app.base.result.Result;
|
||||
import com.budwk.app.zhgh.activity.workscollection.models.Activity_works_collection;
|
||||
import com.budwk.app.zhgh.activity.workscollection.models.Activity_works_collection_upload;
|
||||
@@ -63,18 +67,36 @@ public class ActivityWorksCollectionCommonController {
|
||||
up.*,
|
||||
co.NAME AS activityName,
|
||||
su.typeName AS subjectName,
|
||||
wo.worksTypeName AS worksName
|
||||
wo.worksTypeName AS worksName,
|
||||
CASE WHEN up.userId = @viewerId OR @readAll = 1
|
||||
OR (@readUnion = 1 AND up.unionId = @viewerUnionId)
|
||||
THEN address.shippingAddress END AS shippingAddress
|
||||
FROM
|
||||
`activity_works_collection_upload` up
|
||||
LEFT JOIN activity_works_collection co ON up.activityId = co.id
|
||||
LEFT JOIN activity_works_subjecttype su ON up.subjectId = su.id
|
||||
LEFT JOIN activity_works_workstype wo ON up.worksId = wo.id
|
||||
LEFT JOIN (
|
||||
SELECT userId,
|
||||
CONCAT_WS(' ', CONCAT_WS('', province, city, county), addressDetail, userName, tel) AS shippingAddress,
|
||||
ROW_NUMBER() OVER (PARTITION BY userId ORDER BY id) AS rn
|
||||
FROM welfare_member_address
|
||||
WHERE isDefault = 1
|
||||
) address ON address.userId = up.userId AND address.rn = 1
|
||||
$condition
|
||||
""");
|
||||
Cnd cnd = Cnd.NEW();
|
||||
cnd.and("up.id", "=", id);
|
||||
sql.setCondition(cnd);
|
||||
NutMap result = (NutMap) Daos.query(dao, sql.toString(), Sqls.callback.map());
|
||||
boolean canRead = StpUtil.hasPermission("activity.workscollection.read");
|
||||
boolean canReadAll = canRead && AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name());
|
||||
sql.setParam("viewerId", SecurityUtil.getUserId());
|
||||
sql.setParam("viewerUnionId", SecurityUtil.getUnionId());
|
||||
sql.setParam("readAll", canReadAll ? 1 : 0);
|
||||
sql.setParam("readUnion", canRead ? 1 : 0);
|
||||
sql.setCallback(Sqls.callback.map());
|
||||
dao.execute(sql);
|
||||
NutMap result = sql.getObject(NutMap.class);
|
||||
return Result.success(result);
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -80,6 +80,9 @@ public class ActivityWorksCollectionManageController {
|
||||
@At
|
||||
@SaCheckPermission("activity.workscollection.manage")
|
||||
public Result insert(@Param("data") @Valid Activity_works_collection worksCollection) {
|
||||
if (!hasValidFileSizeLimits(worksCollection)) {
|
||||
return Result.error("总文件大小必须大于或等于单个文件大小");
|
||||
}
|
||||
worksCollection.setIsSubmit(true);
|
||||
dao.insertWith(worksCollection, "subjectTypes");
|
||||
worksCollection.getSubjectTypes().forEach(item -> {
|
||||
@@ -91,6 +94,9 @@ public class ActivityWorksCollectionManageController {
|
||||
@At
|
||||
@SaCheckPermission("activity.workscollection.manage")
|
||||
public Result save(@Param("data") @Valid Activity_works_collection worksCollection) {
|
||||
if (!hasValidFileSizeLimits(worksCollection)) {
|
||||
return Result.error("总文件大小必须大于或等于单个文件大小");
|
||||
}
|
||||
worksCollection.setIsSubmit(false);
|
||||
dao.insertWith(worksCollection, "subjectTypes");
|
||||
worksCollection.getSubjectTypes().forEach(item -> {
|
||||
@@ -103,6 +109,9 @@ public class ActivityWorksCollectionManageController {
|
||||
@SaCheckPermission("activity.workscollection.manage")
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public Result update(@Param("data") @Valid Activity_works_collection worksCollection) {
|
||||
if (!hasValidFileSizeLimits(worksCollection)) {
|
||||
return Result.error("总文件大小必须大于或等于单个文件大小");
|
||||
}
|
||||
dao.update(worksCollection);
|
||||
dao.updateLinks(worksCollection, "subjectTypes");
|
||||
dao.insertLinks(worksCollection, "subjectTypes");
|
||||
@@ -123,6 +132,25 @@ public class ActivityWorksCollectionManageController {
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
private boolean hasValidFileSizeLimits(Activity_works_collection worksCollection) {
|
||||
if (worksCollection.getSubjectTypes() == null) {
|
||||
return true;
|
||||
}
|
||||
for (Activity_works_subjectType subjectType : worksCollection.getSubjectTypes()) {
|
||||
if (subjectType.getWorksTypes() == null) {
|
||||
continue;
|
||||
}
|
||||
for (Activity_works_worksType worksType : subjectType.getWorksTypes()) {
|
||||
Integer single = worksType.getAllowSingleFileSize();
|
||||
Integer total = worksType.getAllowTotalFileSize();
|
||||
if (single != null && single > 0 && total != null && total > 0 && total < single) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@At
|
||||
@SaCheckPermission("activity.workscollection.manage")
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
|
||||
+9
-1
@@ -75,12 +75,20 @@ public class ActivityWorksCollectionReadController {
|
||||
up.*,
|
||||
co.NAME AS activityName,
|
||||
su.typeName AS subjectName,
|
||||
wo.worksTypeName AS worksName
|
||||
wo.worksTypeName AS worksName,
|
||||
address.shippingAddress
|
||||
from
|
||||
activity_works_collection_upload up
|
||||
LEFT JOIN activity_works_collection co ON up.activityId = co.id
|
||||
LEFT JOIN activity_works_subjecttype su ON up.subjectId = su.id
|
||||
LEFT JOIN activity_works_workstype wo ON up.worksId = wo.id
|
||||
LEFT JOIN (
|
||||
SELECT userId,
|
||||
CONCAT_WS(' ', CONCAT_WS('', province, city, county), addressDetail, userName, tel) AS shippingAddress,
|
||||
ROW_NUMBER() OVER (PARTITION BY userId ORDER BY id) AS rn
|
||||
FROM welfare_member_address
|
||||
WHERE isDefault = 1
|
||||
) address ON address.userId = up.userId AND address.rn = 1
|
||||
$condition
|
||||
""");
|
||||
Cnd cnd = Cnd.NEW();
|
||||
|
||||
+26
-52
@@ -1,22 +1,20 @@
|
||||
package com.budwk.app.zhgh.activity.workscollection.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
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.service.BaseService;
|
||||
import com.budwk.app.sys.models.Sys_user;
|
||||
import com.budwk.app.sys.views.View_user;
|
||||
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
|
||||
import com.budwk.app.zhgh.activity.basic.models.ActivityUserScope;
|
||||
import com.budwk.app.zhgh.activity.workscollection.models.Activity_works_collection;
|
||||
import com.budwk.app.zhgh.activity.workscollection.models.Activity_works_collection_upload;
|
||||
import com.budwk.app.zhgh.activity.workscollection.service.ActivityWorksCollectionUploadService;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import com.budwk.app.zhgh.welfare.model.WelfareMemberAddress;
|
||||
import org.nutz.aop.interceptor.ioc.TransAop;
|
||||
import org.nutz.ioc.aop.Aop;
|
||||
import org.nutz.dao.Cnd;
|
||||
import org.nutz.dao.Dao;
|
||||
import org.nutz.dao.FieldFilter;
|
||||
import org.nutz.dao.Sqls;
|
||||
import org.nutz.dao.sql.Sql;
|
||||
import org.nutz.dao.util.Daos;
|
||||
import org.nutz.dao.util.cri.SqlExpressionGroup;
|
||||
import org.nutz.ioc.loader.annotation.Inject;
|
||||
@@ -40,7 +38,7 @@ public class ActivityWorksCollectionUploadController {
|
||||
private Dao dao;
|
||||
|
||||
@Inject
|
||||
private BaseService baseService;
|
||||
private ActivityWorksCollectionUploadService uploadService;
|
||||
|
||||
@At("")
|
||||
@Ok("beetl:/platform/zhgh/activity/workscollection/upload/index.html")
|
||||
@@ -57,27 +55,7 @@ public class ActivityWorksCollectionUploadController {
|
||||
@At
|
||||
@SaCheckPermission("activity.workscollection.upload")
|
||||
public Result pageData(@Valid PageForm pageForm, String activityId, String subjectId, String worksId) {
|
||||
Sql sql = Sqls.create("""
|
||||
select
|
||||
up.*,
|
||||
co.NAME AS activityName,
|
||||
su.typeName AS subjectName,
|
||||
wo.worksTypeName AS worksName
|
||||
from
|
||||
activity_works_collection_upload up
|
||||
LEFT JOIN activity_works_collection co ON up.activityId = co.id
|
||||
LEFT JOIN activity_works_subjecttype su ON up.subjectId = su.id
|
||||
LEFT JOIN activity_works_workstype wo ON up.worksId = wo.id
|
||||
$condition
|
||||
""");
|
||||
Cnd cnd = Cnd.NEW();
|
||||
cnd.and("userId", "=", SecurityUtil.getUserId());
|
||||
cnd.andEX("up.activityId", "=", activityId);
|
||||
cnd.andEX("up.subjectId", "=", subjectId);
|
||||
cnd.andEX("up.worksId", "=", worksId);
|
||||
sql.setCondition(cnd);
|
||||
Pagination pagination = baseService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql);
|
||||
return Result.success(pagination);
|
||||
return uploadService.pageData(pageForm, activityId, subjectId, worksId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,37 +65,33 @@ public class ActivityWorksCollectionUploadController {
|
||||
*/
|
||||
@At
|
||||
@SaCheckPermission("activity.workscollection.upload")
|
||||
public Result insert(@Param("data") @Valid Activity_works_collection_upload upload) {
|
||||
String activityId = upload.getActivityId();
|
||||
Activity_works_collection activity = dao.fetch(Activity_works_collection.class, activityId);
|
||||
if (activity.getActivityGroupId() != null) {
|
||||
int count = dao.count(ActivityUserScope.class, Cnd.where(ActivityUserScope::getGroupId, "=", activity.getActivityGroupId())
|
||||
.and(ActivityUserScope::getUserId, "=", SecurityUtil.getUserId()));
|
||||
if (count == 0) {
|
||||
return Result.error("您没有权限参与该活动");
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public Result insert(@Param("data") @Valid Activity_works_collection_upload upload, @Param("addressId") String addressId) {
|
||||
WelfareMemberAddress address = uploadService.shippingAddress(addressId);
|
||||
if (address == null) {
|
||||
return Result.error("请选择手机号有效的收货地址");
|
||||
}
|
||||
Result result = uploadService.insert(upload);
|
||||
if (result.isSuccess()) {
|
||||
uploadService.confirmShippingAddress(address);
|
||||
}
|
||||
|
||||
View_user vwUser = dao.fetch(View_user.class, Cnd.where(View_user::getId, "=", SecurityUtil.getUserId()));
|
||||
|
||||
upload.setUserId(SecurityUtil.getUserId());
|
||||
upload.setUserName(SecurityUtil.getUserUsername());
|
||||
upload.setLoginName(SecurityUtil.getUserLoginname());
|
||||
upload.setUnitId(SecurityUtil.getUnitId());
|
||||
upload.setUnitName(vwUser.getUnitName());
|
||||
upload.setUnionId(SecurityUtil.getUnionId());
|
||||
upload.setUnionName(vwUser.getUnionName());
|
||||
|
||||
dao.insert(upload);
|
||||
return Result.success();
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@At
|
||||
@SaCheckPermission("activity.workscollection.upload")
|
||||
public Result update(@Param("data") @Valid Activity_works_collection_upload upload) {
|
||||
dao.updateIgnoreNull(upload);
|
||||
return Result.success();
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public Result update(@Param("data") @Valid Activity_works_collection_upload upload, @Param("addressId") String addressId) {
|
||||
WelfareMemberAddress address = uploadService.shippingAddress(addressId);
|
||||
if (address == null) {
|
||||
return Result.error("请选择手机号有效的收货地址");
|
||||
}
|
||||
Result result = uploadService.update(upload);
|
||||
if (result.isSuccess()) {
|
||||
uploadService.confirmShippingAddress(address);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@At
|
||||
|
||||
+196
-4
@@ -1,9 +1,33 @@
|
||||
package com.budwk.app.zhgh.activity.workscollection.controller.h5;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import com.budwk.app.base.param.PageForm;
|
||||
import com.budwk.app.base.result.Result;
|
||||
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
|
||||
import com.budwk.app.zhgh.activity.workscollection.models.Activity_works_collection;
|
||||
import com.budwk.app.zhgh.activity.workscollection.models.Activity_works_collection_upload;
|
||||
import com.budwk.app.zhgh.activity.workscollection.models.Activity_works_subjectType;
|
||||
import com.budwk.app.zhgh.activity.workscollection.models.Activity_works_worksType;
|
||||
import com.budwk.app.zhgh.activity.workscollection.service.ActivityWorksCollectionUploadService;
|
||||
import com.budwk.app.zhgh.welfare.model.WelfareMemberAddress;
|
||||
import org.nutz.aop.interceptor.ioc.TransAop;
|
||||
import org.nutz.ioc.aop.Aop;
|
||||
import org.nutz.dao.Cnd;
|
||||
import org.nutz.dao.Dao;
|
||||
import org.nutz.dao.Sqls;
|
||||
import org.nutz.dao.sql.Sql;
|
||||
import org.nutz.ioc.loader.annotation.Inject;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
import org.nutz.lang.Strings;
|
||||
import org.nutz.lang.util.NutMap;
|
||||
import org.nutz.mvc.annotation.At;
|
||||
import org.nutz.mvc.annotation.Ok;
|
||||
import org.nutz.mvc.annotation.POST;
|
||||
import org.nutz.mvc.annotation.Param;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 手机端作品征集
|
||||
@@ -13,12 +37,18 @@ import org.nutz.mvc.annotation.Ok;
|
||||
@Ok("json:full")
|
||||
public class H5ActivityWorksCollectionController {
|
||||
|
||||
@Inject
|
||||
private Dao dao;
|
||||
|
||||
@Inject
|
||||
private ActivityWorksCollectionUploadService uploadService;
|
||||
|
||||
/**
|
||||
* 活动列表页面
|
||||
*/
|
||||
@At("/activity")
|
||||
@At({"", "/activity"})
|
||||
@Ok("beetl:/platform/zhghh5/activity/workscollection/activity/index.html")
|
||||
@SaCheckPermission("activity.workscollection.upload")
|
||||
@SaCheckPermission("h5.activity.workscollection.upload")
|
||||
public void activity() {
|
||||
}
|
||||
|
||||
@@ -27,7 +57,7 @@ public class H5ActivityWorksCollectionController {
|
||||
*/
|
||||
@At("/upload")
|
||||
@Ok("beetl:/platform/zhghh5/activity/workscollection/upload/index.html")
|
||||
@SaCheckPermission("activity.workscollection.upload")
|
||||
@SaCheckPermission("h5.activity.workscollection.upload")
|
||||
public void upload() {
|
||||
}
|
||||
|
||||
@@ -36,7 +66,169 @@ public class H5ActivityWorksCollectionController {
|
||||
*/
|
||||
@At("/upload/mine")
|
||||
@Ok("beetl:/platform/zhghh5/activity/workscollection/upload/mine.html")
|
||||
@SaCheckPermission("activity.workscollection.upload")
|
||||
@SaCheckPermission("h5.activity.workscollection.upload")
|
||||
public void uploadMine() {
|
||||
}
|
||||
|
||||
@At("/upload/address")
|
||||
@Ok("beetl:/platform/zhghh5/activity/workscollection/upload/address.html")
|
||||
@SaCheckPermission("h5.activity.workscollection.upload")
|
||||
public void uploadAddress() {
|
||||
}
|
||||
|
||||
@At("/upload/address/edit")
|
||||
@Ok("beetl:/platform/zhghh5/activity/workscollection/upload/addressEdit.html")
|
||||
@SaCheckPermission("h5.activity.workscollection.upload")
|
||||
public void editUploadAddress() {
|
||||
}
|
||||
|
||||
@At("/activities")
|
||||
@POST
|
||||
@SaCheckPermission("h5.activity.workscollection.upload")
|
||||
public Result activities() {
|
||||
Date now = new Date();
|
||||
List<NutMap> activities = dao.query(Activity_works_collection.class,
|
||||
visibleActivities().desc("startDateTime").desc("id")).stream()
|
||||
.map(activity -> activityInfo(activity, now))
|
||||
.toList();
|
||||
return Result.success(activities);
|
||||
}
|
||||
|
||||
@At("/activityDetail")
|
||||
@POST
|
||||
@SaCheckPermission("h5.activity.workscollection.upload")
|
||||
public Result activityDetail(@Param("activityId") String activityId) {
|
||||
Activity_works_collection activity = findActivity(activityId);
|
||||
if (activity == null) {
|
||||
return Result.error("活动不存在或您没有权限参与该活动");
|
||||
}
|
||||
List<Activity_works_subjectType> subjects = dao.query(Activity_works_subjectType.class,
|
||||
Cnd.where("activityId", "=", activityId).asc("location"));
|
||||
for (Activity_works_subjectType subject : subjects) {
|
||||
subject.setWorksTypes(dao.query(Activity_works_worksType.class,
|
||||
Cnd.where("subjectId", "=", subject.getId()).asc("location")));
|
||||
}
|
||||
return Result.success(activityInfo(activity, new Date())
|
||||
.setv("content", activity.getContent())
|
||||
.setv("introduce", activity.getIntroduce())
|
||||
.setv("rewardMethod", activity.getRewardMethod())
|
||||
.setv("subjectTypes", subjects));
|
||||
}
|
||||
|
||||
@At("/minePageData")
|
||||
@POST
|
||||
@SaCheckPermission("h5.activity.workscollection.upload")
|
||||
public Result minePageData(@Valid PageForm pageForm) {
|
||||
return uploadService.pageData(pageForm, null, null, null);
|
||||
}
|
||||
|
||||
@At("/myWork")
|
||||
@POST
|
||||
@SaCheckPermission("h5.activity.workscollection.upload")
|
||||
public Result myWork(@Param("id") String id) {
|
||||
Activity_works_collection_upload work = dao.fetch(Activity_works_collection_upload.class, myWorkCondition(id));
|
||||
return work == null ? Result.error("作品不存在") : Result.success(work);
|
||||
}
|
||||
|
||||
@At("/saveWork")
|
||||
@POST
|
||||
@SaCheckPermission("h5.activity.workscollection.upload")
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public Result saveWork(@Param("data") @Valid Activity_works_collection_upload form, @Param("addressId") String addressId) {
|
||||
WelfareMemberAddress address = uploadService.shippingAddress(addressId);
|
||||
if (address == null) {
|
||||
return Result.error("请选择手机号有效的收货地址");
|
||||
}
|
||||
String id = form.getId();
|
||||
Activity_works_collection_upload original = null;
|
||||
if (Strings.isNotBlank(id)) {
|
||||
original = dao.fetch(Activity_works_collection_upload.class, myWorkCondition(id));
|
||||
if (original == null) {
|
||||
return Result.error("作品不存在");
|
||||
}
|
||||
if (!original.getActivityId().equals(form.getActivityId())) {
|
||||
return Result.error("无法变更作品所属活动");
|
||||
}
|
||||
}
|
||||
|
||||
Activity_works_collection activity = findActivity(form.getActivityId());
|
||||
if (activity == null) {
|
||||
return Result.error("活动不存在或您没有权限参与该活动");
|
||||
}
|
||||
if (!"open".equals(activityStatus(activity, new Date()))) {
|
||||
return Result.error("当前活动不在作品提交时间内");
|
||||
}
|
||||
|
||||
|
||||
Activity_works_collection_upload work = new Activity_works_collection_upload();
|
||||
work.setActivityId(activity.getId());
|
||||
work.setSubjectId(form.getSubjectId());
|
||||
work.setWorksId(form.getWorksId());
|
||||
work.setName(form.getName());
|
||||
work.setDescription(form.getDescription());
|
||||
work.setFiles(form.getFiles());
|
||||
Result result;
|
||||
if (original == null) {
|
||||
result = uploadService.insert(work);
|
||||
} else {
|
||||
work.setId(original.getId());
|
||||
result = uploadService.update(work);
|
||||
}
|
||||
if (result.isSuccess()) {
|
||||
uploadService.confirmShippingAddress(address);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@At("/deleteWork")
|
||||
@POST
|
||||
@SaCheckPermission("h5.activity.workscollection.upload")
|
||||
public Result deleteWork(@Param("id") String id) {
|
||||
Activity_works_collection_upload work = dao.fetch(Activity_works_collection_upload.class, myWorkCondition(id));
|
||||
if (work == null) {
|
||||
return Result.error("作品不存在");
|
||||
}
|
||||
Sql openActivity = Sqls.create("""
|
||||
SELECT id FROM activity_works_collection
|
||||
WHERE id = @activityId AND enable = 1 AND (isSubmit = 1 OR isSubmit IS NULL)
|
||||
AND startDateTime <= CURRENT_TIMESTAMP(3) AND endDateTime >= CURRENT_TIMESTAMP(3)
|
||||
""").setParam("activityId", work.getActivityId());
|
||||
return dao.clear(Activity_works_collection_upload.class, myWorkCondition(id).and("activityId", "in", openActivity)) > 0
|
||||
? Result.success() : Result.error("当前活动不在作品提交时间内,无法删除作品");
|
||||
}
|
||||
|
||||
private Cnd visibleActivities() {
|
||||
return Cnd.where("enable", "=", true)
|
||||
.and(Cnd.exps("isSubmit", "=", true).or("isSubmit", "is", null))
|
||||
.and(Cnd.exps("activityGroupId", "is", null)
|
||||
.or("activityGroupId", "in", Sqls.create("select groupId from activity_user_scope where userId = @userId")
|
||||
.setParam("userId", SecurityUtil.getUserId())));
|
||||
}
|
||||
|
||||
private Activity_works_collection findActivity(String activityId) {
|
||||
return dao.fetch(Activity_works_collection.class, visibleActivities().and("id", "=", activityId));
|
||||
}
|
||||
|
||||
private Cnd myWorkCondition(String id) {
|
||||
return Cnd.where("id", "=", id).and("userId", "=", SecurityUtil.getUserId());
|
||||
}
|
||||
|
||||
private NutMap activityInfo(Activity_works_collection activity, Date now) {
|
||||
return NutMap.NEW()
|
||||
.setv("id", activity.getId())
|
||||
.setv("name", activity.getName())
|
||||
.setv("cover", activity.getCover())
|
||||
.setv("startDateTime", activity.getStartDateTime() == null ? null : activity.getStartDateTime().getTime())
|
||||
.setv("endDateTime", activity.getEndDateTime() == null ? null : activity.getEndDateTime().getTime())
|
||||
.setv("status", activityStatus(activity, now));
|
||||
}
|
||||
|
||||
private String activityStatus(Activity_works_collection activity, Date now) {
|
||||
if (activity.getStartDateTime() == null || activity.getEndDateTime() == null || now.after(activity.getEndDateTime())) {
|
||||
return "closed";
|
||||
}
|
||||
return now.before(activity.getStartDateTime()) ? "upcoming" : "open";
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
package com.budwk.app.zhgh.activity.workscollection.service;
|
||||
|
||||
import cn.hutool.json.JSONObject;
|
||||
import com.budwk.app.base.page.Pagination;
|
||||
import com.budwk.app.sys.models.Sys_file;
|
||||
import com.budwk.app.sys.services.SysFileService;
|
||||
import com.budwk.app.zhgh.activity.workscollection.models.Activity_works_subjectType;
|
||||
import com.budwk.app.zhgh.activity.workscollection.models.Activity_works_worksType;
|
||||
import com.budwk.app.base.param.PageForm;
|
||||
import com.budwk.app.base.result.Result;
|
||||
import com.budwk.app.base.service.BaseService;
|
||||
import com.budwk.app.sys.views.View_user;
|
||||
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
|
||||
import com.budwk.app.zhgh.activity.basic.models.ActivityUserScope;
|
||||
import com.budwk.app.zhgh.activity.workscollection.models.Activity_works_collection;
|
||||
import com.budwk.app.zhgh.activity.workscollection.models.Activity_works_collection_upload;
|
||||
import com.budwk.app.zhgh.welfare.model.WelfareMemberAddress;
|
||||
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 java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
@IocBean
|
||||
public class ActivityWorksCollectionUploadService {
|
||||
|
||||
private static final String DUPLICATE_SUBMISSION = "您已提交过该活动下此主题类型和作品类型的作品,请在我的作品中编辑。";
|
||||
|
||||
@Inject
|
||||
private Dao dao;
|
||||
|
||||
@Inject
|
||||
private SysFileService sysFileService;
|
||||
|
||||
private static final String DOWNLOAD_PREFIX = "/platform/sys/file/download?id=";
|
||||
|
||||
@Inject
|
||||
private BaseService baseService;
|
||||
|
||||
public Result pageData(PageForm pageForm, String activityId, String subjectId, String worksId) {
|
||||
Sql sql = Sqls.create("""
|
||||
select
|
||||
up.*,
|
||||
co.NAME AS activityName,
|
||||
co.cover AS activityCover,
|
||||
CASE WHEN co.enable = 1 AND (co.isSubmit = 1 OR co.isSubmit IS NULL)
|
||||
AND co.startDateTime <= CURRENT_TIMESTAMP(3)
|
||||
AND co.endDateTime >= CURRENT_TIMESTAMP(3)
|
||||
THEN 1 ELSE 0 END AS canModify,
|
||||
su.typeName AS subjectName,
|
||||
wo.worksTypeName AS worksName,
|
||||
address.shippingAddress
|
||||
from
|
||||
activity_works_collection_upload up
|
||||
LEFT JOIN activity_works_collection co ON up.activityId = co.id
|
||||
LEFT JOIN activity_works_subjecttype su ON up.subjectId = su.id
|
||||
LEFT JOIN activity_works_workstype wo ON up.worksId = wo.id
|
||||
LEFT JOIN (
|
||||
SELECT userId,
|
||||
CONCAT_WS(' ', CONCAT_WS('', province, city, county), addressDetail, userName, tel) AS shippingAddress,
|
||||
ROW_NUMBER() OVER (PARTITION BY userId ORDER BY id) AS rn
|
||||
FROM welfare_member_address
|
||||
WHERE isDefault = 1
|
||||
) address ON address.userId = up.userId AND address.rn = 1
|
||||
$condition
|
||||
""");
|
||||
Cnd cnd = Cnd.NEW();
|
||||
cnd.and("up.userId", "=", SecurityUtil.getUserId());
|
||||
cnd.andEX("up.activityId", "=", activityId);
|
||||
cnd.andEX("up.subjectId", "=", subjectId);
|
||||
cnd.andEX("up.worksId", "=", worksId);
|
||||
cnd.desc("up.createdAt").desc("up.id");
|
||||
sql.setCondition(cnd);
|
||||
Pagination pagination = baseService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql);
|
||||
return Result.success(pagination);
|
||||
}
|
||||
|
||||
public WelfareMemberAddress shippingAddress(String addressId) {
|
||||
if (addressId == null || addressId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
WelfareMemberAddress address = dao.fetch(WelfareMemberAddress.class,
|
||||
Cnd.where("userId", "=", SecurityUtil.getUserId()).and("id", "=", addressId));
|
||||
return address != null && address.getTel() != null && address.getTel().matches("1[3-9][0-9]{9}")
|
||||
? address : null;
|
||||
}
|
||||
|
||||
public void confirmShippingAddress(WelfareMemberAddress address) {
|
||||
dao.update(WelfareMemberAddress.class, Chain.make("isDefault", false),
|
||||
Cnd.where("userId", "=", address.getUserId()).and("id", "<>", address.getId()));
|
||||
dao.update(WelfareMemberAddress.class, Chain.make("isDefault", true),
|
||||
Cnd.where("userId", "=", address.getUserId()).and("id", "=", address.getId()));
|
||||
}
|
||||
|
||||
public Result insert(Activity_works_collection_upload upload) {
|
||||
String userId = SecurityUtil.getUserId();
|
||||
String activityId = upload.getActivityId();
|
||||
Activity_works_collection activity = dao.fetch(Activity_works_collection.class, activityId);
|
||||
if (activity == null) {
|
||||
return Result.error("活动不存在");
|
||||
}
|
||||
if (activity.getActivityGroupId() != null) {
|
||||
int count = dao.count(ActivityUserScope.class, Cnd.where(ActivityUserScope::getGroupId, "=", activity.getActivityGroupId())
|
||||
.and(ActivityUserScope::getUserId, "=", userId));
|
||||
if (count == 0) {
|
||||
return Result.error("您没有权限参与该活动");
|
||||
}
|
||||
}
|
||||
|
||||
if (!lockSubmitter(userId)) {
|
||||
return Result.error("投稿用户不存在");
|
||||
}
|
||||
if (dao.count(Activity_works_collection_upload.class, submissionCondition(userId, upload)) > 0) {
|
||||
return Result.error(DUPLICATE_SUBMISSION);
|
||||
}
|
||||
|
||||
Result validation = validateWork(upload, null);
|
||||
if (!validation.isSuccess()) {
|
||||
return validation;
|
||||
}
|
||||
|
||||
View_user vwUser = dao.fetch(View_user.class, Cnd.where(View_user::getId, "=", userId));
|
||||
|
||||
upload.setUserId(userId);
|
||||
upload.setUserName(SecurityUtil.getUserUsername());
|
||||
upload.setLoginName(SecurityUtil.getUserLoginname());
|
||||
upload.setUnitId(SecurityUtil.getUnitId());
|
||||
upload.setUnitName(vwUser.getUnitName());
|
||||
upload.setUnionId(SecurityUtil.getUnionId());
|
||||
upload.setUnionName(vwUser.getUnionName());
|
||||
|
||||
dao.insert(upload);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
public Result update(Activity_works_collection_upload upload) {
|
||||
String userId = SecurityUtil.getUserId();
|
||||
if (!lockSubmitter(userId)) {
|
||||
return Result.error("投稿用户不存在");
|
||||
}
|
||||
Activity_works_collection_upload original = dao.fetch(Activity_works_collection_upload.class,
|
||||
Cnd.where("id", "=", upload.getId()).and("userId", "=", userId));
|
||||
if (original == null) {
|
||||
return Result.error("作品不存在");
|
||||
}
|
||||
if (upload.getActivityId() == null) {
|
||||
upload.setActivityId(original.getActivityId());
|
||||
}
|
||||
boolean sameCategory = Objects.equals(original.getActivityId(), upload.getActivityId())
|
||||
&& Objects.equals(original.getSubjectId(), upload.getSubjectId())
|
||||
&& Objects.equals(original.getWorksId(), upload.getWorksId());
|
||||
if (!sameCategory && dao.count(Activity_works_collection_upload.class,
|
||||
submissionCondition(userId, upload).and("id", "<>", original.getId())) > 0) {
|
||||
return Result.error(DUPLICATE_SUBMISSION);
|
||||
}
|
||||
Result validation = validateWork(upload, original);
|
||||
if (!validation.isSuccess()) {
|
||||
return validation;
|
||||
}
|
||||
upload.setUserId(userId);
|
||||
return dao.updateIgnoreNull(upload) > 0 ? Result.success() : Result.error("作品不存在");
|
||||
}
|
||||
|
||||
private Result validateWork(Activity_works_collection_upload form, Activity_works_collection_upload original) {
|
||||
Activity_works_subjectType subject = dao.fetch(Activity_works_subjectType.class,
|
||||
Cnd.where("id", "=", form.getSubjectId()).and("activityId", "=", form.getActivityId()));
|
||||
Activity_works_worksType worksType = dao.fetch(Activity_works_worksType.class,
|
||||
Cnd.where("id", "=", form.getWorksId()).and("subjectId", "=", form.getSubjectId()));
|
||||
if (subject == null || worksType == null) {
|
||||
return Result.error("请选择该活动下有效的主题类型和作品类型");
|
||||
}
|
||||
if (overLimit(worksType.getMaxWordCount(), form.getDescription().length())) {
|
||||
return Result.error("作品描述超过该作品类型的字数限制");
|
||||
}
|
||||
if (overLimit(worksType.getAllowFileNum(), form.getFiles().size())) {
|
||||
return Result.error("附件数量超过该作品类型的限制");
|
||||
}
|
||||
|
||||
Set<String> existingUrls = new HashSet<>();
|
||||
if (original != null && original.getFiles() != null) {
|
||||
for (JSONObject file : original.getFiles()) {
|
||||
existingUrls.add(attachmentUrl(file));
|
||||
}
|
||||
}
|
||||
Set<String> fileIds = new HashSet<>();
|
||||
List<JSONObject> files = new ArrayList<>();
|
||||
long totalSizeBytes = 0;
|
||||
for (JSONObject attachment : form.getFiles()) {
|
||||
String url = attachmentUrl(attachment);
|
||||
if (url == null || !url.startsWith(DOWNLOAD_PREFIX)) {
|
||||
return Result.error("请上传有效的作品附件");
|
||||
}
|
||||
String fileId = url.substring(DOWNLOAD_PREFIX.length());
|
||||
if (!fileId.matches("[a-zA-Z0-9]{26}(?:[a-zA-Z0-9]{6})?") || !fileIds.add(fileId)) {
|
||||
return Result.error("作品附件无效或重复");
|
||||
}
|
||||
Sys_file file = dao.fetch(Sys_file.class, fileId);
|
||||
if (file == null || Boolean.TRUE.equals(file.getDelFlag())
|
||||
|| !(SecurityUtil.getUserId().equals(file.getCreatedBy()) || existingUrls.contains(url))) {
|
||||
return Result.error("请上传有效的作品附件");
|
||||
}
|
||||
List<String> allowedTypes = worksType.getAllowFileTypes();
|
||||
if (allowedTypes != null && !allowedTypes.isEmpty()
|
||||
&& allowedTypes.stream().noneMatch(type -> type.equalsIgnoreCase(file.getSuffix()))) {
|
||||
return Result.error("附件格式不符合该作品类型的要求");
|
||||
}
|
||||
long sizeBytes;
|
||||
try {
|
||||
sizeBytes = sysFileService.sizeBytes(file);
|
||||
} catch (RuntimeException e) {
|
||||
return Result.error("无法读取作品附件大小,请重新上传");
|
||||
}
|
||||
if (sizeBytes < 0 || exceedsFileSize(worksType.getAllowSingleFileSize(), sizeBytes)) {
|
||||
return Result.error("单个附件大小超过该作品类型的限制");
|
||||
}
|
||||
totalSizeBytes += sizeBytes;
|
||||
files.add(new JSONObject()
|
||||
.set("name", file.getName())
|
||||
.set("url", url)
|
||||
.set("size", sizeBytes)
|
||||
.set("response", new JSONObject().set("code", 0).set("data", url)));
|
||||
}
|
||||
if (exceedsFileSize(worksType.getAllowTotalFileSize(), totalSizeBytes)) {
|
||||
return Result.error("附件总大小超过该作品类型的限制");
|
||||
}
|
||||
form.setFiles(files);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
private boolean exceedsFileSize(Integer limitKb, long sizeBytes) {
|
||||
return limitKb != null && limitKb > 0 && sizeBytes > limitKb * 1024L;
|
||||
}
|
||||
|
||||
private boolean overLimit(Integer limit, long value) {
|
||||
return limit != null && limit > 0 && value > limit;
|
||||
}
|
||||
|
||||
private String attachmentUrl(JSONObject file) {
|
||||
JSONObject response = file == null ? null : file.getJSONObject("response");
|
||||
return response == null ? null : response.getStr("data");
|
||||
}
|
||||
|
||||
private Cnd submissionCondition(String userId, Activity_works_collection_upload upload) {
|
||||
return Cnd.where("userId", "=", userId)
|
||||
.and("activityId", "=", upload.getActivityId())
|
||||
.and("subjectId", "=", upload.getSubjectId())
|
||||
.and("worksId", "=", upload.getWorksId());
|
||||
}
|
||||
|
||||
private boolean lockSubmitter(String userId) {
|
||||
// 在投稿事务中锁定账号行,串行处理同一用户从不同端发起的保存请求。
|
||||
Sql sql = Sqls.create("SELECT id FROM sys_user WHERE id = @userId FOR UPDATE");
|
||||
sql.setParam("userId", userId);
|
||||
sql.setCallback(Sqls.callback.str());
|
||||
return dao.execute(sql).getResult() != null;
|
||||
}
|
||||
}
|
||||
+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_role;
|
||||
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.controllers.open.commons.service.CommonService;
|
||||
import com.budwk.app.zhgh.club.model.ClubUser;
|
||||
@@ -63,6 +64,7 @@ public class ClubUserJoinApplyController {
|
||||
Sql sql = Sqls.create("""
|
||||
SELECT
|
||||
cua.*,
|
||||
club.clubName,
|
||||
u.loginname AS loginName,
|
||||
u.username AS userName,
|
||||
u.sex,
|
||||
@@ -74,6 +76,7 @@ public class ClubUserJoinApplyController {
|
||||
FROM
|
||||
club_user_apply cua
|
||||
LEFT JOIN vw_user u ON u.id = cua.userId
|
||||
LEFT JOIN sys_club club ON club.id = cua.clubId
|
||||
WHERE cua.id = @id
|
||||
""").setParam("id", id);
|
||||
sql.setCallback(Sqls.callback.map());
|
||||
@@ -87,10 +90,22 @@ public class ClubUserJoinApplyController {
|
||||
@SaCheckPermission("club.join.apply")
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
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()));
|
||||
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())) {
|
||||
BpmProcessInstance processInstance = dao.fetch(BpmProcessInstance.class, Cnd.where(BpmProcessInstance::getProcessInstanceBusinessId, "=", userApply.getId()));
|
||||
if (!processInstance.getProcessInstanceStatus().equals(BpmProcessInstanceStatusEnum.COMPLETED.name())) {
|
||||
|
||||
+3
-117
@@ -1,32 +1,11 @@
|
||||
package com.budwk.app.zhgh.club.controller.apply;
|
||||
|
||||
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.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.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.service.ClubUserJoinService;
|
||||
import com.budwk.app.zhgh.club.vo.ClubUserJoinPageVo;
|
||||
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.IocBean;
|
||||
import org.nutz.mvc.annotation.At;
|
||||
@@ -34,23 +13,14 @@ import org.nutz.mvc.annotation.Ok;
|
||||
import org.nutz.mvc.annotation.Param;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@IocBean
|
||||
@At("/platform/club/join/clubApproval")
|
||||
@Ok("json:full")
|
||||
public class ClubUserJoinApprovalController {
|
||||
|
||||
@Inject
|
||||
private CommonService commonService;
|
||||
@Inject
|
||||
private BpmService bpmService;
|
||||
@Inject
|
||||
private ClubUserJoinService clubUserJoinService;
|
||||
@Inject
|
||||
private Dao dao;
|
||||
|
||||
@At("")
|
||||
@SaCheckPermission("club.join.clubApproval")
|
||||
@@ -62,106 +32,22 @@ public class ClubUserJoinApprovalController {
|
||||
@At
|
||||
@SaCheckPermission("club.join.clubApproval")
|
||||
public Result pageData(@Valid 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){
|
||||
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);
|
||||
return Result.success(clubUserJoinService.pageDataByClubApproval(pageForm, approval));
|
||||
}
|
||||
|
||||
@At
|
||||
@SaCheckPermission("club.join.clubApproval")
|
||||
@ApiOperation("审核")
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public Result approval(@Valid @Param("approval") 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
clubUserJoinService.clubApproval(approvalParam);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
@At
|
||||
@SaCheckPermission("club.join.clubApproval")
|
||||
@ApiOperation("撤回")
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public Result revoke(@Valid 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()));
|
||||
clubUserJoinService.revokeClubApproval(taskId);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
package com.budwk.app.zhgh.club.h5controller;
|
||||
|
||||
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 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;
|
||||
|
||||
import javax.validation.Valid;
|
||||
|
||||
@IocBean
|
||||
@At("/platform/h5/club/audit")
|
||||
@@ -12,10 +20,40 @@ import org.nutz.mvc.annotation.Ok;
|
||||
@Api("h5入会审核")
|
||||
public class H5ClubAuditController {
|
||||
|
||||
@Inject
|
||||
private ClubUserJoinService clubUserJoinService;
|
||||
|
||||
|
||||
@At("")
|
||||
@Ok("beetl:/platform/zhghh5/club/audit/index.html")
|
||||
@SaCheckPermission("h5.club.audit")
|
||||
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;
|
||||
|
||||
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.zhgh.club.model.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;
|
||||
|
||||
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.zhgh.club.model.ClubUserApply;
|
||||
import com.budwk.app.zhgh.club.service.ClubUserJoinService;
|
||||
import org.nutz.dao.Dao;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@IocBean(args = {"refer:dao"})
|
||||
public class ClubUserJoinServiceImpl extends BaseServiceImpl<ClubUserApply> implements ClubUserJoinService {
|
||||
@Inject
|
||||
private BpmService bpmService;
|
||||
|
||||
public ClubUserJoinServiceImpl(Dao 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.web.commons.auth.utils.SecurityUtil;
|
||||
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.dao.Chain;
|
||||
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.Param;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
/**
|
||||
* @ClassName WelfareAddressManageController
|
||||
* @Description 福利地址管理
|
||||
@@ -31,12 +34,28 @@ public class WelfareAddressManageController {
|
||||
@Inject
|
||||
private BaseService baseService;
|
||||
|
||||
@Inject
|
||||
private WelfareAddressManageService welfareAddressManageService;
|
||||
|
||||
@At("")
|
||||
@Ok("beetl:/platform/zhgh/welfare/addressManage/index.html")
|
||||
@SaCheckPermission("welfare.addressManage")
|
||||
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);
|
||||
|
||||
/**
|
||||
* 移动端工会统计;方式筛选只影响已选人数和各福利选项人数。
|
||||
* @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
|
||||
|
||||
+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.userState", "in", pageForm.getUserStates());
|
||||
if (StrUtil.isAllBlank(pageForm.getPageOrderName(), pageForm.getPageOrderBy())) {
|
||||
// 默认先按工号正序分页,工号相同时沿用工会和单位顺序。
|
||||
cnd.asc("t2.loginname");
|
||||
cnd.asc("t1.welfareUnionName");
|
||||
cnd.asc("t1.welfareUnitName");
|
||||
} else {
|
||||
@@ -644,32 +646,52 @@ public class WelfareListServiceImpl extends BaseServiceImpl<WelfareList> impleme
|
||||
LEFT JOIN sys_user t2 ON t2.id = t1.userId
|
||||
$condition
|
||||
""");
|
||||
Cnd cnd = Cnd.NEW();
|
||||
cnd.and("t1.projectId", "=", pageForm.getProjectId());
|
||||
cnd.and("t2.idCard", "in", Sqls.create("select idCard from sys_user where idCard is not null and idCard != '' group by idCard having count(1) > 1"));
|
||||
// 只统计当前福利项目名单中的身份证号;同号名单记录至少两条才属于重复组。
|
||||
Sql repeatIdCardSql = Sqls.create("""
|
||||
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())) {
|
||||
cnd.where().andLike("t2.username", pageForm.getUserName());
|
||||
matchedCnd.where().andLike("t2.username", pageForm.getUserName());
|
||||
}
|
||||
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());
|
||||
cnd.andEX("t2.birthday", "=", pageForm.getBirthday());
|
||||
matchedCnd.andEX("t2.sex", "=", pageForm.getSex());
|
||||
matchedCnd.andEX("t2.birthday", "=", pageForm.getBirthday());
|
||||
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());
|
||||
cnd.andEX("t1.welfareUnionId", "=", pageForm.getUnionId());
|
||||
cnd.andEX("t1.personType", "in", pageForm.getPersonTypes());
|
||||
cnd.andEX("t1.preparedBy", "in", pageForm.getPreparedBys());
|
||||
cnd.andEX("t1.userState", "in", pageForm.getUserStates());
|
||||
|
||||
matchedCnd.andEX("t1.welfareUnitId", "in", pageForm.getUnitIds());
|
||||
matchedCnd.andEX("t1.welfareUnionId", "=", pageForm.getUnionId());
|
||||
matchedCnd.andEX("t1.personType", "in", pageForm.getPersonTypes());
|
||||
matchedCnd.andEX("t1.preparedBy", "in", pageForm.getPreparedBys());
|
||||
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("t1.welfareUnionName");
|
||||
cnd.asc("t1.welfareUnitName");
|
||||
|
||||
cnd.asc("t2.loginname");
|
||||
cnd.asc("t1.id");
|
||||
sql.setCondition(cnd);
|
||||
List<NutMap> list = listMap(sql);
|
||||
list = listMap(sql);
|
||||
}
|
||||
|
||||
try {
|
||||
List<ExcelExportEntity> exportEntities = new ArrayList<>();
|
||||
|
||||
+2
@@ -102,6 +102,8 @@ public class WelfareSelectionSituationServiceImpl extends BaseServiceImpl<Welfar
|
||||
}
|
||||
|
||||
if (StrUtil.isAllBlank(pageForm.getPageOrderName(), pageForm.getPageOrderBy())) {
|
||||
// 默认先按工号正序分页,工号相同时沿用工会和单位顺序。
|
||||
cnd.asc("t4.loginname");
|
||||
cnd.asc("welfareUnionName");
|
||||
cnd.asc("welfareUnitName");
|
||||
} else {
|
||||
|
||||
+20
@@ -138,6 +138,26 @@ public class WelfareStatisticsServiceImpl extends BaseServiceImpl<WelfareProject
|
||||
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
|
||||
public Pagination selectedUnionUserPageData(PageForm pageForm, String projectId, String unionId, Boolean isSelectByAdmin) {
|
||||
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;
|
||||
@@ -0,0 +1,4 @@
|
||||
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 ('85bd177d04154c4c8eb57d6e6cd16893', '', '0172', '作品征集', null, 'menu', '/platform/h5/activity/worksCollection/activity', null, '/platform/sys/file/download?id=7hf45bajvmjg2on1tb6b6dhhl0', 1, 0, 'h5.activity.workscollection.upload', null, 17, 0, '17a7f8ad3ee947b4a26175049a9c253d', 1794556127700, '17a7f8ad3ee947b4a26175049a9c253d', 1794556139027, 0, null, 'H5', '543c7a3c6fde4a01a89570b4479c93f4', 0);
|
||||
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 ('9fcbf730b6a94b709b4cccec598b25a6', '91d79e0f4e464f298e7a6b37217386d3', '000900010004', '收货地址', null, 'menu', '/platform/activity/worksCollection/addressManage', null, null, 1, 0, 'activity.workscollection.addressManage', null, 22, 0, '17a7f8ad3ee947b4a26175049a9c253d', 1794557773073, '17a7f8ad3ee947b4a26175049a9c253d', 1794557773074, 0, null, 'PC', '889c6f60d1ca474494c4573f0b289c72', 0);
|
||||
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', '85bd177d04154c4c8eb57d6e6cd16893');
|
||||
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', '9fcbf730b6a94b709b4cccec598b25a6');
|
||||
@@ -32,7 +32,7 @@ var ioc={
|
||||
// 单个文件最大尺寸(大约的值,单位为字节,即 209715200 为 200M)
|
||||
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 : {
|
||||
|
||||
@@ -14,7 +14,8 @@ const h5PopupHistory = (() => {
|
||||
popup.closeFromHistory()
|
||||
}
|
||||
|
||||
window.addEventListener("popstate", handlePopState)
|
||||
// 捕获阶段优先消费弹框返回事件,避免 PJAX 重载页面并重置筛选条件。
|
||||
window.addEventListener("popstate", handlePopState, true)
|
||||
|
||||
return {
|
||||
/**
|
||||
@@ -37,11 +38,13 @@ const h5PopupHistory = (() => {
|
||||
/**
|
||||
* 通过取消、关闭按钮关闭指定弹框时,同步回退对应历史记录。
|
||||
* @param id 弹框唯一标识,必须是当前最上层弹框
|
||||
* @return boolean;true 表示已发起历史回退,false 表示弹框不在栈顶
|
||||
* @return boolean;true 表示已发起历史回退,false 表示弹框不在栈顶或正在关闭
|
||||
*/
|
||||
close(id) {
|
||||
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()
|
||||
return true
|
||||
},
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
.field .van-field__error-message {
|
||||
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>
|
||||
<template>
|
||||
<div>
|
||||
@@ -89,8 +100,11 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<van-picker
|
||||
:key="column.columnCode + '-' + (pickerItemHeights[column.columnCode] || 44)"
|
||||
:columns="column.selectValues"
|
||||
:title="column.columnName"
|
||||
:item-height="pickerItemHeights[column.columnCode] || 44"
|
||||
:visible-item-count="pickerVisibleItemCounts[column.columnCode] || 6"
|
||||
show-toolbar
|
||||
@cancel="ordinaryPickerCancel(column)"
|
||||
@confirm="
|
||||
@@ -98,7 +112,11 @@
|
||||
ordinaryPickerConfirm(value, column)
|
||||
}
|
||||
"
|
||||
/>
|
||||
>
|
||||
<template v-if="fullOptionText" #option="option">
|
||||
<div class="train-picker-option-text">{{ option }}</div>
|
||||
</template>
|
||||
</van-picker>
|
||||
</template>
|
||||
</van-popup>
|
||||
</template>
|
||||
@@ -140,7 +158,12 @@ module.exports = {
|
||||
name: "TrainDynamicForm",
|
||||
components: {},
|
||||
props: {
|
||||
dynamicData: []
|
||||
dynamicData: [],
|
||||
// 由亲子活动报名页单独启用,其余引用页面保留原有选项展示。
|
||||
fullOptionText: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
model: {
|
||||
prop: "dynamicData",
|
||||
@@ -160,6 +183,8 @@ module.exports = {
|
||||
return {
|
||||
//所有的picker状态
|
||||
pickerStates: {},
|
||||
pickerItemHeights: {},
|
||||
pickerVisibleItemCounts: {},
|
||||
fileList: [],
|
||||
addressStates: {},
|
||||
addressAreaList: window.trainSignUpAreaList || { province_list: {}, city_list: {}, county_list: {} }
|
||||
@@ -444,7 +469,44 @@ module.exports = {
|
||||
const addressValue = (state.displayArea || "") + (state.detail || "")
|
||||
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) {
|
||||
this.measurePickerOptions(column)
|
||||
this.$set(this.pickerStates, column.columnCode + "Picker", true)
|
||||
if (!["DATE", "DATETIME"].includes(column.columnType)) {
|
||||
//说明是普通的下拉框
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="search">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -9,7 +9,13 @@
|
||||
|
||||
<script>
|
||||
module.exports = {
|
||||
name: "pageFormSearch"
|
||||
name: "pageFormSearch",
|
||||
props: {
|
||||
isSearchButton: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -154,6 +154,10 @@ module.exports = {
|
||||
required: false
|
||||
},
|
||||
// 接受上传的文件类型
|
||||
validate_file: {
|
||||
type: Function,
|
||||
default: null
|
||||
},
|
||||
accept: {
|
||||
type: String,
|
||||
default: "",
|
||||
@@ -290,6 +294,9 @@ module.exports = {
|
||||
|
||||
// 这是兜底逻辑,保准只让这个image类型上传图片
|
||||
beforeUpload(file) {
|
||||
if (this.validate_file) {
|
||||
return this.validate_file(file, this.fileList)
|
||||
}
|
||||
return true
|
||||
|
||||
// if (!this.fileAccept || typeof this.fileAccept !== "string") {
|
||||
@@ -546,9 +553,13 @@ module.exports = {
|
||||
}
|
||||
}
|
||||
|
||||
this.$message.success("获取手机文件成功")
|
||||
const newFileList = [...originFileList]
|
||||
for (const file of data.files) {
|
||||
if (this.validate_file && !this.validate_file(file, newFileList)) return
|
||||
newFileList.push(file)
|
||||
}
|
||||
|
||||
const newFileList = originFileList.concat(data.files)
|
||||
this.$message.success("获取手机文件成功")
|
||||
this.$emit("update:value", newFileList)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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-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/vxe-table.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 -->
|
||||
<script src="${base!}/assets/platform/plugins/form-create/form-create.min.js"></script>
|
||||
@@ -356,7 +356,7 @@
|
||||
if (!state.activeModuleId && getters.pcModules) {
|
||||
commit("setActiveMenuModuleId", getters.pcModules[0].id)
|
||||
}
|
||||
pjaxReplace(pathname === "/platform/home" ? pathname : pathname)
|
||||
pjaxReplace(pathname + search)
|
||||
return
|
||||
}
|
||||
//拷贝一份
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
<script src="${base!}/assets/platform/plugins/axios/axios.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片段的工具,模拟前端路由机制 -->
|
||||
<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!}/components/plugins/sysDict/DictData.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>
|
||||
|
||||
<!--富文本编辑器-->
|
||||
|
||||
@@ -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 = {
|
||||
template: /*language=HTML*/ `
|
||||
<guava ref="guava">
|
||||
<guava ref="guava" @hook:mounted="onPageVisible">
|
||||
<template>
|
||||
<el-card shadow="never">
|
||||
|
||||
@@ -65,7 +65,8 @@ const ACTIVITY_CULTURE_USER_STATISTICS = {
|
||||
</el-button>
|
||||
</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">
|
||||
<el-table-column :index="indexMethod" align="center" header-align="center"
|
||||
label="序号" type="index"
|
||||
@@ -110,8 +111,10 @@ const ACTIVITY_CULTURE_USER_STATISTICS = {
|
||||
pageForm: {
|
||||
unionId: "",
|
||||
searchName: "atp.username",
|
||||
pageSize: 50,
|
||||
year: new Date().getFullYear() + ""
|
||||
},
|
||||
tableHeight: 300,
|
||||
tableColumns: [
|
||||
{ prop: "userName", label: "姓名", width: 180, fixed: "left" },
|
||||
{ prop: "loginName", label: "工号", width: 180, fixed: "left" },
|
||||
@@ -133,6 +136,60 @@ const ACTIVITY_CULTURE_USER_STATISTICS = {
|
||||
methods: {
|
||||
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) {
|
||||
const formConfig = this.activityList.find((v) => v.id === this.pageForm.activityId)?.formConfig
|
||||
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() {
|
||||
await this.getActivityByYearOrType()
|
||||
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>
|
||||
<el-dialog :close-on-click-modal="false" :visible.sync="signDialog"
|
||||
: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">
|
||||
<div class="left-span-label">个人信息</div>
|
||||
<el-row>
|
||||
@@ -72,6 +72,9 @@ const signForm = {
|
||||
</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">
|
||||
<el-tabs v-model="active" type="card">
|
||||
<el-tab-pane v-for="(item, index) in formData.mobileColumnsValue"
|
||||
@@ -137,7 +140,7 @@ const signForm = {
|
||||
|
||||
</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 :loading="formLoading" @click="onSubmit" type="primary">{{prefillMode ? '保存预填' : '提 交'}}</el-button>
|
||||
</el-row>
|
||||
@@ -158,7 +161,7 @@ const signForm = {
|
||||
courseRow: {},
|
||||
courseTypeRow: {},
|
||||
courseTimeSelectList: [],
|
||||
active: '',
|
||||
active: '0',
|
||||
|
||||
activity: {},
|
||||
}
|
||||
@@ -173,13 +176,23 @@ const signForm = {
|
||||
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
|
||||
}
|
||||
const list = clone(this.courseTypeRow.familyMobileSignColumnList)
|
||||
this.formData.mobileColumnsValue.push(list)
|
||||
// 新增儿童后选中对应的字符串索引,确保标签和填写区域同步显示。
|
||||
this.$set(this, "active", String(this.formData.mobileColumnsValue.length - 1))
|
||||
},
|
||||
delFamily() {
|
||||
// 无儿童时不执行删除;删完后保留首项索引,兼容重新添加儿童。
|
||||
if (this.formData.mobileColumnsValue.length === 0) return
|
||||
this.formData.mobileColumnsValue.splice(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() {
|
||||
//获取家属是多少人
|
||||
@@ -478,12 +491,33 @@ const signForm = {
|
||||
this.mergeMobileColumnsValue(courseType, signUpData.mobileColumnsValue))
|
||||
}
|
||||
this.$set(this, "formData", formData)
|
||||
// 不同活动共用报名组件,每次打开都选中首个儿童,避免沿用上次的标签状态。
|
||||
this.$set(this, "active", "0")
|
||||
this.$nextTick(() => {
|
||||
this.$refs.form.clearValidate()
|
||||
})
|
||||
},
|
||||
},
|
||||
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 {
|
||||
margin: 0 0 15px !important;
|
||||
}
|
||||
|
||||
@@ -114,6 +114,24 @@ const basicForm = {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</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">
|
||||
<text-editor v-model="formData.introduce"></text-editor>
|
||||
</el-form-item>
|
||||
@@ -280,9 +298,14 @@ const basicForm = {
|
||||
|
||||
<div class="left-span-label" style="margin-top: 20px">判断家属数量唯一标识</div>
|
||||
<el-form-item label="唯一标识" prop="onlyKey">
|
||||
<el-select @focus="keyFocus" v-model="formData.onlyKey" placeholder="请选择唯一标识">
|
||||
<el-option v-for="item in typeArray" :key="item.columnCode" :label="item.columnName" :value="item.columnCode"></el-option>
|
||||
<el-select @focus="keyFocus" :value="formData.onlyKey" placeholder="请选择唯一标识" style="width: 420px"
|
||||
@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>
|
||||
<div style="color: #c64120; font-size: 12px">
|
||||
请选择本活动所有子活动类型共有且均设为必填的字段,各类型字段编码必须一致。有报名或预填后禁止直接更换。
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
@@ -290,8 +313,8 @@ const basicForm = {
|
||||
<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 === 1" type="primary" @click="step = 2">下一步</el-button>
|
||||
<el-button type="primary" @click="onSave">保存</el-button>
|
||||
<el-button type="primary" @click="onSubmit">提交</el-button>
|
||||
<el-button type="primary" :loading="formLoading" @click="onSave">保存</el-button>
|
||||
<el-button type="primary" :loading="formLoading" @click="onSubmit">提交</el-button>
|
||||
</div>
|
||||
|
||||
<el-dialog :close-on-click-modal="false" :visible.sync="signUpDialog" title="设置报名限制" append-to-body>
|
||||
@@ -325,6 +348,7 @@ const basicForm = {
|
||||
data() {
|
||||
return {
|
||||
signUpDialog: false,
|
||||
formLoading: false,
|
||||
step: 1,
|
||||
formData: {
|
||||
notice: false,
|
||||
@@ -348,7 +372,9 @@ const basicForm = {
|
||||
restrictLimit: 1,
|
||||
limitNum: 1,
|
||||
typeLimits: [],
|
||||
keyWord: '家属'
|
||||
keyWord: '家属',
|
||||
familyCountScope: 1,
|
||||
timeConflictCheck: false,
|
||||
},
|
||||
historicalActList: [],
|
||||
trainTypeList: [],
|
||||
@@ -372,28 +398,47 @@ const basicForm = {
|
||||
},
|
||||
methods: {
|
||||
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) {
|
||||
this.$set(this, 'typeArray', [])
|
||||
this.$message.warning('请先设置' + this.activityType + '类型')
|
||||
return
|
||||
}
|
||||
const typeList = this.courseTypeList.filter(o => types.includes(o.id))
|
||||
let array = []
|
||||
typeList.forEach(item => {
|
||||
array = array.concat(item.familyMobileSignColumnList)
|
||||
// 唯一标识必须在每种类型中各出现一次且均为必填,不限定字段的验证规则。
|
||||
const firstColumns = typeList[0] ? (typeList[0].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)
|
||||
}
|
||||
})
|
||||
// 可选:保留没有 columnCode 的项
|
||||
const withoutCode = array.filter(item => !item.columnCode)
|
||||
this.typeArray = [...uniqueMap.values(), ...withoutCode]
|
||||
this.$set(this, 'typeArray', options)
|
||||
},
|
||||
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 (row.courseReservedNumber > row.coursePeopleNumber && row.reserveMode === 1) {
|
||||
this.$alert("预留人数不能大于" + this.activityType + "人数!", "提示", {
|
||||
@@ -402,7 +447,7 @@ const basicForm = {
|
||||
row.courseReservedNumber = 0
|
||||
}
|
||||
} else {
|
||||
const registerUserCount = await this.getRegisterUserCount(row.id)
|
||||
return this.getRegisterUserCount(row.id).then((registerUserCount) => {
|
||||
if (row.courseReservedNumber + registerUserCount > row.coursePeopleNumber) {
|
||||
if (row.reserveMode === 1) {
|
||||
let num = row.coursePeopleNumber - registerUserCount
|
||||
@@ -422,11 +467,12 @@ const basicForm = {
|
||||
row.courseReservedNumber = 0
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
async getRegisterUserCount(courseId) {
|
||||
const resp = await this.$axios.post("/platform/family/manage/getRegisterUserCount", { courseId })
|
||||
return resp.code === 0 ? resp.data : 0
|
||||
getRegisterUserCount(courseId) {
|
||||
return this.$axios.post("/platform/family/manage/getRegisterUserCount", { courseId })
|
||||
.then((resp) => resp.code === 0 ? resp.data : 0)
|
||||
},
|
||||
courseTypeChange(val, row) {
|
||||
row.reserveMode = 1
|
||||
@@ -497,10 +543,11 @@ const basicForm = {
|
||||
addCourse() {
|
||||
this.formData.courseList.push({ courseTimeList: [], isMobileSign: false, isReceiveGift: false, reserveMode: 1, courseIsLimitApply: false })
|
||||
},
|
||||
async historicalActChange(val) {
|
||||
const resp = await this.$axios.post("/platform/family/manage/findOne", {id: val})
|
||||
historicalActChange(val) {
|
||||
return this.$axios.post("/platform/family/manage/findOne", {id: val}).then((resp) => {
|
||||
if (resp.code === 0) {
|
||||
this.formData = resp.data
|
||||
this.$set(this, "formData", resp.data)
|
||||
this.applyRuleDefaults()
|
||||
this.typeChange(this.formData.trainType)
|
||||
this.formData.id = ""
|
||||
this.formData.courseList.forEach((course) => {
|
||||
@@ -521,6 +568,7 @@ const basicForm = {
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
typeChange(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", "全部教职工")
|
||||
}
|
||||
},
|
||||
async getActivityGroup() {
|
||||
const { data } = await this.$axios.post("/platform/activity/basic/scope/getActivityUserScopeGroup")
|
||||
return data
|
||||
getActivityGroup() {
|
||||
return this.$axios.post("/platform/activity/basic/scope/getActivityUserScopeGroup").then((resp) => resp.data)
|
||||
},
|
||||
async getHistoricalActList() {
|
||||
const resp = await this.$axios.post("/platform/family/manage/getHistoricalActList", {})
|
||||
return resp.data
|
||||
getHistoricalActList() {
|
||||
return this.$axios.post("/platform/family/manage/getHistoricalActList", {}).then((resp) => resp.data)
|
||||
},
|
||||
async onSave() {
|
||||
onSave() {
|
||||
let valid = false
|
||||
this.$refs.form.validateField("activityName", (errMsg) => {
|
||||
valid = errMsg === ""
|
||||
@@ -551,10 +597,10 @@ const basicForm = {
|
||||
this.$message.warning("请输入活动名称")
|
||||
return
|
||||
}
|
||||
await this.doHandle('保存')
|
||||
return this.doHandle('保存')
|
||||
},
|
||||
onSubmit() {
|
||||
this.$refs["form"].validate(async (valid, errMsg) => {
|
||||
this.$refs["form"].validate((valid, errMsg) => {
|
||||
if (valid) {
|
||||
const courseValid = this.formData.courseList.some((v, i) => {
|
||||
const basicValid = v.courseName && v.coursePeopleNumber && v.courseLocation && v.courseInstructor && v.courseType
|
||||
@@ -599,7 +645,7 @@ const basicForm = {
|
||||
return
|
||||
}
|
||||
this.formData.isDisabled = false
|
||||
await this.doHandle('提交')
|
||||
this.doHandle('提交')
|
||||
} else {
|
||||
if(Object.keys(errMsg).length > 0) {
|
||||
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)
|
||||
cloneData.activitySignUpStartTime = cloneData.activitySignTime !== undefined ? cloneData.activitySignTime[0] : null
|
||||
cloneData.activitySignUpEndTime = cloneData.activitySignTime !== undefined ? cloneData.activitySignTime[1] : null
|
||||
@@ -620,13 +666,13 @@ const basicForm = {
|
||||
}
|
||||
cloneData.typeLimits = JSON.stringify(this.formData.typeLimits)
|
||||
cloneData.courseList = JSON.stringify(cloneData.courseList)
|
||||
const confirm = await this.$confirm("您确定要" + type + "吗?", "提示", {
|
||||
return this.$confirm("您确定要" + type + "吗?", "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning"
|
||||
})
|
||||
if (confirm !== "confirm") return
|
||||
const resp = await this.$axios.post("/platform/family/manage/doHandle", cloneData)
|
||||
}).then(() => {
|
||||
this.formLoading = true
|
||||
return this.$axios.post("/platform/family/manage/doHandle", cloneData).then((resp) => {
|
||||
if (resp.code === 0) {
|
||||
this.$message.success(resp.msg)
|
||||
this.step = 1
|
||||
@@ -635,26 +681,35 @@ const basicForm = {
|
||||
} else {
|
||||
this.$message.warning(resp.msg)
|
||||
}
|
||||
}).finally(() => {
|
||||
this.formLoading = false
|
||||
})
|
||||
}).catch(() => {})
|
||||
},
|
||||
async getAllType() {
|
||||
const resp = await this.$axios.post("/platform/family/type/getAllType", {})
|
||||
return resp.data
|
||||
getAllType() {
|
||||
return this.$axios.post("/platform/family/type/getAllType", {}).then((resp) => resp.data)
|
||||
},
|
||||
async init(row) {
|
||||
if (row && row.id) {
|
||||
const resp = await this.$axios.post("/platform/family/manage/findOne", {id: row.id})
|
||||
init(row) {
|
||||
if (!row || !row.id) {
|
||||
this.applyRuleDefaults()
|
||||
return Promise.resolve()
|
||||
}
|
||||
return this.$axios.post("/platform/family/manage/findOne", {id: row.id}).then((resp) => {
|
||||
if (resp.code === 0) {
|
||||
this.formData = resp.data
|
||||
this.$set(this, "formData", resp.data)
|
||||
this.typeChange(this.formData.trainType)
|
||||
if(this.formData.onlyKey) this.keyFocus()
|
||||
}
|
||||
this.applyRuleDefaults()
|
||||
}
|
||||
})
|
||||
},
|
||||
async initData(row) {
|
||||
this.activityGroupList = await this.getActivityGroup()
|
||||
this.historicalActList = await this.getHistoricalActList()
|
||||
this.courseTypeList = await this.getAllType()
|
||||
await this.init(row)
|
||||
initData(row) {
|
||||
return Promise.all([this.getActivityGroup(), this.getHistoricalActList(), this.getAllType()])
|
||||
.then(([groups, activities, types]) => {
|
||||
this.$set(this, "activityGroupList", groups)
|
||||
this.$set(this, "historicalActList", activities)
|
||||
this.$set(this, "courseTypeList", types)
|
||||
return this.init(row)
|
||||
})
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
@@ -664,9 +719,11 @@ const basicForm = {
|
||||
}
|
||||
},
|
||||
'formData.activityGroupId': {
|
||||
async handler(newVal, oldVal) {
|
||||
this.activityGroupList = await this.getActivityGroup()
|
||||
this.userScopeDialog = false
|
||||
handler(newVal, oldVal) {
|
||||
this.getActivityGroup().then((groups) => {
|
||||
this.$set(this, 'activityGroupList', groups)
|
||||
this.$set(this, 'userScopeDialog', false)
|
||||
})
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
|
||||
@@ -11,6 +11,10 @@ const info = {
|
||||
<el-descriptions-item label="活动结束时间">{{viewData.activityEndTime}}</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.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">
|
||||
<div v-html="viewData.introduce"></div>
|
||||
</el-descriptions-item>
|
||||
@@ -54,6 +58,7 @@ const info = {
|
||||
<el-dialog :visible.sync="viewMapDialog" title="地点" width="60%">
|
||||
<div id="viewMap" style="width: 100%; height: 500px"></div>
|
||||
</el-dialog>
|
||||
<slot></slot>
|
||||
</div>
|
||||
`,
|
||||
dicts: ["FAMILY_SIGNUP_TYPE"],
|
||||
@@ -72,7 +77,7 @@ const info = {
|
||||
this.$axios.post("/platform/family/manage/findOne", { id: id })
|
||||
.then((resp) => {
|
||||
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)
|
||||
this.activityType = type?.name || "子活动"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ layout("/layouts/platform.html"){
|
||||
#-->
|
||||
|
||||
<div id="app" v-cloak>
|
||||
<guava ref="guava">
|
||||
<guava ref="guava" @hook:mounted="onPageVisible">
|
||||
<el-card shadow="never">
|
||||
<search @search="doSearch">
|
||||
<search-item label="年度:">
|
||||
@@ -36,7 +36,8 @@ layout("/layouts/platform.html"){
|
||||
<el-button @click="exportSignPerson" type="primary" size="small">导出签到名单</el-button>
|
||||
<el-button @click="exportGiftPerson" type="primary" size="small">导出领取名单</el-button>
|
||||
</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
|
||||
:label="column.prop === 'courseNames' ? trainType : column.label"
|
||||
@@ -98,15 +99,42 @@ layout("/layouts/platform.html"){
|
||||
new Vue({
|
||||
el: "#app",
|
||||
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() {
|
||||
return {
|
||||
reserveDialogVisible: false,
|
||||
reserveTableData: [],
|
||||
activityList: [],
|
||||
pageForm: {
|
||||
pageSize: 50,
|
||||
year: new Date().getFullYear().toString(),
|
||||
activityId: null
|
||||
},
|
||||
tableHeight: 300,
|
||||
tableColumns: [
|
||||
{ label: "姓名", prop: "username" },
|
||||
{ label: "工号", prop: "loginname" },
|
||||
@@ -139,6 +167,55 @@ layout("/layouts/platform.html"){
|
||||
}
|
||||
},
|
||||
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) {
|
||||
this.multipleSelection = val
|
||||
},
|
||||
@@ -259,6 +336,16 @@ layout("/layouts/platform.html"){
|
||||
})
|
||||
</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>
|
||||
|
||||
<!--#
|
||||
}
|
||||
#-->
|
||||
|
||||
@@ -11,6 +11,7 @@ const AU_INFO_TEMPLATE = {
|
||||
<el-descriptions-item label="主题类型">{{viewData.subjectName}}</el-descriptions-item>
|
||||
<el-descriptions-item label="作品类型" :span="2">{{viewData.worksName}}</el-descriptions-item>
|
||||
<el-descriptions-item label="作品描述" :span="3">{{viewData.description}}</el-descriptions-item>
|
||||
<el-descriptions-item label="收货地址" :span="3">{{viewData.shippingAddress || '—'}}</el-descriptions-item>
|
||||
<el-descriptions-item label="作品附件" :span="3">
|
||||
<template slot="label">作品附件</template>
|
||||
<file-preview complete_result :files="viewData.files"></file-preview>
|
||||
|
||||
@@ -107,12 +107,14 @@ layout("/layouts/platform.html"){
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="主题类型" prop="subjectTypes">
|
||||
<el-row class="pb5" v-for="(type,index) in formData.subjectTypes" :key="type.id">
|
||||
<el-col :span="20">
|
||||
<el-input placeholder="请输入名称" v-model="type.typeName"></el-input>
|
||||
<template v-for="(type,index) in formData.subjectTypes">
|
||||
<el-form-item :key="'subject-' + (type.id || index)" label="主题类型"
|
||||
:prop="index === 0 ? 'subjectTypes' : undefined">
|
||||
<el-row>
|
||||
<el-col :span="18">
|
||||
<el-input placeholder="请输入主题类型" v-model="type.typeName"></el-input>
|
||||
</el-col>
|
||||
<el-col :span="2" style="text-align: center">
|
||||
<el-col :span="2" :offset="2" style="text-align: center">
|
||||
<el-button @click="addType(index)" icon="el-icon-plus" type="primary"></el-button>
|
||||
</el-col>
|
||||
<el-col :span="2" style="text-align: center">
|
||||
@@ -123,16 +125,12 @@ layout("/layouts/platform.html"){
|
||||
type="danger"
|
||||
></el-button>
|
||||
</el-col>
|
||||
<el-col class="pt5 pb5">
|
||||
</el-row>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="type.worksTypes.length" :key="'works-' + (type.id || index)" label="作品类型">
|
||||
<el-row class="pb5" v-for="(worksType,worksIndex) in type.worksTypes" :key="worksType.id">
|
||||
<el-col :span="18">
|
||||
<el-col :span="4">
|
||||
<span class="el-form-item__label" v-if="worksIndex === 0">作品类型</span>
|
||||
<span v-else> </span>
|
||||
</el-col>
|
||||
<el-col :span="20">
|
||||
<el-input placeholder="请输入名称" v-model="worksType.worksTypeName"></el-input>
|
||||
</el-col>
|
||||
<el-input placeholder="请输入作品类型" v-model="worksType.worksTypeName"></el-input>
|
||||
</el-col>
|
||||
<el-col :span="2" style="text-align: center">
|
||||
<el-button @click="openSetting(worksType)" icon="el-icon-s-tools"
|
||||
@@ -147,9 +145,8 @@ layout("/layouts/platform.html"){
|
||||
type="danger"></el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item label="面向对象" prop="joinCnd">
|
||||
<div style="display: flex; justify-content: space-between">
|
||||
<div style="width: 99%">
|
||||
@@ -213,7 +210,8 @@ layout("/layouts/platform.html"){
|
||||
<el-dialog :close-on-click-modal="false" :visible.sync="settingDialogVisible" title="更多设置" top="4%" width="40%">
|
||||
<el-form :model="currentWorksType" label-width="160px" size="small">
|
||||
<el-form-item label="作品介绍最多字数">
|
||||
<el-input-number :max="10000" :min="1" style="width: 100%"
|
||||
<el-input-number :max="100" :min="1" :precision="0" style="width: 100%"
|
||||
placeholder="请输入作品介绍最多字数"
|
||||
v-model="currentWorksType.maxWordCount"></el-input-number>
|
||||
</el-form-item>
|
||||
|
||||
@@ -233,17 +231,20 @@ layout("/layouts/platform.html"){
|
||||
|
||||
<el-form-item label="允许上传的文件数量" prop="allowFileNum">
|
||||
<el-input-number :max="10000" :min="1" style="width: 100%"
|
||||
v-model="currentWorksType.allowFileNum"></el-input-number>
|
||||
placeholder="请输入允许上传的文件数量"
|
||||
v-model="currentWorksType.allowFileNum" :precision="0"></el-input-number>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="单个文件大小(kb)" prop="allowSingleFileSize">
|
||||
<el-input-number :min="1" style="width: 100%"
|
||||
v-model="currentWorksType.allowSingleFileSize"></el-input-number>
|
||||
placeholder="请输入单个文件大小上限(KB)"
|
||||
v-model="currentWorksType.allowSingleFileSize" :precision="0"></el-input-number>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="总文件大小(kb)" prop="allowTotalFileSize">
|
||||
<el-input-number :min="1" style="width: 100%"
|
||||
v-model="currentWorksType.allowTotalFileSize"></el-input-number>
|
||||
placeholder="请输入文件总大小上限(KB)"
|
||||
v-model="currentWorksType.allowTotalFileSize" :precision="0"></el-input-number>
|
||||
</el-form-item>
|
||||
|
||||
<!-- <div class="left-span-label">上传图片设置</div>-->
|
||||
@@ -301,7 +302,7 @@ layout("/layouts/platform.html"){
|
||||
|
||||
<el-row class="mt20" justify="end" type="flex">
|
||||
<el-button @click="settingDialogVisible=false">取消</el-button>
|
||||
<el-button @click="settingDialogVisible = false" type="primary">确定</el-button>
|
||||
<el-button @click="applyWorksTypeSettings" type="primary">确定</el-button>
|
||||
</el-row>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@@ -325,6 +326,7 @@ layout("/layouts/platform.html"){
|
||||
allowFileTypes: [],
|
||||
},
|
||||
settingDialogVisible: false,
|
||||
editingWorksType: null,
|
||||
colorList: ["#081776", "#31cab1", "#e54242", "#ff8939", "#ffb712", "#1dc47b", "#4495f7", "#ff5e8b", "#5d6e7f"],
|
||||
pageForm: {
|
||||
activityTime: []
|
||||
@@ -354,9 +356,30 @@ layout("/layouts/platform.html"){
|
||||
this.formData.subjectTypes.splice(index + 1, 0, {worksTypes: [item]})
|
||||
},
|
||||
openSetting(worksType) {
|
||||
this.currentWorksType = worksType
|
||||
this.editingWorksType = worksType
|
||||
this.currentWorksType = { ...worksType, allowFileTypes: [...(worksType.allowFileTypes || [])] }
|
||||
this.settingDialogVisible = true
|
||||
},
|
||||
applyWorksTypeSettings() {
|
||||
if (!this.validateFileSizeLimits(this.currentWorksType)) return
|
||||
Object.keys(this.currentWorksType).forEach(key => {
|
||||
this.$set(this.editingWorksType, key, this.currentWorksType[key])
|
||||
})
|
||||
this.settingDialogVisible = false
|
||||
},
|
||||
validateFileSizeLimits(worksType) {
|
||||
const { allowSingleFileSize, allowTotalFileSize } = worksType
|
||||
if (allowSingleFileSize > 0 && allowTotalFileSize > 0 && allowTotalFileSize < allowSingleFileSize) {
|
||||
const name = worksType.worksTypeName
|
||||
this.$message.warning((name ? "「" + name + "」:" : "") + "总文件大小必须大于或等于单个文件大小")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
validateWorksTypeSettings() {
|
||||
return (this.formData.subjectTypes || []).every(subject =>
|
||||
(subject.worksTypes || []).every(worksType => this.validateFileSizeLimits(worksType)))
|
||||
},
|
||||
addWorksType(type, worksIndex) {
|
||||
let item = {}
|
||||
this.initData(item)
|
||||
@@ -405,13 +428,16 @@ layout("/layouts/platform.html"){
|
||||
this.$refs.guava.index()
|
||||
},
|
||||
doSubmit() {
|
||||
if (!this.validateWorksTypeSettings()) return
|
||||
this.$refs.formRef.validate((valid) => {
|
||||
if (valid) {
|
||||
this.formData.startDateTime = this.formData.activityTime[0]
|
||||
this.formData.endDateTime = this.formData.activityTime[1]
|
||||
this.formData.nbStartDateTime = this.formData.nbTime[0]
|
||||
this.formData.nbEndDateTime = this.formData.nbTime[1]
|
||||
const [nbStartDateTime = null, nbEndDateTime = null] = this.formData.nbTime || []
|
||||
this.formData.nbStartDateTime = nbStartDateTime
|
||||
this.formData.nbEndDateTime = nbEndDateTime
|
||||
this.formData.type = "1"
|
||||
this.formData.isSubmit = true
|
||||
let loading = this.$loading({
|
||||
lock: true,
|
||||
text: "数据正在提交中,请稍后...",
|
||||
@@ -432,11 +458,14 @@ layout("/layouts/platform.html"){
|
||||
})
|
||||
},
|
||||
doSave() {
|
||||
if (!this.validateWorksTypeSettings()) return
|
||||
this.formData.startDateTime = this.formData.activityTime[0]
|
||||
this.formData.endDateTime = this.formData.activityTime[1]
|
||||
this.formData.nbStartDateTime = this.formData.nbTime[0]
|
||||
this.formData.nbEndDateTime = this.formData.nbTime[1]
|
||||
const [nbStartDateTime = null, nbEndDateTime = null] = this.formData.nbTime || []
|
||||
this.formData.nbStartDateTime = nbStartDateTime
|
||||
this.formData.nbEndDateTime = nbEndDateTime
|
||||
this.formData.type = "1"
|
||||
this.formData.isSubmit = false
|
||||
let loading = this.$loading({
|
||||
lock: true,
|
||||
text: "数据正在保存中,请稍后...",
|
||||
|
||||
@@ -62,6 +62,9 @@ layout("/layouts/platform.html"){
|
||||
<el-table-column label="工号" prop="loginName"></el-table-column>
|
||||
<el-table-column label="单位" prop="unitName"></el-table-column>
|
||||
<el-table-column label="分工会" prop="unionName"></el-table-column>
|
||||
<el-table-column label="收货地址" prop="shippingAddress" min-width="240" show-overflow-tooltip>
|
||||
<template slot-scope="{row}">{{row.shippingAddress || '—'}}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上传时间" prop="createdAt">
|
||||
<template scope="scope">
|
||||
<span>{{$moment(scope.row.createdAt).format( 'YYYY-MM-DD')}}</span>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
const AU_FORM_TEMPLATE = {
|
||||
template: /*language=HTML*/ `
|
||||
<div>
|
||||
<el-dialog :title="formData.id ? '编辑':'新增'" :visible.sync="dialogFormVisible" width="60%"
|
||||
:close-on-click-modal="false">
|
||||
<el-form :model="formData" ref="formRef" size="small" label-width="80px" :rules="formRules">
|
||||
<el-form-item label="活动主题" prop="activityId">
|
||||
<el-select clearable placeholder="请输入内容" v-model="formData.activityId" @change="activityChange"
|
||||
<el-select clearable placeholder="请输入内容" v-model="formData.activityId" @change="changeActivity"
|
||||
style="width: 100%">
|
||||
<el-option v-for="item in activityOptions" :key="item.id" :label="item.name"
|
||||
:value="item.id"></el-option>
|
||||
@@ -12,7 +13,7 @@ const AU_FORM_TEMPLATE = {
|
||||
</el-form-item>
|
||||
<el-form-item label="主题类型" prop="subjectId">
|
||||
<el-select clearable placeholder="请选择主题类型" v-model="formData.subjectId"
|
||||
@change="subjectChange" style="width: 100%">
|
||||
@change="changeSubject" style="width: 100%">
|
||||
<el-option v-for="item in subjectTypesOptions" :key="item.id" :label="item.typeName"
|
||||
:value="item.id"></el-option>
|
||||
</el-select>
|
||||
@@ -28,9 +29,19 @@ const AU_FORM_TEMPLATE = {
|
||||
<el-input v-model="formData.name" clearable placeholder="请输入作品名称"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="作品描述" prop="description">
|
||||
<el-input v-model="formData.description" type="textarea" :autosize="{ minRows: 2, maxRows: 4}"
|
||||
<el-input v-model="formData.description" :maxlength="descriptionLimit" show-word-limit type="textarea" :autosize="{ minRows: 2, maxRows: 4}"
|
||||
clearable placeholder="请输入作品描述"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="收货地址" required>
|
||||
<el-select :value="shippingAddress ? shippingAddress.id : ''" placeholder="请选择收货地址"
|
||||
:disabled="submitting" :loading="addressLoading" style="width: 100%" @change="changeShippingAddress">
|
||||
<el-option v-for="row in addressOptions" :key="row.id" :label="addressText(row)"
|
||||
:value="row.id" :disabled="!validPhone(row.tel)"></el-option>
|
||||
<el-option label="管理收货地址" value="manage">
|
||||
<span style="color: #409eff"><i class="el-icon-setting"></i> 管理收货地址</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="附件" prop="files">
|
||||
<file-upload
|
||||
:value.sync="formData.files"
|
||||
@@ -39,19 +50,29 @@ const AU_FORM_TEMPLATE = {
|
||||
upload_result_category="array"
|
||||
complete_result
|
||||
:accept="fileAccept"
|
||||
:validate_file="validateUploadFile"
|
||||
></file-upload>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button @click="dialogFormVisible = false">取 消</el-button>
|
||||
<el-button type="primary" @click="doSubmit">提 交</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="doSubmit">提 交</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
<shipping-address ref="shippingAddress" @selected="shippingAddress = $event"
|
||||
@addresses-updated="refreshShippingAddress"></shipping-address>
|
||||
</div>
|
||||
`,
|
||||
components: { "shipping-address": WORKS_SHIPPING_ADDRESS },
|
||||
data() {
|
||||
return {
|
||||
dialogFormVisible: false,
|
||||
formData: {},
|
||||
shippingAddress: null,
|
||||
addressOptions: [],
|
||||
addressLoading: false,
|
||||
addressRequest: 0,
|
||||
submitting: false,
|
||||
formRules: {
|
||||
activityId: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
|
||||
subjectId: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
|
||||
@@ -65,25 +86,94 @@ const AU_FORM_TEMPLATE = {
|
||||
worksTypeOptions: [],
|
||||
|
||||
chooseWorksType: {},
|
||||
upload_text: "",
|
||||
fileAccept: null
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
computed: {
|
||||
descriptionLimit() {
|
||||
const limit = this.chooseWorksType.maxWordCount
|
||||
return limit > 0 ? Math.min(limit, 100) : 100
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
worksTypeChange(val) {
|
||||
this.chooseWorksType = this.worksTypeOptions.find((o) => o.id === val)
|
||||
this.fileUploadNum = this.chooseWorksType.allowFileNum
|
||||
if (this.chooseWorksType.allowFileTypes && this.chooseWorksType.allowFileTypes.length > 0) {
|
||||
const allowFileTypes = this.chooseWorksType.allowFileTypes.join(",")
|
||||
this.upload_text = "请上传<span style='color: red'>" + allowFileTypes + "</span>格式的文件"
|
||||
this.fileAccept = this.chooseWorksType.allowFileTypes.map((item) => "." + item).join(",")
|
||||
this.chooseWorksType = this.worksTypeOptions.find(option => option.id === val) || {}
|
||||
this.fileAccept = (this.chooseWorksType.allowFileTypes || []).map(type => "." + type).join(",")
|
||||
},
|
||||
attachmentError(files) {
|
||||
const type = this.chooseWorksType
|
||||
const extensions = (type.allowFileTypes || []).map(value => value.toLowerCase())
|
||||
if (type.allowFileNum > 0 && files.length > type.allowFileNum) {
|
||||
return "最多只能上传 " + type.allowFileNum + " 个附件"
|
||||
}
|
||||
let totalBytes = 0
|
||||
for (const file of files) {
|
||||
const name = file.name || ""
|
||||
const extension = name.includes(".") ? name.split(".").pop().toLowerCase() : ""
|
||||
if (extensions.length && !extensions.includes(extension)) {
|
||||
return "附件格式不符合要求,可上传 " + extensions.join("、")
|
||||
}
|
||||
const size = Number(file.size) || 0
|
||||
if (type.allowSingleFileSize > 0 && size > type.allowSingleFileSize * 1024) {
|
||||
return "单个附件不能超过 " + type.allowSingleFileSize + " KB"
|
||||
}
|
||||
totalBytes += size
|
||||
}
|
||||
if (type.allowTotalFileSize > 0 && totalBytes > type.allowTotalFileSize * 1024) {
|
||||
return "附件总大小不能超过 " + type.allowTotalFileSize + " KB"
|
||||
}
|
||||
return ""
|
||||
},
|
||||
validateUploadFile(file, files) {
|
||||
if (!this.formData.worksId) {
|
||||
this.$message.warning("请先选择作品类型")
|
||||
return false
|
||||
}
|
||||
const error = this.attachmentError([...files.filter(item => file.uid == null || item.uid !== file.uid), file])
|
||||
if (error) this.$message.warning(error)
|
||||
return !error
|
||||
},
|
||||
addressText(row) {
|
||||
return [row.userName, row.tel, [row.province, row.city, row.county, row.addressDetail].filter(Boolean).join("")].filter(Boolean).join(" ")
|
||||
},
|
||||
validPhone(value) {
|
||||
return /^1[3-9][0-9]{9}$/.test(value)
|
||||
},
|
||||
changeShippingAddress(id) {
|
||||
if (id === "manage") {
|
||||
this.$refs.shippingAddress.open(this.shippingAddress)
|
||||
} else {
|
||||
this.upload_text = ""
|
||||
this.shippingAddress = this.addressOptions.find(row => row.id === id) || null
|
||||
}
|
||||
},
|
||||
refreshShippingAddress(addresses) {
|
||||
this.addressRequest++
|
||||
this.addressLoading = false
|
||||
this.addressOptions = addresses
|
||||
if (this.shippingAddress) {
|
||||
this.shippingAddress = addresses.find(row => row.id === this.shippingAddress.id) || null
|
||||
}
|
||||
},
|
||||
async loadAddressOptions(request) {
|
||||
this.addressLoading = true
|
||||
try {
|
||||
const { data } = await this.$axios.post("/platform/activity/worksCollection/addressManage/selectUserAddress")
|
||||
if (request === this.addressRequest) {
|
||||
this.addressOptions = data
|
||||
this.shippingAddress = data.find(row => row.isDefault && this.validPhone(row.tel)) || null
|
||||
}
|
||||
} catch (error) {
|
||||
// 地址选择弹窗可重新加载地址。
|
||||
} finally {
|
||||
if (request === this.addressRequest) this.addressLoading = false
|
||||
}
|
||||
},
|
||||
onOpen(id) {
|
||||
this.shippingAddress = null
|
||||
this.addressOptions = []
|
||||
this.loadAddressOptions(++this.addressRequest)
|
||||
this.dialogFormVisible = true
|
||||
this.worksTypeChange(null)
|
||||
if (id) {
|
||||
this.$axios.post(loc() + "/findOne", { id }).then(async (res) => {
|
||||
if (res.code === 0) {
|
||||
@@ -113,21 +203,20 @@ const AU_FORM_TEMPLATE = {
|
||||
// }
|
||||
// },
|
||||
doSubmit() {
|
||||
// if (
|
||||
// this.formData.files &&
|
||||
// this.formData.files.length > 0 &&
|
||||
// this.chooseWorksType.allowFileTypes &&
|
||||
// this.chooseWorksType.allowFileTypes.length > 0
|
||||
// ) {
|
||||
// for (let i = 1; i <= this.formData.files.length; i++) {
|
||||
// const file = this.formData.files[i - 1]
|
||||
// const suffixName = this.getFileExtension(file.name)
|
||||
// if (!this.chooseWorksType.allowFileTypes.includes(suffixName)) {
|
||||
// this.$message.warning("此次活动不支持上传" + suffixName + "格式的文件")
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
if (this.submitting) return
|
||||
if (!this.shippingAddress || !this.validPhone(this.shippingAddress.tel)) {
|
||||
this.$message.warning("请选择手机号有效的收货地址")
|
||||
return
|
||||
}
|
||||
if ((this.formData.description || "").length > this.descriptionLimit) {
|
||||
this.$message.warning("作品描述最多 " + this.descriptionLimit + " 个字")
|
||||
return
|
||||
}
|
||||
const attachmentError = this.attachmentError(this.formData.files || [])
|
||||
if (attachmentError) {
|
||||
this.$message.warning(attachmentError)
|
||||
return
|
||||
}
|
||||
|
||||
this.$confirm("您确定要提交吗?", "提示", {
|
||||
confirmButtonText: "确定",
|
||||
@@ -136,12 +225,20 @@ const AU_FORM_TEMPLATE = {
|
||||
}).then(() => {
|
||||
this.$refs.formRef.validate((valid) => {
|
||||
if (valid) {
|
||||
this.$axios.post(loc() + (this.formData.id ? "/update" : "/insert"), { data: JSON.stringify(this.formData) }).then((res) => {
|
||||
const editing = !!this.formData.id
|
||||
if (this.submitting) return
|
||||
this.submitting = true
|
||||
this.$axios.post(loc() + (editing ? "/update" : "/insert"), {
|
||||
data: JSON.stringify(this.formData),
|
||||
addressId: this.shippingAddress.id
|
||||
}).then((res) => {
|
||||
if (res.code === 0) {
|
||||
this.dialogFormVisible = false
|
||||
this.$message.success(res.msg)
|
||||
this.$emit("refresh")
|
||||
}
|
||||
}).finally(() => {
|
||||
this.submitting = false
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -155,16 +252,28 @@ const AU_FORM_TEMPLATE = {
|
||||
}
|
||||
})
|
||||
},
|
||||
changeActivity(value) {
|
||||
this.$set(this.formData, "subjectId", null)
|
||||
this.changeSubject(null)
|
||||
this.subjectTypesOptions = []
|
||||
if (value) this.activityChange(value)
|
||||
},
|
||||
changeSubject(value) {
|
||||
this.$set(this.formData, "worksId", null)
|
||||
this.worksTypeOptions = []
|
||||
this.worksTypeChange(null)
|
||||
if (value) this.subjectChange(value)
|
||||
},
|
||||
activityChange(value) {
|
||||
this.$axios.post("/platform/activity/worksCollection/common/getSubjectTypes", { activityId: value }).then((res) => {
|
||||
if (res.code === 0) {
|
||||
if (res.code === 0 && this.formData.activityId === value) {
|
||||
this.subjectTypesOptions = res.data
|
||||
}
|
||||
})
|
||||
},
|
||||
async subjectChange(value) {
|
||||
const resp = await this.$axios.post("/platform/activity/worksCollection/common/getWorksTypes", { subjectId: value })
|
||||
if (resp.code === 0) {
|
||||
if (resp.code === 0 && this.formData.subjectId === value) {
|
||||
this.worksTypeOptions = resp.data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ layout("/layouts/platform.html"){
|
||||
<el-table-column label="工号" prop="loginName"></el-table-column>
|
||||
<el-table-column label="单位" prop="unitName"></el-table-column>
|
||||
<el-table-column label="分工会" prop="unionName"></el-table-column>
|
||||
<el-table-column label="收货地址" prop="shippingAddress" min-width="240" show-overflow-tooltip>
|
||||
<template slot-scope="{row}">{{row.shippingAddress || '—'}}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上传时间" prop="createdAt">
|
||||
<template scope="scope">
|
||||
<span>{{$moment(scope.row.createdAt).format('YYYY-MM-DD')}}</span>
|
||||
@@ -69,6 +72,7 @@ layout("/layouts/platform.html"){
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<!--#include("shippingAddress.js"){}#-->
|
||||
<!--#include("auForm.js"){}#-->
|
||||
<!--#include("../common/info.js"){}#-->
|
||||
new Vue({
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
<!--#include("/platform/zhgh/welfare/addressManage/addressDialog.js"){}#-->
|
||||
const WORKS_SHIPPING_ADDRESS = {
|
||||
template: /*language=HTML*/ `
|
||||
<div>
|
||||
<el-dialog title="选择收货地址" :visible.sync="visible" width="580px" append-to-body
|
||||
:close-on-click-modal="false">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px">
|
||||
<p style="margin: 0; color: #606266">请选择活动纪念品的收货地址。</p>
|
||||
<el-button type="primary" size="small" :disabled="loading" @click="addAddress">新增地址</el-button>
|
||||
</div>
|
||||
<div v-loading="loading">
|
||||
<el-radio-group v-if="addresses.length" v-model="selectedId" style="display: grid; gap: 10px; max-height: 45vh; overflow-y: auto">
|
||||
<div v-for="row in addresses" :key="row.id" @click="selectedId = row.id"
|
||||
:style="{ borderColor: selectedId === row.id ? '#409eff' : '#dcdfe6' }"
|
||||
style="padding: 12px; border: 1px solid; border-radius: 6px; cursor: pointer">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px">
|
||||
<el-radio :label="row.id">
|
||||
{{ row.userName }} {{ row.tel }}
|
||||
<el-tag v-if="row.isDefault" size="mini">默认</el-tag>
|
||||
</el-radio>
|
||||
<el-button type="text" icon="el-icon-edit" title="编辑地址" aria-label="编辑地址"
|
||||
:disabled="loading" @click.stop="editAddress(row)"></el-button>
|
||||
</div>
|
||||
<div style="padding: 4px 0 0 24px; overflow-wrap: anywhere">
|
||||
{{ [row.province, row.city, row.county, row.addressDetail].filter(Boolean).join(' ') }}
|
||||
<div v-if="!validPhone(row.tel)" style="color: #f56c6c">请先修改手机号码</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-radio-group>
|
||||
<p v-else-if="!loading">{{ failed ? '地址加载失败' : '暂无收货地址' }}</p>
|
||||
</div>
|
||||
<div slot="footer">
|
||||
<el-button v-if="failed" @click="loadAddresses">重新加载</el-button>
|
||||
<el-button type="primary" :disabled="!selectedAddress || !validPhone(selectedAddress.tel) || loading || failed"
|
||||
@click="confirmAddress">确定</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
<address-dialog ref="addressDialog" :address-api="addressApi"
|
||||
@select_user_address="loadAddresses"></address-dialog>
|
||||
</div>
|
||||
`,
|
||||
components: {
|
||||
"address-dialog": ADDRESS_DIALOG
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
addressApi: "/platform/activity/worksCollection/addressManage",
|
||||
visible: false,
|
||||
addresses: [],
|
||||
selectedId: "",
|
||||
loading: false,
|
||||
failed: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selectedAddress() {
|
||||
return this.addresses.find(row => row.id === this.selectedId)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
validPhone(value) {
|
||||
return /^1[3-9][0-9]{9}$/.test(value)
|
||||
},
|
||||
open(address) {
|
||||
this.visible = true
|
||||
this.addresses = []
|
||||
this.selectedId = address ? address.id : ""
|
||||
this.loadAddresses()
|
||||
},
|
||||
async loadAddresses() {
|
||||
this.loading = true
|
||||
this.failed = false
|
||||
try {
|
||||
const response = await this.$axios.post(this.addressApi + "/selectUserAddress")
|
||||
this.addresses = response.data
|
||||
if (!this.selectedAddress) this.selectedId = ""
|
||||
this.$emit("addresses-updated", this.addresses)
|
||||
} catch (error) {
|
||||
this.failed = true
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
addAddress() {
|
||||
const dialog = this.$refs.addressDialog
|
||||
dialog.formData = {}
|
||||
dialog.dialogVisible = true
|
||||
},
|
||||
editAddress(row) {
|
||||
this.$refs.addressDialog.openEdit({ ...row })
|
||||
},
|
||||
confirmAddress() {
|
||||
if (!this.selectedAddress || !this.validPhone(this.selectedAddress.tel)) return
|
||||
this.$emit("selected", this.selectedAddress)
|
||||
this.visible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,14 +46,14 @@ layout("/layouts/platform.html"){
|
||||
<el-radio-button :label="false">未审核</el-radio-button>
|
||||
</el-radio-group>
|
||||
</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 prop="userName" 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="unitName" 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="申请模式">
|
||||
<template slot-scope="scope">
|
||||
<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 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="操作" width="300px">
|
||||
<el-table-column label="操作" width="160px">
|
||||
<template slot-scope="{row}">
|
||||
<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">
|
||||
@@ -131,6 +131,21 @@ layout("/layouts/platform.html"){
|
||||
}
|
||||
},
|
||||
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) {
|
||||
this.$refs.guava.public(() => {
|
||||
this.$refs.infoRef.onOpen(row.id)
|
||||
@@ -178,10 +193,15 @@ layout("/layouts/platform.html"){
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
this.tableLoading = true
|
||||
try {
|
||||
this.unitList = await this.$businessTool.listUnit()
|
||||
this.unionList = await this.$businessTool.listUnion()
|
||||
this.clubList = await this.$businessTool.listClub()
|
||||
this.pageData()
|
||||
await this.pageData()
|
||||
} finally {
|
||||
this.tableLoading = false
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -41,12 +41,13 @@ let ADDRESS_DIALOG = {
|
||||
v-model="formData.userName"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
:rules="{required:true,message: '请填写手机号码', trigger: 'blur'}"
|
||||
:rules="[{ required: true, message: '请填写手机号码', trigger: 'blur' },
|
||||
{ pattern: /^1[3-9][0-9]{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }]"
|
||||
label="手机号码"
|
||||
prop="tel">
|
||||
<el-input maxlength="11" placeholder="请填写手机号码"
|
||||
type="tel"
|
||||
v-model="formData.tel"></el-input>
|
||||
v-model.trim="formData.tel"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
@@ -57,6 +58,12 @@ let ADDRESS_DIALOG = {
|
||||
</el-dialog>
|
||||
`,
|
||||
mixins: [initTableMixins],
|
||||
props: {
|
||||
addressApi: {
|
||||
type: String,
|
||||
default: "/platform/welfare/addressManage"
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dzOptions: chengShiList,
|
||||
@@ -90,7 +97,7 @@ let ADDRESS_DIALOG = {
|
||||
doSaveUserAddress() {
|
||||
this.$refs["addForm"].validate(async (valid) => {
|
||||
if (valid) {
|
||||
const resp = await this.$axios.post("/platform/welfare/addressManage/doSaveUserAddress", { data: JSON.stringify(this.formData) })
|
||||
const resp = await this.$axios.post(this.addressApi + "/doSaveUserAddress", { data: JSON.stringify(this.formData) })
|
||||
if (resp.code === 0) {
|
||||
this.$message({
|
||||
message: resp.msg,
|
||||
|
||||
@@ -7,6 +7,7 @@ layout("/layouts/platform.html"){
|
||||
<el-card shadow="never">
|
||||
<table-tool :app="this" label="收货地址">
|
||||
<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>
|
||||
|
||||
<el-table
|
||||
@@ -50,7 +51,7 @@ layout("/layouts/platform.html"){
|
||||
</el-card>
|
||||
</template>
|
||||
</guava>
|
||||
<address-dialog ref="addressDialog" @select_user_address="selectUserAddress"></address-dialog>
|
||||
<address-dialog ref="addressDialog" :address-api="addressApi" @select_user_address="selectUserAddress"></address-dialog>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -60,6 +61,7 @@ layout("/layouts/platform.html"){
|
||||
mixins: [initTableMixins],
|
||||
data() {
|
||||
return {
|
||||
addressApi: loc().replace(/\/$/, ""),
|
||||
dzOptions: chengShiList,
|
||||
dialogVisible: false,
|
||||
tableColumns: [
|
||||
@@ -74,12 +76,16 @@ layout("/layouts/platform.html"){
|
||||
"address-dialog": ADDRESS_DIALOG
|
||||
},
|
||||
methods: {
|
||||
// 导出全部用户的收货地址,不传当前用户或分页条件;下载和 loading 复用全局方法。
|
||||
exportAllAddress() {
|
||||
this.$downLoad(this.addressApi + "/exportAllAddress", {})
|
||||
},
|
||||
selectUserAddress(){
|
||||
this.pageData()
|
||||
},
|
||||
pageData() {
|
||||
this.tableLoading = true
|
||||
this.$axios.post("/platform/welfare/addressManage/selectUserAddress", this.pageForm).then((resp) => {
|
||||
this.$axios.post(this.addressApi + "/selectUserAddress", this.pageForm).then((resp) => {
|
||||
this.tableLoading = false
|
||||
if (resp.code === 0) {
|
||||
this.tableData = resp.data
|
||||
@@ -99,7 +105,7 @@ layout("/layouts/platform.html"){
|
||||
cancelButtonText: "取消",
|
||||
type: "warning"
|
||||
}).then(async () => {
|
||||
const resp = await this.$axios.post("/platform/welfare/addressManage/deleteAddress", { id: id })
|
||||
const resp = await this.$axios.post(this.addressApi + "/deleteAddress", { id: id })
|
||||
if (resp.code === 0) {
|
||||
this.doSearch()
|
||||
this.$message({
|
||||
@@ -116,7 +122,7 @@ layout("/layouts/platform.html"){
|
||||
this.$refs.addressDialog.openEdit(row)
|
||||
},
|
||||
async doDefault(row) {
|
||||
const resp = await this.$axios.post(loc() + "/doDefault", { id: row.id })
|
||||
const resp = await this.$axios.post(this.addressApi + "/doDefault", { id: row.id })
|
||||
if (resp.code === 0) {
|
||||
this.doSearch()
|
||||
this.$message({
|
||||
|
||||
@@ -3,7 +3,7 @@ layout("/layouts/platform.html"){
|
||||
#-->
|
||||
|
||||
<div id="app" v-cloak>
|
||||
<guava>
|
||||
<guava @hook:mounted="onPageVisible">
|
||||
<el-card shadow="never">
|
||||
<search @search="doSearch">
|
||||
<search-item label="所属年份">
|
||||
@@ -96,7 +96,9 @@ layout("/layouts/platform.html"){
|
||||
|
||||
<el-table
|
||||
:data="tableData"
|
||||
:max-height="tableHeight"
|
||||
:size="tableSize"
|
||||
class="welfare-selection-table"
|
||||
@sort-change="pageOrder"
|
||||
ref="tableRef"
|
||||
row-key="id"
|
||||
@@ -168,6 +170,31 @@ layout("/layouts/platform.html"){
|
||||
new Vue({
|
||||
el: "#app",
|
||||
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: {
|
||||
"option-select": optionSelect
|
||||
},
|
||||
@@ -175,9 +202,11 @@ layout("/layouts/platform.html"){
|
||||
return {
|
||||
pageForm: {
|
||||
searchName: "username",
|
||||
pageSize: 50,
|
||||
year: new Date().getFullYear().toString(),
|
||||
isSelect: null
|
||||
},
|
||||
tableHeight: 300,
|
||||
unionOptions: [],
|
||||
unitOptions: [],
|
||||
projectOptions: [],
|
||||
@@ -216,6 +245,57 @@ layout("/layouts/platform.html"){
|
||||
}
|
||||
},
|
||||
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() {
|
||||
this.$axios.post("/platform/welfare/common/list", { year: this.pageForm.year }).then((res) => {
|
||||
@@ -355,6 +435,16 @@ layout("/layouts/platform.html"){
|
||||
})
|
||||
</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>
|
||||
<guava ref="guava">
|
||||
<guava ref="guava" @hook:mounted="onPageVisible">
|
||||
<template>
|
||||
<el-card shadow="never">
|
||||
<search @search="doSearch">
|
||||
@@ -194,7 +194,9 @@ layout("/layouts/platform.html"){
|
||||
|
||||
<el-table
|
||||
:data="tableData"
|
||||
:max-height="tableHeight"
|
||||
:size="tableSize"
|
||||
class="welfare-list-table"
|
||||
@sort-change="pageOrder"
|
||||
ref="tableRef"
|
||||
row-key="id"
|
||||
@@ -294,6 +296,29 @@ layout("/layouts/platform.html"){
|
||||
},
|
||||
mounted() {
|
||||
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: {
|
||||
add_welfare_list_by_select: ADD_WELFARE_LIST_BY_SELECT,
|
||||
@@ -306,8 +331,10 @@ layout("/layouts/platform.html"){
|
||||
checkedFields: [],
|
||||
pageForm: {
|
||||
searchName: "username",
|
||||
pageSize: 50,
|
||||
year: new Date().getFullYear().toString()
|
||||
},
|
||||
tableHeight: 300,
|
||||
unionOptions: [],
|
||||
unitOptions: [],
|
||||
sexOptions: ["男", "女"],
|
||||
@@ -335,6 +362,57 @@ layout("/layouts/platform.html"){
|
||||
}
|
||||
},
|
||||
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() {
|
||||
this.$downLoad("/platform/welfare/list/mange/exportAddress", this.pageForm)
|
||||
@@ -422,6 +500,16 @@ layout("/layouts/platform.html"){
|
||||
})
|
||||
</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>
|
||||
</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">
|
||||
<van-tabs v-model="familyActive" type="card" color="#0e78c5" animated>
|
||||
<van-tab v-for="item, index in formData.mobileColumnsValue"
|
||||
:name="index + ''"
|
||||
:title="activity.keyWord + (index + 1)"
|
||||
: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-tabs>
|
||||
</div>
|
||||
@@ -111,6 +114,12 @@ const applyForm = {
|
||||
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
|
||||
}
|
||||
const list = clone(this.courseType.familyMobileSignColumnList)
|
||||
this.formData.mobileColumnsValue.push(list)
|
||||
},
|
||||
|
||||
@@ -16,11 +16,22 @@ layout("/layouts/platform_h5.html"){
|
||||
text-overflow: unset !important;
|
||||
white-space: normal !important;
|
||||
}
|
||||
.sign-success .label{
|
||||
color: #0e9558;
|
||||
/* 仅突出当前活动列表的报名结果,姓名过长时完整换行展示。 */
|
||||
.table-list-container .sign-success {
|
||||
align-items: baseline;
|
||||
line-height: 28px;
|
||||
}
|
||||
.sign-success .value{
|
||||
color: #0e9558;
|
||||
.table-list-container .sign-success .label {
|
||||
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>
|
||||
|
||||
|
||||
+230
@@ -2,6 +2,236 @@
|
||||
layout("/layouts/platform_h5.html"){
|
||||
#-->
|
||||
|
||||
<style>
|
||||
#app.works-activity-page {
|
||||
min-height: 100%;
|
||||
height: auto;
|
||||
color: #323233;
|
||||
}
|
||||
|
||||
.works-activity-page .van-nav-bar__title {
|
||||
color: #323233;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.works-activity-page .van-nav-bar__arrow,
|
||||
.works-activity-page .van-nav-bar__text {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.works-activity-page .van-nav-bar__text {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.works-activity-page .van-nav-bar__text::after {
|
||||
position: absolute;
|
||||
right: 20%;
|
||||
bottom: -6px;
|
||||
left: 20%;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: currentColor;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.works-activity-list {
|
||||
box-sizing: border-box;
|
||||
min-height: calc(100vh - 46px);
|
||||
padding: 12px 10px;
|
||||
}
|
||||
|
||||
.works-activity-card {
|
||||
--cover-width: clamp(68px, 25vw, 108px);
|
||||
--card-gap: clamp(8px, 3.5vw, 16px);
|
||||
display: grid;
|
||||
grid-template-columns: var(--cover-width) minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
gap: var(--card-gap);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin: 0 0 10px;
|
||||
padding: 12px 10px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.works-activity-card:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.works-activity-cover {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 9px;
|
||||
background: #f7f8fa;
|
||||
color: #c8c9cc;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.works-activity-cover .van-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.works-activity-heading {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.works-activity-name {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
margin-bottom: 5px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.works-activity-status {
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.works-activity-status--open {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.works-activity-status--upcoming {
|
||||
background: #f2f3f5;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.works-activity-status--closed {
|
||||
background: #969799;
|
||||
}
|
||||
|
||||
.works-activity-dates {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
margin-top: 7px;
|
||||
color: #646566;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.works-activity-date {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.works-activity-date .van-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="app" class="works-activity-page" v-cloak>
|
||||
<van-nav-bar title="作品征集" left-arrow right-text="我的作品" fixed placeholder :border="false"
|
||||
@click-left="historyBack" @click-right="$pjaxReplace(moduleUrl + '/upload/mine')"></van-nav-bar>
|
||||
|
||||
<van-pull-refresh v-model="refreshing" class="works-activity-list" @refresh="loadActivities">
|
||||
<van-loading v-if="loading" style="padding: 24px; text-align: center"></van-loading>
|
||||
<button v-for="activity in activities" :key="activity.id" type="button" class="works-activity-card"
|
||||
@click="$pjaxReplace(moduleUrl + '/upload?activityId=' + encodeURIComponent(activity.id))">
|
||||
<span class="works-activity-cover" aria-hidden="true">
|
||||
<van-image v-if="activity.cover" :src="activity.cover" width="100%" height="100%" fit="cover" alt="">
|
||||
<template #error><van-icon name="photo-o"></van-icon></template>
|
||||
</van-image>
|
||||
<van-icon v-else name="photo-o"></van-icon>
|
||||
</span>
|
||||
<span>
|
||||
<span class="works-activity-heading">
|
||||
<strong class="works-activity-name" :title="activity.name">{{ activity.name }}</strong>
|
||||
<van-tag class="works-activity-status" :class="'works-activity-status--' + activity.status">
|
||||
{{ statuses[activity.status] }}
|
||||
</van-tag>
|
||||
</span>
|
||||
<span class="works-activity-dates">
|
||||
<span class="works-activity-date">
|
||||
<van-icon name="calendar-o" aria-hidden="true"></van-icon>
|
||||
<span>开始:{{ formatDate(activity.startDateTime) }}</span>
|
||||
</span>
|
||||
<span class="works-activity-date">
|
||||
<van-icon name="calendar-o" aria-hidden="true"></van-icon>
|
||||
<span>截止:{{ formatDate(activity.endDateTime) }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<van-empty v-if="!loading && !failed && !activities.length" description="暂无作品征集活动"></van-empty>
|
||||
<van-empty v-if="!loading && failed" description="活动加载失败">
|
||||
<van-button size="small" @click="loadActivities">重新加载</van-button>
|
||||
</van-empty>
|
||||
</van-pull-refresh>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
new Vue({
|
||||
el: "#app",
|
||||
data() {
|
||||
return {
|
||||
moduleUrl: "/platform/h5/activity/worksCollection",
|
||||
activities: [],
|
||||
statuses: {
|
||||
open: "征集中",
|
||||
upcoming: "未开始",
|
||||
closed: "已结束"
|
||||
},
|
||||
loading: false,
|
||||
refreshing: false,
|
||||
failed: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
formatDate(value) {
|
||||
return value ? this.$moment(value).format("YYYY-MM-DD HH:mm") : "—"
|
||||
},
|
||||
async loadActivities() {
|
||||
if (this.loading) {
|
||||
this.refreshing = false
|
||||
return
|
||||
}
|
||||
this.loading = true
|
||||
this.failed = false
|
||||
try {
|
||||
const response = await this.$axios.post(this.moduleUrl + "/activities")
|
||||
this.activities = response.data
|
||||
} catch (error) {
|
||||
this.failed = true
|
||||
} finally {
|
||||
this.loading = false
|
||||
this.refreshing = false
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadActivities()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<!--#
|
||||
}
|
||||
#-->
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<!--#
|
||||
layout("/layouts/platform_h5.html"){
|
||||
#-->
|
||||
|
||||
<div id="app" v-cloak>
|
||||
<van-nav-bar title="选择收货地址" left-arrow fixed placeholder @click-left="finish"></van-nav-bar>
|
||||
<van-notice-bar wrapable :scrollable="false"
|
||||
text="请选择活动纪念品的收货地址。"></van-notice-bar>
|
||||
|
||||
<van-loading v-if="loading" style="padding: 24px; text-align: center"></van-loading>
|
||||
<van-empty v-else-if="failed" description="地址加载失败">
|
||||
<van-button size="small" @click="loadAddresses">重新加载</van-button>
|
||||
</van-empty>
|
||||
<template v-else>
|
||||
<van-address-list v-if="addresses.length" :list="addresses" default-tag-text="默认"
|
||||
add-button-text="新增地址" @select="confirmAddress" @edit="openEditor"
|
||||
@add="openEditor()"></van-address-list>
|
||||
<van-empty v-else description="暂无收货地址">
|
||||
<van-button round type="primary" @click="openEditor()">新增地址</van-button>
|
||||
</van-empty>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
new Vue({
|
||||
el: "#app",
|
||||
data() {
|
||||
return {
|
||||
addressApi: "/platform/activity/worksCollection/addressManage",
|
||||
addresses: [],
|
||||
loading: false,
|
||||
failed: false,
|
||||
saving: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async loadAddresses() {
|
||||
this.loading = true
|
||||
this.failed = false
|
||||
try {
|
||||
const response = await this.$axios.post(this.addressApi + "/selectUserAddress")
|
||||
this.addresses = response.data.map(row => ({
|
||||
...row,
|
||||
name: row.userName,
|
||||
address: [row.province, row.city, row.county, row.addressDetail].filter(Boolean).join(" ")
|
||||
}))
|
||||
} catch (error) {
|
||||
this.failed = true
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
openEditor(row) {
|
||||
if (this.saving) return
|
||||
const query = new URLSearchParams({ fromPicker: "1" })
|
||||
if (row) query.set("id", row.id)
|
||||
this.$pjaxReplace("/platform/h5/activity/worksCollection/upload/address/edit?" + query.toString())
|
||||
},
|
||||
async confirmAddress(row) {
|
||||
if (this.saving) return
|
||||
if (!/^1[3-9][0-9]{9}$/.test(row.tel)) {
|
||||
this.$toast("请先修改手机号码")
|
||||
return
|
||||
}
|
||||
this.saving = true
|
||||
try {
|
||||
await this.$axios.post(this.addressApi + "/doDefault", { id: row.id })
|
||||
this.$toast.success("已确认收货地址")
|
||||
this.finish()
|
||||
} finally {
|
||||
this.saving = false
|
||||
}
|
||||
},
|
||||
finish() {
|
||||
this.$pjaxReplace("/platform/h5/activity/worksCollection/upload/mine")
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadAddresses()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<!--#
|
||||
}
|
||||
#-->
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
<!--#
|
||||
layout("/layouts/platform_h5.html"){
|
||||
#-->
|
||||
<style>
|
||||
.work-address-editor .van-address-edit__buttons {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
gap: 12px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.work-address-editor .van-address-edit__buttons .van-button {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.work-address-editor .van-address-edit__buttons .van-button--danger {
|
||||
background: var(--color-primary, #1989fa);
|
||||
border-color: var(--color-primary, #1989fa);
|
||||
}
|
||||
</style>
|
||||
<div id="app" v-cloak>
|
||||
<van-nav-bar :title="addressId ? '修改收货地址' : '新增收货地址'" left-arrow fixed placeholder
|
||||
@click-left="finish"></van-nav-bar>
|
||||
<van-loading v-if="loading" style="padding: 24px; text-align: center"></van-loading>
|
||||
<van-empty v-else-if="failed" description="无法加载收货地址">
|
||||
<van-button size="small" @click="loadAddress">重新加载</van-button>
|
||||
</van-empty>
|
||||
<van-address-edit v-else class="work-address-editor" :address-info="addressInfo" :area-list="areaList"
|
||||
:area-columns-placeholder="['请选择', '请选择', '请选择']"
|
||||
:detail-maxlength="100" :detail-rows="2" :tel-maxlength="11" :tel-validator="validPhone"
|
||||
:show-delete="!!addressId" show-set-default :is-saving="saving" :is-deleting="deleting"
|
||||
delete-button-text="删除地址" save-button-text="保存"
|
||||
@save="saveAddress" @delete="deleteAddress"></van-address-edit>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<!--#include("/platform/zhghh5/welfare/include/areaList.js"){}#-->
|
||||
new Vue({
|
||||
el: "#app",
|
||||
data() {
|
||||
return {
|
||||
addressApi: "/platform/activity/worksCollection/addressManage",
|
||||
uploadUrl: "/platform/h5/activity/worksCollection/upload",
|
||||
addressId: GetQueryString("id"),
|
||||
activityId: GetQueryString("activityId"),
|
||||
workId: GetQueryString("workId"),
|
||||
fromPicker: GetQueryString("fromPicker") === "1",
|
||||
areaList,
|
||||
addressInfo: {},
|
||||
loading: false,
|
||||
failed: false,
|
||||
saving: false,
|
||||
deleting: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
validPhone(value) {
|
||||
return /^1[3-9][0-9]{9}$/.test(value)
|
||||
},
|
||||
async loadAddress() {
|
||||
if (!this.addressId) return
|
||||
this.loading = true
|
||||
this.failed = false
|
||||
try {
|
||||
const { data } = await this.$axios.post(this.addressApi + "/selectUserAddress")
|
||||
const row = data.find(address => address.id === this.addressId)
|
||||
if (row) {
|
||||
this.addressInfo = { ...row, name: row.userName }
|
||||
} else {
|
||||
this.failed = true
|
||||
}
|
||||
} catch (error) {
|
||||
this.failed = true
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async saveAddress(row) {
|
||||
if (this.saving || this.deleting) return
|
||||
this.saving = true
|
||||
try {
|
||||
await this.$axios.post(this.addressApi + "/doSaveUserAddress", {
|
||||
data: JSON.stringify({ ...row, id: this.addressId, userName: row.name })
|
||||
})
|
||||
this.$toast.success("地址已保存")
|
||||
this.finish()
|
||||
} catch (error) {
|
||||
// 公共请求处理会显示错误信息。
|
||||
} finally {
|
||||
this.saving = false
|
||||
}
|
||||
},
|
||||
async deleteAddress() {
|
||||
if (!this.addressId || this.saving || this.deleting) return
|
||||
this.deleting = true
|
||||
try {
|
||||
await this.$dialog.confirm({ message: "确定删除这个收货地址吗?" })
|
||||
await this.$axios.post(this.addressApi + "/deleteAddress", { id: this.addressId })
|
||||
this.$toast.success("地址已删除")
|
||||
this.finish()
|
||||
} catch (error) {
|
||||
// 取消确认或请求失败时留在当前页。
|
||||
} finally {
|
||||
this.deleting = false
|
||||
}
|
||||
},
|
||||
finish() {
|
||||
if (this.fromPicker && window.history.length > 1) {
|
||||
this.historyBack()
|
||||
return
|
||||
}
|
||||
if (!this.activityId) {
|
||||
this.$pjaxReplace(this.uploadUrl + "/address")
|
||||
return
|
||||
}
|
||||
const query = new URLSearchParams({ activityId: this.activityId })
|
||||
if (this.workId) query.set("id", this.workId)
|
||||
this.$pjaxReplace(this.uploadUrl + "?" + query.toString())
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadAddress()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<!--#
|
||||
}
|
||||
#-->
|
||||
+489
-1
@@ -1,7 +1,495 @@
|
||||
<!--#
|
||||
layout("/layouts/platform_h5.html"){
|
||||
#-->
|
||||
22222222222
|
||||
<style>
|
||||
.work-activity-detail .van-cell__title {
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.work-activity-detail .activity-detail-content {
|
||||
margin-top: 8px;
|
||||
color: #323233;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.work-activity-detail .activity-detail-content > .content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.work-activity-detail .activity-detail-content > .content > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.work-activity-detail .activity-detail-content > .content > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.work-upload-form .shipping-address-contact {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 8px;
|
||||
color: #323233;
|
||||
}
|
||||
|
||||
.work-upload-form .shipping-address-value {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.work-upload-form .shipping-address-placeholder {
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
.work-upload-form .shipping-address-detail {
|
||||
margin-top: 2px;
|
||||
color: #646566;
|
||||
}
|
||||
|
||||
.work-upload-form .van-field__label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.work-upload-form .van-field:not(.direction-column-field) .van-field__label {
|
||||
width: 64px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.work-upload-form .attachment-field {
|
||||
row-gap: 6px;
|
||||
}
|
||||
|
||||
.work-upload-form .attachment-field .van-field__control {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.work-upload-form .attachment-hint {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: #969799;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.work-address-picker .van-address-list {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.work-address-picker .van-address-list__bottom {
|
||||
position: static;
|
||||
padding: 12px 0 0;
|
||||
}
|
||||
|
||||
.work-address-picker .van-address-list__add {
|
||||
margin: 0;
|
||||
background: var(--color-primary, #1989fa);
|
||||
border-color: var(--color-primary, #1989fa);
|
||||
}
|
||||
</style>
|
||||
<div id="app" v-cloak>
|
||||
<van-nav-bar :title="workId ? '编辑作品' : '征集详情'" left-arrow fixed placeholder
|
||||
@click-left="historyBack"></van-nav-bar>
|
||||
|
||||
<van-loading v-if="loading" style="padding: 24px; text-align: center"></van-loading>
|
||||
<van-empty v-else-if="failed" description="无法加载活动或作品">
|
||||
<van-button size="small" @click="loadForm">重新加载</van-button>
|
||||
</van-empty>
|
||||
|
||||
<template v-else-if="activity">
|
||||
<van-image v-if="activity.cover" :src="activity.cover" width="100%" height="200px" fit="cover"></van-image>
|
||||
<van-cell :title="activity.name">
|
||||
<template #label>
|
||||
<div>开始:{{ formatDate(activity.startDateTime) }}</div>
|
||||
<div>截止:{{ formatDate(activity.endDateTime) }}</div>
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell v-if="activity.introduce" title="活动介绍" class="work-activity-detail">
|
||||
<template #label>
|
||||
<rich-text class="activity-detail-content" :value="activity.introduce"></rich-text>
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell v-if="activity.content" title="活动内容" class="work-activity-detail">
|
||||
<template #label>
|
||||
<rich-text class="activity-detail-content" :value="activity.content"></rich-text>
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell v-if="activity.rewardMethod" title="奖励方式" class="work-activity-detail">
|
||||
<template #label>
|
||||
<rich-text class="activity-detail-content" :value="activity.rewardMethod"></rich-text>
|
||||
</template>
|
||||
</van-cell>
|
||||
|
||||
<van-notice-bar v-if="activity.status !== 'open'" :text="activity.status === 'upcoming' ? '征集尚未开始' : '征集已结束'"></van-notice-bar>
|
||||
<van-notice-bar v-else-if="!activity.subjectTypes.length" text="该活动暂无可投稿的主题"></van-notice-bar>
|
||||
<van-form v-else class="form-container work-upload-form" @submit="submitWork" @failed="handleSubmitFailure" :disabled="uploading || submitting">
|
||||
<van-cell-group title="作品信息">
|
||||
<van-field label="主题类型" :value="selectedSubject ? selectedSubject.typeName : ''" placeholder="请选择主题类型"
|
||||
readonly required :is-link="!uploading && !submitting" :clickable="!uploading && !submitting"
|
||||
@click="!uploading && !submitting && (showSubjectPicker = true)"></van-field>
|
||||
<van-field label="作品类型" :value="selectedType ? selectedType.worksTypeName : ''" :placeholder="worksTypePlaceholder"
|
||||
readonly required :disabled="!worksTypes.length || uploading || submitting"
|
||||
:is-link="!!worksTypes.length && !uploading && !submitting"
|
||||
:clickable="!!worksTypes.length && !uploading && !submitting"
|
||||
@click="worksTypes.length && !uploading && !submitting && (showWorksPicker = true)"></van-field>
|
||||
<van-field v-model.trim="form.name" name="name" label="作品名称" placeholder="请输入作品名称" required maxlength="100"
|
||||
:rules="[{ required: true, message: '请输入作品名称' }]"></van-field>
|
||||
<van-field v-model.trim="form.description" name="description" label="作品描述" placeholder="请输入作品描述" required
|
||||
type="textarea" autosize rows="3" :maxlength="descriptionLimit" show-word-limit
|
||||
:rules="[{ required: true, message: '请输入作品描述' }]"></van-field>
|
||||
<van-field label="收货地址" readonly required is-link clickable @click="openAddressPicker">
|
||||
<template #input>
|
||||
<div v-if="shippingAddress" class="shipping-address-value">
|
||||
<div class="shipping-address-contact">
|
||||
<span>{{ shippingAddress.userName }}</span>
|
||||
<span>{{ shippingAddress.tel }}</span>
|
||||
</div>
|
||||
<div class="shipping-address-detail">{{ shippingAddress.address }}</div>
|
||||
</div>
|
||||
<span v-else class="shipping-address-placeholder">请选择活动纪念品收货地址</span>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field label="作品附件" required class="direction-column-field attachment-field">
|
||||
<template #label>
|
||||
<span>作品附件</span>
|
||||
<span v-if="attachmentHint" class="attachment-hint">{{ attachmentHint }}</span>
|
||||
</template>
|
||||
<template #input>
|
||||
<van-uploader v-model="attachments" :accept="fileAccept" :max-count="fileCountLimit"
|
||||
:disabled="!selectedType || uploading || submitting" :deletable="!uploading && !submitting"
|
||||
:before-read="validateFiles" :after-read="uploadFiles" result-type="file"
|
||||
multiple></van-uploader>
|
||||
</template>
|
||||
</van-field>
|
||||
</van-cell-group>
|
||||
<div class="form-actions">
|
||||
<van-button block type="primary" native-type="submit" :loading="submitting" :disabled="uploading">
|
||||
{{ workId ? '保存修改' : '提交作品' }}
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<van-popup v-model="showSubjectPicker" position="bottom" round safe-area-inset-bottom>
|
||||
<van-picker v-if="showSubjectPicker" show-toolbar title="主题类型" :columns="activity.subjectTypes" value-key="typeName"
|
||||
:default-index="subjectPickerIndex" @cancel="showSubjectPicker = false" @confirm="changeSubject"></van-picker>
|
||||
</van-popup>
|
||||
<van-popup v-model="showWorksPicker" position="bottom" round safe-area-inset-bottom>
|
||||
<van-picker v-if="showWorksPicker" show-toolbar title="作品类型" :columns="worksTypes" value-key="worksTypeName"
|
||||
:default-index="worksPickerIndex" @cancel="showWorksPicker = false" @confirm="changeWorksType"></van-picker>
|
||||
</van-popup>
|
||||
</van-form>
|
||||
</template>
|
||||
|
||||
<van-action-sheet v-model="showAddressPicker" class="work-address-picker" title="选择收货地址" :close-on-click-overlay="false">
|
||||
<van-loading v-if="addressLoading" style="padding: 20px; text-align: center"></van-loading>
|
||||
<van-empty v-else-if="addressFailed" description="地址加载失败">
|
||||
<van-button size="small" @click="loadAddresses()">重新加载</van-button>
|
||||
</van-empty>
|
||||
<van-address-list v-else-if="addresses.length" :value="shippingAddress ? shippingAddress.id : ''"
|
||||
:list="addresses" default-tag-text="默认" add-button-text="新增地址"
|
||||
@select="useAddress" @edit="editAddress" @add="editAddress()"></van-address-list>
|
||||
<van-empty v-else description="暂无收货地址">
|
||||
<van-button round type="primary" @click="editAddress()">新增地址</van-button>
|
||||
</van-empty>
|
||||
</van-action-sheet>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
new Vue({
|
||||
el: "#app",
|
||||
data() {
|
||||
return {
|
||||
moduleUrl: "/platform/h5/activity/worksCollection",
|
||||
activityId: GetQueryString("activityId"),
|
||||
workId: GetQueryString("id"),
|
||||
activity: null,
|
||||
form: { subjectId: "", worksId: "", name: "", description: "" },
|
||||
attachments: [],
|
||||
addressApi: "/platform/activity/worksCollection/addressManage",
|
||||
addresses: [],
|
||||
shippingAddress: null,
|
||||
addressLoading: false,
|
||||
addressFailed: false,
|
||||
showAddressPicker: false,
|
||||
showSubjectPicker: false,
|
||||
showWorksPicker: false,
|
||||
loading: false,
|
||||
failed: false,
|
||||
uploading: false,
|
||||
submitting: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selectedSubject() {
|
||||
return this.activity.subjectTypes.find(item => item.id === this.form.subjectId)
|
||||
},
|
||||
worksTypes() {
|
||||
return this.selectedSubject ? this.selectedSubject.worksTypes : []
|
||||
},
|
||||
selectedType() {
|
||||
return this.worksTypes.find(item => item.id === this.form.worksId)
|
||||
},
|
||||
worksTypePlaceholder() {
|
||||
if (!this.selectedSubject) return "请先选择主题类型"
|
||||
return this.worksTypes.length ? "请选择作品类型" : "该主题暂无作品类型"
|
||||
},
|
||||
subjectPickerIndex() {
|
||||
return Math.max(0, this.activity.subjectTypes.findIndex(item => item.id === this.form.subjectId))
|
||||
},
|
||||
worksPickerIndex() {
|
||||
return Math.max(0, this.worksTypes.findIndex(item => item.id === this.form.worksId))
|
||||
},
|
||||
descriptionLimit() {
|
||||
const limit = this.selectedType && this.selectedType.maxWordCount
|
||||
return limit > 0 ? Math.min(100, limit) : 100
|
||||
},
|
||||
fileCountLimit() {
|
||||
const limit = this.selectedType && this.selectedType.allowFileNum
|
||||
return limit > 0 ? limit : Infinity
|
||||
},
|
||||
allowedExtensions() {
|
||||
return (this.selectedType && this.selectedType.allowFileTypes || []).map(type => type.toLowerCase())
|
||||
},
|
||||
fileAccept() {
|
||||
return this.allowedExtensions.length ? this.allowedExtensions.map(type => "." + type).join(",") : "*/*"
|
||||
},
|
||||
attachmentHint() {
|
||||
if (!this.selectedType) return "请先选择作品类型"
|
||||
const hints = []
|
||||
if (this.allowedExtensions.length) hints.push(this.allowedExtensions.join("、"))
|
||||
if (Number.isFinite(this.fileCountLimit)) hints.push("最多 " + this.fileCountLimit + " 个附件")
|
||||
if (this.selectedType.allowSingleFileSize > 0) hints.push("单个不超过 " + this.selectedType.allowSingleFileSize + " KB")
|
||||
if (this.selectedType.allowTotalFileSize > 0) hints.push("总计不超过 " + this.selectedType.allowTotalFileSize + " KB")
|
||||
return hints.join(",")
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
validPhone(value) {
|
||||
return /^1[3-9][0-9]{9}$/.test(value)
|
||||
},
|
||||
addressText(row) {
|
||||
return [row.province, row.city, row.county, row.addressDetail].filter(Boolean).join("")
|
||||
},
|
||||
async loadAddresses(useDefault = false) {
|
||||
if (this.addressLoading) return
|
||||
this.addressLoading = true
|
||||
this.addressFailed = false
|
||||
try {
|
||||
const { data } = await this.$axios.post(this.addressApi + "/selectUserAddress")
|
||||
this.addresses = data.map(row => ({ ...row, name: row.userName, address: this.addressText(row) }))
|
||||
if (this.shippingAddress) {
|
||||
this.shippingAddress = this.addresses.find(row => row.id === this.shippingAddress.id && this.validPhone(row.tel)) || null
|
||||
} else if (useDefault && !this.showAddressPicker) {
|
||||
this.shippingAddress = this.addresses.find(row => row.isDefault && this.validPhone(row.tel)) || null
|
||||
}
|
||||
} catch (error) {
|
||||
this.addressFailed = true
|
||||
} finally {
|
||||
this.addressLoading = false
|
||||
}
|
||||
},
|
||||
openAddressPicker() {
|
||||
if (this.submitting || this.uploading) return
|
||||
this.showAddressPicker = true
|
||||
this.loadAddresses()
|
||||
},
|
||||
draftKey() {
|
||||
return ["works-address-return", store.state.user.id, this.activityId, this.workId || "new"].join(":")
|
||||
},
|
||||
editAddress(row) {
|
||||
if (this.uploading || this.submitting) return
|
||||
const draft = {
|
||||
form: this.form,
|
||||
attachments: this.attachments.filter(file => file.url && file.status !== "failed")
|
||||
.map(({ url, name, size, isImage, status }) => ({ url, name, size, isImage, status })),
|
||||
shippingAddress: this.shippingAddress
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem(this.draftKey(), JSON.stringify(draft))
|
||||
} catch (error) {
|
||||
this.$toast.fail("暂时无法保留投稿内容,请重试")
|
||||
return
|
||||
}
|
||||
const query = new URLSearchParams({ activityId: this.activityId, fromPicker: "1" })
|
||||
if (this.workId) query.set("workId", this.workId)
|
||||
if (row) query.set("id", row.id)
|
||||
this.showAddressPicker = false
|
||||
this.$pjaxReplace(this.moduleUrl + "/upload/address/edit?" + query.toString())
|
||||
},
|
||||
useAddress(row) {
|
||||
if (!this.validPhone(row.tel)) {
|
||||
this.$toast("请先修改手机号码")
|
||||
return
|
||||
}
|
||||
this.shippingAddress = row
|
||||
this.showAddressPicker = false
|
||||
},
|
||||
restoreAddressDraft() {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(this.draftKey())
|
||||
if (!stored) return
|
||||
sessionStorage.removeItem(this.draftKey())
|
||||
const draft = JSON.parse(stored)
|
||||
this.form = draft.form
|
||||
this.attachments = draft.attachments
|
||||
this.shippingAddress = draft.shippingAddress
|
||||
this.openAddressPicker()
|
||||
} catch (error) {
|
||||
this.$toast.fail("无法恢复投稿草稿")
|
||||
}
|
||||
},
|
||||
formatDate(value) {
|
||||
return value ? this.$moment(value).format("YYYY-MM-DD HH:mm") : "—"
|
||||
},
|
||||
async loadForm() {
|
||||
this.loading = true
|
||||
this.failed = false
|
||||
try {
|
||||
const { data } = await this.$axios.post(this.moduleUrl + "/activityDetail", { activityId: this.activityId })
|
||||
if (this.workId) {
|
||||
const { data: work } = await this.$axios.post(this.moduleUrl + "/myWork", { id: this.workId })
|
||||
if (work.activityId !== data.id) throw new Error("作品与活动不匹配")
|
||||
this.form = {
|
||||
subjectId: work.subjectId,
|
||||
worksId: work.worksId,
|
||||
name: work.name,
|
||||
description: work.description
|
||||
}
|
||||
this.attachments = (work.files || []).map(file => ({
|
||||
name: file.name,
|
||||
url: file.response.data,
|
||||
size: file.size,
|
||||
isImage: this.isImage(file.name)
|
||||
}))
|
||||
}
|
||||
this.activity = data
|
||||
this.restoreAddressDraft()
|
||||
} catch (error) {
|
||||
this.failed = true
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
changeSubject(subject) {
|
||||
this.showSubjectPicker = false
|
||||
if (subject.id !== this.form.subjectId) {
|
||||
this.form.subjectId = subject.id
|
||||
this.form.worksId = ""
|
||||
}
|
||||
},
|
||||
changeWorksType(type) {
|
||||
this.showWorksPicker = false
|
||||
this.form.worksId = type.id
|
||||
},
|
||||
isImage(name) {
|
||||
return /\.(jpe?g|png|gif|bmp|webp)$/i.test(name)
|
||||
},
|
||||
validateFiles(input) {
|
||||
if (!this.selectedType || this.uploading || this.submitting) return false
|
||||
const files = Array.isArray(input) ? input : [input]
|
||||
if (this.attachments.length + files.length > this.fileCountLimit) {
|
||||
this.$toast("附件数量超过限制")
|
||||
return false
|
||||
}
|
||||
const singleLimit = this.selectedType.allowSingleFileSize
|
||||
for (const file of files) {
|
||||
const extension = file.name.includes(".") ? file.name.split(".").pop().toLowerCase() : ""
|
||||
if (this.allowedExtensions.length && !this.allowedExtensions.includes(extension)) {
|
||||
this.$toast("附件格式不符合要求")
|
||||
return false
|
||||
}
|
||||
if (singleLimit > 0 && file.size > singleLimit * 1024) {
|
||||
this.$toast("单个附件大小超过限制")
|
||||
return false
|
||||
}
|
||||
}
|
||||
const totalLimit = this.selectedType.allowTotalFileSize
|
||||
const totalBytes = files.reduce((sum, file) => sum + file.size, 0)
|
||||
+ this.attachments.reduce((sum, file) => sum + (file.size || 0), 0)
|
||||
if (totalLimit > 0 && totalBytes > totalLimit * 1024) {
|
||||
this.$toast("附件总大小超过限制")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
async uploadFiles(input) {
|
||||
const items = Array.isArray(input) ? input : [input]
|
||||
this.uploading = true
|
||||
try {
|
||||
await Promise.all(items.map(async item => {
|
||||
this.$set(item, "status", "uploading")
|
||||
try {
|
||||
const body = new FormData()
|
||||
body.append("file", item.file)
|
||||
const { data } = await this.$axios.post("/platform/sys/file/uploadDynamicReturnUrl", body, {
|
||||
headers: { "Content-Type": "multipart/form-data" }
|
||||
})
|
||||
this.$set(item, "url", data)
|
||||
this.$set(item, "name", item.file.name)
|
||||
this.$set(item, "size", item.file.size)
|
||||
this.$set(item, "isImage", this.isImage(item.file.name))
|
||||
this.$set(item, "status", "done")
|
||||
} catch (error) {
|
||||
this.$set(item, "status", "failed")
|
||||
this.$set(item, "message", "上传失败")
|
||||
}
|
||||
}))
|
||||
} finally {
|
||||
this.uploading = false
|
||||
}
|
||||
},
|
||||
handleSubmitFailure() {
|
||||
if (!this.shippingAddress && !this.uploading) this.openAddressPicker()
|
||||
},
|
||||
async submitWork() {
|
||||
if (this.uploading || this.submitting) return
|
||||
if (!this.shippingAddress || !this.validPhone(this.shippingAddress.tel)) {
|
||||
this.openAddressPicker()
|
||||
return
|
||||
}
|
||||
if (!this.selectedType) {
|
||||
this.$toast("请选择主题类型和作品类型")
|
||||
return
|
||||
}
|
||||
if (this.form.description.length > this.descriptionLimit) {
|
||||
this.$toast("作品描述超过字数限制")
|
||||
return
|
||||
}
|
||||
if (!this.attachments.length || this.attachments.some(file => !file.url || file.status === "failed")) {
|
||||
this.$toast("请上传附件并移除上传失败的文件")
|
||||
return
|
||||
}
|
||||
this.submitting = true
|
||||
try {
|
||||
await this.$dialog.confirm({ message: this.workId ? "确定保存修改吗?" : "确定提交这件作品吗?" })
|
||||
await this.$axios.post(this.moduleUrl + "/saveWork", {
|
||||
addressId: this.shippingAddress.id,
|
||||
data: JSON.stringify({
|
||||
...this.form,
|
||||
id: this.workId,
|
||||
activityId: this.activityId,
|
||||
files: this.attachments.map(file => ({ response: { data: file.url } }))
|
||||
})
|
||||
})
|
||||
this.$toast.success(this.workId ? "保存成功" : "提交成功")
|
||||
if (this.workId) {
|
||||
this.historyBack()
|
||||
} else {
|
||||
this.$pjaxReplace(this.moduleUrl + "/upload/mine")
|
||||
}
|
||||
} catch (error) {
|
||||
// 请求失败由公共响应处理显示。
|
||||
} finally {
|
||||
this.submitting = false
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadForm()
|
||||
this.loadAddresses(true)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<!--#
|
||||
}
|
||||
#-->
|
||||
|
||||
+301
-8
@@ -1,8 +1,301 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Title</title>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
<!--#
|
||||
layout("/layouts/platform_h5.html"){
|
||||
#-->
|
||||
|
||||
<style>
|
||||
.my-works-list {
|
||||
padding: 14px 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.my-works-list .work-card {
|
||||
margin-bottom: 14px;
|
||||
padding: 14px 12px 0;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
color: #323233;
|
||||
}
|
||||
|
||||
.my-works-list .work-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.my-works-list .work-cover {
|
||||
width: 82px;
|
||||
height: 58px;
|
||||
flex: 0 0 82px;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: #f7f8fa;
|
||||
color: #c8c9cc;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.my-works-list .work-heading,
|
||||
.my-works-list .work-metadata-value {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.my-works-list .work-title,
|
||||
.my-works-list .work-name {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.my-works-list .work-title {
|
||||
margin: 0 0 5px;
|
||||
font-size: 17px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.my-works-list .work-name {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.my-works-list .work-metadata {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.2fr);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.my-works-list .work-metadata-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.my-works-list .work-metadata-item + .work-metadata-item {
|
||||
border-left: 1px solid #ebedf0;
|
||||
}
|
||||
|
||||
.my-works-list .work-metadata-item .van-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.my-works-list .work-metadata-value {
|
||||
margin-top: 3px;
|
||||
color: #323233;
|
||||
}
|
||||
|
||||
.my-works-list .work-more {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
border: 0;
|
||||
border-top: 1px solid #ebedf0;
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.my-works-list .work-detail {
|
||||
padding: 8px 0 12px;
|
||||
border-top: 1px solid #ebedf0;
|
||||
}
|
||||
|
||||
.my-works-list .work-detail-enter-active {
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.my-works-list .work-detail-enter {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.my-works-list .work-detail-enter-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.my-works-list .work-field {
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.my-works-list .work-field-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.my-works-list .work-field-value {
|
||||
min-width: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.my-works-list .work-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.my-works-list .work-actions .van-button {
|
||||
height: 30px;
|
||||
padding: 0 9px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="app" v-cloak>
|
||||
<van-nav-bar title="我的作品" left-arrow fixed placeholder @click-left="historyBack"></van-nav-bar>
|
||||
|
||||
<van-list v-model="loading" class="my-works-list" :finished="finished" :error.sync="failed"
|
||||
error-text="加载失败,点击重试" style="min-height: calc(100vh - 46px)" @load="loadWorks">
|
||||
<article v-for="work in works" :key="work.id" class="work-card">
|
||||
<div class="work-summary">
|
||||
<div class="work-cover">
|
||||
<van-image v-if="work.activityCover" :src="work.activityCover" width="100%" height="100%" fit="cover"></van-image>
|
||||
<van-icon v-else name="photo-o" size="24"></van-icon>
|
||||
</div>
|
||||
<div class="work-heading">
|
||||
<h2 class="work-title" :title="work.activityName">{{ work.activityName || '—' }}</h2>
|
||||
<div class="work-name" :title="work.name">作品名称:{{ work.name || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="work-metadata">
|
||||
<div class="work-metadata-item">
|
||||
<van-icon name="label-o" size="16"></van-icon>
|
||||
<div>
|
||||
<div>主题类型</div>
|
||||
<div class="work-metadata-value">{{ work.subjectName || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="work-metadata-item">
|
||||
<van-icon name="apps-o" size="16"></van-icon>
|
||||
<div>
|
||||
<div>作品类型</div>
|
||||
<div class="work-metadata-value">{{ work.worksName || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="work-metadata-item">
|
||||
<van-icon name="calendar-o" size="16"></van-icon>
|
||||
<div>
|
||||
<div>提交日期</div>
|
||||
<div class="work-metadata-value">{{ work.createdAt ? $moment(work.createdAt).format('YYYY-MM-DD') : '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="work-detail">
|
||||
<div v-if="openWorks.includes(work.id)" :id="'work-detail-' + work.id" class="work-detail">
|
||||
<div class="work-field">
|
||||
<span class="work-field-label">作品描述</span>
|
||||
<span class="work-field-value">{{ work.description || '—' }}</span>
|
||||
</div>
|
||||
<div class="work-field">
|
||||
<span class="work-field-label">收货地址</span>
|
||||
<span class="work-field-value">{{ work.shippingAddress || '—' }}</span>
|
||||
</div>
|
||||
<div class="work-field">
|
||||
<span class="work-field-label">作品附件</span>
|
||||
<div class="work-field-value">
|
||||
<file-preview :files="work.files" complete_result></file-preview>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="Number(work.canModify) === 1" class="work-actions">
|
||||
<van-button size="small" type="danger" plain icon="delete-o" :disabled="deleting"
|
||||
@click="deleteWork(work)">删除</van-button>
|
||||
<van-button size="small" type="primary" icon="edit" :disabled="deleting"
|
||||
@click="editWork(work)">编辑</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<button class="work-more" type="button" :aria-expanded="openWorks.includes(work.id) ? 'true' : 'false'"
|
||||
:aria-controls="openWorks.includes(work.id) ? 'work-detail-' + work.id : null" @click="toggleWork(work.id)">
|
||||
{{ openWorks.includes(work.id) ? '收起' : '查看更多' }}
|
||||
</button>
|
||||
</article>
|
||||
<van-empty v-if="finished && !works.length" image="/assets/platform/plugins/vant-green/images/nodata/nodata.png"
|
||||
description="暂无提交的作品"></van-empty>
|
||||
</van-list>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
new Vue({
|
||||
el: "#app",
|
||||
data() {
|
||||
return {
|
||||
moduleUrl: "/platform/h5/activity/worksCollection",
|
||||
works: [],
|
||||
openWorks: [],
|
||||
pageNumber: 1,
|
||||
loading: false,
|
||||
finished: false,
|
||||
failed: false,
|
||||
deleting: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleWork(id) {
|
||||
const index = this.openWorks.indexOf(id)
|
||||
if (index === -1) {
|
||||
this.openWorks.push(id)
|
||||
} else {
|
||||
this.openWorks.splice(index, 1)
|
||||
}
|
||||
},
|
||||
async loadWorks() {
|
||||
try {
|
||||
const { data } = await this.$axios.post(this.moduleUrl + "/minePageData", {
|
||||
pageNumber: this.pageNumber,
|
||||
pageSize: 10
|
||||
})
|
||||
const rows = data.list || []
|
||||
this.works = this.works.concat(rows)
|
||||
this.finished = !rows.length || this.pageNumber >= data.totalPage
|
||||
this.pageNumber++
|
||||
} catch (error) {
|
||||
this.failed = true
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
editWork(work) {
|
||||
if (Number(work.canModify) !== 1) return
|
||||
this.$pjaxReplace(this.moduleUrl + "/upload?activityId=" + encodeURIComponent(work.activityId)
|
||||
+ "&id=" + encodeURIComponent(work.id))
|
||||
},
|
||||
async deleteWork(work) {
|
||||
if (this.deleting || this.loading || Number(work.canModify) !== 1) return
|
||||
this.deleting = true
|
||||
try {
|
||||
await this.$dialog.confirm({ message: "确定删除这件作品吗?" })
|
||||
await this.$axios.post(this.moduleUrl + "/deleteWork", { id: work.id })
|
||||
this.works = []
|
||||
this.openWorks = []
|
||||
this.pageNumber = 1
|
||||
this.finished = false
|
||||
this.failed = false
|
||||
this.loading = true
|
||||
await this.loadWorks()
|
||||
this.$toast.success("删除成功")
|
||||
} catch (error) {
|
||||
// 请求失败由公共响应处理显示。
|
||||
} finally {
|
||||
this.deleting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<!--#
|
||||
}
|
||||
#-->
|
||||
|
||||
@@ -203,7 +203,7 @@ layout("/layouts/platform_h5.html"){
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<div class="submit-btn">
|
||||
<van-button round block type="info">提交申请</van-button>
|
||||
<van-button round block type="info" :disabled="formLoading">提交申请</van-button>
|
||||
</div>
|
||||
</van-form>
|
||||
<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() {
|
||||
return {
|
||||
viewShow: false,
|
||||
formLoading: false,
|
||||
formData: {
|
||||
clubId: "",
|
||||
clubName: "",
|
||||
@@ -284,6 +285,22 @@ layout("/layouts/platform_h5.html"){
|
||||
});
|
||||
},
|
||||
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;
|
||||
this.formData.userId = user.id;
|
||||
this.formData.userName = user.username;
|
||||
@@ -298,6 +315,7 @@ layout("/layouts/platform_h5.html"){
|
||||
this.formData.governmentPosition = user.governmentPosition;
|
||||
},
|
||||
doSubmit() {
|
||||
if (this.formLoading) return;
|
||||
if (!this.isAgree) {
|
||||
this.$toast.fail("请先阅读并同意协议");
|
||||
return;
|
||||
@@ -309,7 +327,9 @@ layout("/layouts/platform_h5.html"){
|
||||
this.$axios.post("/platform/club/join/apply/submit", this.formData).then(res => {
|
||||
if (res.code === 0) {
|
||||
this.$toast(res.msg);
|
||||
this.$pjaxReplace("/platform/club/join/mine")
|
||||
this.$pjaxReplace("/platform/h5/club/mine")
|
||||
} else {
|
||||
this.$toast.fail(res.msg);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user