This commit is contained in:
2026-07-23 18:30:30 +08:00
parent 86ab0142da
commit 785b9a4b80
57 changed files with 1795 additions and 340 deletions
@@ -156,6 +156,8 @@ public class SysHomeController {
List<Sys_menu> menus = sysUserService.getMenus(SecurityUtil.getUserId());
List<String> allMenuIds = menus.stream().map(Sys_menu::getId).toList();
List<Sys_menu> sysMenus = list.stream()
// 推荐项必须同时属于当前用户已授权菜单,避免首页展示无权限入口。
.filter(menu -> allMenuIds.contains(menu.getId()))
.sorted(Comparator.comparing(Sys_menu::getLocation, Comparator.nullsLast(Integer::compareTo))
.thenComparing(Sys_menu::getId))
.toList();
@@ -174,8 +176,12 @@ public class SysHomeController {
);
List<Sys_menu> menus = sysUserService.getMenus(SecurityUtil.getUserId());
List<String> allMenuIds = menus.stream().map(Sys_menu::getId).toList();
//List<Sys_menu> sysMenus = list.stream().filter(m -> allMenuIds.contains(m.getId())).sorted(Comparator.comparingInt(Sys_menu::getLocation)).toList();
List<Sys_menu> sysMenus = list.stream().sorted(Comparator.comparingInt(Sys_menu::getLocation)).toList();
List<Sys_menu> sysMenus = list.stream()
// 推荐项必须同时属于当前用户已授权菜单,避免首页展示无权限入口。
.filter(menu -> allMenuIds.contains(menu.getId()))
.sorted(Comparator.comparing(Sys_menu::getLocation, Comparator.nullsLast(Integer::compareTo))
.thenComparing(Sys_menu::getId))
.toList();
return Result.success(sysMenus);
}
@@ -527,8 +527,8 @@ public class SysUnionController {
@ApiOperation("分工会组成单位穿梭框数据")
@SaCheckPermission("sys.manager.union.partUnit")
public Result branchUnionPartUnitTransferData(String unionId) {
// List<Sys_unit> units = dao.query(Sys_unit.class, Cnd.where("unitLevel", "=", 2).asc("unitcode"));
List<Sys_unit> units = dao.query(Sys_unit.class, Cnd.where("unitTypeCode", "=", "1").asc("unitcode"));
// 组成单位仅允许选择二级单位,单位类别不能替代层级判断。
List<Sys_unit> units = dao.query(Sys_unit.class, Cnd.where("unitLevel", "=", 2).asc("unitcode"));
List<String> selectUnitIds = units.stream().filter(unit -> StrUtil.isNotBlank(unit.getUnionId()) && unit.getUnionId().equals(unionId)).map(Sys_unit::getId).toList();
List<Sys_unit> matchUnits = units.stream().filter(unit -> StrUtil.isBlank(unit.getUnionId()) || (StrUtil.isNotBlank(unit.getUnionId()) && unit.getUnionId().equals(unionId))).toList();
NutMap transferData = NutMap.NEW().addv("selectUnitIds", selectUnitIds).addv("allUnits", matchUnits);
@@ -542,7 +542,8 @@ public class SysUnionController {
public Result branchUnionPartUnitSet(String unionId, @Param("unitIds") String[] unitIds) {
sysUnitService.update(Chain.make("unionId", null), Cnd.where("unionId", "=", unionId));
if (Lang.isNotEmpty(unitIds)) {
sysUnitService.update(Chain.make("unionId", unionId), Cnd.where("id", "in", unitIds));
// 保存时再次限制单位层级,避免绕过穿梭框提交三级或其他层级单位。
sysUnitService.update(Chain.make("unionId", unionId), Cnd.where("id", "in", unitIds).and("unitLevel", "=", 2));
}
return Result.success();
}
@@ -245,7 +245,8 @@ public class ActivityBasicScopeController {
COLUMN_COMMENT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'sys_user'
AND TABLE_SCHEMA = 'budwk_v5_mini'
-- 使用当前连接数据库,避免环境切换后查询不到字段元数据。
AND TABLE_SCHEMA = DATABASE()
""");
List<NutMap> sqlDataList = activityBasicScopeService.listMap(sql);
// sqlDataList.forEach(v -> v.put("DATA_TYPE", new String((byte[]) v.get("DATA_TYPE"))));
@@ -3,7 +3,7 @@ package com.budwk.app.zhgh.dayofficework.healthCheckup.service.impl;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.base.utils.ManyAddOrRenewUtil;
import com.budwk.app.sys.views.View_user;
import com.budwk.app.zhgh.activity.basic.models.ActivityUserScope;
import com.budwk.app.zhgh.activity.basic.service.ActivityBasicScopeService;
import com.budwk.app.zhgh.dayofficework.healthCheckup.model.*;
import com.budwk.app.zhgh.dayofficework.healthCheckup.service.HealthCheckupProjectService;
import org.nutz.aop.interceptor.ioc.TransAop;
@@ -36,6 +36,9 @@ public class HealthCheckupProjectServiceImpl extends BaseServiceImpl<HealthCheck
@Inject
private ManyAddOrRenewUtil manyAddOrRenewUtil;
@Inject
private ActivityBasicScopeService activityBasicScopeService;
public HealthCheckupProjectServiceImpl(Dao dao) {
super(dao);
}
@@ -97,9 +100,10 @@ public class HealthCheckupProjectServiceImpl extends BaseServiceImpl<HealthCheck
public List<HealthCheckupUser> getHealthCheckupUserListByProject(HealthCheckupProject healthCheckupProject) {
String projectId = healthCheckupProject.getId();
List<ActivityUserScope> groupUsers = dao().query(ActivityUserScope.class, Cnd.where("groupId", "=", healthCheckupProject.getActivityGroupId()));
List<String> userIds = groupUsers.stream().map(ActivityUserScope::getUserId).collect(Collectors.toList());
List<View_user> userMapList = dao().query(View_user.class, Cnd.where("id", "in", userIds));
// 可报名人员范围同时支持人员结果分组和 SQL 条件分组,统一通过分组服务动态解析人员,
// 避免 SQL 条件分组因未保存 userId 明细而生成空名单。
List<View_user> userMapList = dao().query(View_user.class, Cnd.where("id", "in",
activityBasicScopeService.buildGroupUserIdSubSql(healthCheckupProject.getActivityGroupId())));
return userMapList.stream().map(v -> {
HealthCheckupUser user = new HealthCheckupUser();
user.setId(R.UU32());
@@ -97,6 +97,21 @@ public class OutlayManageClubQueryController {
return Result.success(pagination);
}
/**
* 导出社团拨付核对汇总表。
*
* @param year 统计年度,未传时默认当前年度
* @param clubId 社团主键,可为空;为空时导出当前用户权限范围内的全部社团
* @param response 下载响应,返回 XSSF Excel 文件流
*/
@At
@Ok("void")
@ApiOperation("导出社团拨付核对汇总表")
@SaCheckPermission("outlay.outlayManage.club.query")
public void exportSummary(Integer year, String clubId, HttpServletResponse response) {
clubQueryService.exportSummary(year, clubId, response);
}
@At
@ApiOperation("提交核对")
@@ -29,6 +29,7 @@ import org.nutz.lang.Strings;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
/**
@@ -90,6 +91,21 @@ public class OutlayManageClubUseDetailController {
return Result.success(pagination);
}
/**
* 导出社团预算使用情况汇总表。
*
* @param year 统计年度,未传时默认当前年度
* @param clubId 社团主键,可为空;为空时导出当前用户权限范围内的全部社团
* @param response 下载响应,返回 XSSF Excel 文件流
*/
@At
@Ok("void")
@ApiOperation("导出社团预算使用情况")
@SaCheckPermission("outlay.outlayManage.club.useDetail")
public void doExport(Integer year, String clubId, HttpServletResponse response) {
outlayUseDetailService.exportClubUseDetail(year, clubId, response);
}
@At
@ApiOperation("某个社团预算使用详情")
@@ -5,9 +5,20 @@ import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.service.BaseService;
import org.nutz.dao.sql.Sql;
import javax.servlet.http.HttpServletResponse;
public interface OutlayManageClubQueryService extends BaseService {
void doMoney(String clubId);
Sql backUserSql(PageForm pageForm, String clubId, String type, Integer year, String payed);
/**
* 导出社团拨付核对汇总表。
*
* @param year 统计年度,未传时默认当前年度
* @param clubId 社团主键,可为空;为空时导出当前用户权限范围内的全部社团
* @param response 下载响应,用于向浏览器输出 XSSF Excel 文件
*/
void exportSummary(Integer year, String clubId, HttpServletResponse response);
}
@@ -3,6 +3,8 @@ package com.budwk.app.zhgh.dayofficework.outlay.outlayManage.service;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.model.OutlayUseDetail;
import javax.servlet.http.HttpServletResponse;
public interface OutlayUseDetailService extends BaseService<OutlayUseDetail> {
@@ -13,4 +15,13 @@ public interface OutlayUseDetailService extends BaseService<OutlayUseDetail> {
*/
void doDeleteDetail(String id,String outlayType);
void doEditDetail(OutlayUseDetail outlayUseDetail,String outlayType);
/**
* 导出社团预算使用情况汇总表。
*
* @param year 统计年度
* @param clubId 社团主键,可为空;为空时导出当前用户权限范围内的全部社团
* @param response 下载响应,用于向浏览器输出 XSSF Excel 文件
*/
void exportClubUseDetail(Integer year, String clubId, HttpServletResponse response);
}
@@ -1,5 +1,9 @@
package com.budwk.app.zhgh.dayofficework.outlay.outlayManage.service.impl;
import cn.afterturn.easypoi.excel.ExcelExportUtil;
import cn.afterturn.easypoi.excel.entity.ExportParams;
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
@@ -7,6 +11,11 @@ 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.service.impl.BaseServiceImpl;
import com.budwk.app.base.utils.CommonDownloadUtil;
import com.budwk.app.sys.models.Sys_user_role;
import com.budwk.app.sys.services.SysRoleService;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.club.service.impl.SysClubUserServiceImpl;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.club.model.OutlayClubUserBackHistory;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.club.model.OutlayManageClub;
@@ -19,10 +28,15 @@ import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.dao.util.cri.SqlExpressionGroup;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import org.apache.poi.ss.usermodel.Workbook;
import javax.servlet.http.HttpServletResponse;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
@@ -36,6 +50,10 @@ import java.util.stream.Collectors;
*/
@IocBean(args = {"refer:dao"})
public class OutlayManageClubQueryServiceImpl extends BaseServiceImpl implements OutlayManageClubQueryService {
@Inject
private SysRoleService sysRoleService;
public OutlayManageClubQueryServiceImpl(Dao dao) {
super(dao);
}
@@ -189,4 +207,66 @@ public class OutlayManageClubQueryServiceImpl extends BaseServiceImpl implements
sql.setCondition(cnd);
return sql;
}
/**
* 导出当前用户可查询的社团拨付核对汇总数据。
* 年审人数、可拨付人数和不可拨付人数分别取当前年度记录中的历史统计值;
* 拨付标准依据现有规则计算:0 人为 0 元,1 至 49 人为 3000 元,50 人及以上为 5000 元。
*
* @param year 统计年度,未传时使用当前年度
* @param clubId 社团主键,可为空
* @param response 下载响应,用于输出 Excel 文件
*/
@Override
public void exportSummary(Integer year, String clubId, HttpServletResponse response) {
Integer queryYear = year == null ? DateUtil.thisYear() : year;
Cnd cnd = Cnd.NEW();
cnd.and("year", "=", queryYear);
if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_CLUB_ADMIN.name())) {
List<String> roleIds = sysRoleService.getRoleIdsByCode(List.of(RoleConstant.CLUB_PRESIDENT, RoleConstant.CLUB_SECRETARY));
List<Sys_user_role> userRoles = dao().query(Sys_user_role.class,
Cnd.where("userId", "=", SecurityUtil.getUserId())
.and("roleId", "in", roleIds));
List<String> myClubIds = userRoles.stream().map(Sys_user_role::getClubId).toList();
cnd.andEX("clubId", "in", myClubIds);
}
cnd.andEX("clubId", "=", clubId);
cnd.asc("clubCode");
Sql summarySql = Sqls.create("SELECT * FROM `outlay_manage_club` $condition");
summarySql.setCondition(cnd);
List<NutMap> summaryList = listMap(summarySql);
for (NutMap summary : summaryList) {
int giveMoneyNum = summary.getInt("historyGiveMoneyNum");
summary.put("standard", getAllocateStandard(giveMoneyNum));
summary.put("isCheckName", summary.getBoolean("isCheck") ? "已核对" : "未核对");
}
List<ExcelExportEntity> exportEntities = new ArrayList<>();
exportEntities.add(new ExcelExportEntity("社团名称", "clubName", 25));
exportEntities.add(new ExcelExportEntity("年审人数", "historyUserNum", 15));
exportEntities.add(new ExcelExportEntity("可拨付人数", "historyGiveMoneyNum", 15));
exportEntities.add(new ExcelExportEntity("不可拨付人数", "historyNotGiveMoneyNum", 15));
exportEntities.add(new ExcelExportEntity("标准", "standard", 15));
exportEntities.add(new ExcelExportEntity("拨付总额", "totalQuota", 15));
exportEntities.add(new ExcelExportEntity("是否核对", "isCheckName", 15));
ExportParams exportParams = new ExportParams();
exportParams.setType(ExcelType.XSSF);
Workbook workbook = ExcelExportUtil.exportExcel(exportParams, exportEntities, summaryList);
CommonDownloadUtil.download(queryYear + "年社团拨付核对汇总.xlsx", workbook, response);
}
/**
* 根据可拨付人数返回社团年度拨付标准。
*
* @param giveMoneyNum 可拨付人数
* @return 对应的拨付标准金额
*/
private BigDecimal getAllocateStandard(int giveMoneyNum) {
if (giveMoneyNum == 0) {
return BigDecimal.ZERO;
}
return giveMoneyNum >= 50 ? BigDecimal.valueOf(5000) : BigDecimal.valueOf(3000);
}
}
@@ -1,13 +1,35 @@
package com.budwk.app.zhgh.dayofficework.outlay.outlayManage.service.impl;
import cn.afterturn.easypoi.excel.ExcelExportUtil;
import cn.afterturn.easypoi.excel.entity.ExportParams;
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity;
import cn.hutool.core.date.DateUtil;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.base.utils.CommonDownloadUtil;
import com.budwk.app.sys.models.Sys_role;
import com.budwk.app.sys.models.Sys_user_role;
import com.budwk.app.sys.services.SysRoleService;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.club.model.OutlayManageClub;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.model.OutlayUseDetail;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.school.model.OutlayManageSchool;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.service.OutlayUseDetailService;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.union.model.OutlayManageUnion;
import org.apache.poi.ss.usermodel.Workbook;
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.util.NutMap;
import javax.servlet.http.HttpServletResponse;
import java.util.ArrayList;
import java.util.List;
/**
* @author zhf
@@ -16,6 +38,10 @@ import org.nutz.ioc.loader.annotation.IocBean;
*/
@IocBean(args = {"refer:dao"})
public class OutlayUseDetailServiceImpl extends BaseServiceImpl<OutlayUseDetail> implements OutlayUseDetailService {
@Inject
private SysRoleService sysRoleService;
public OutlayUseDetailServiceImpl(Dao dao) {
super(dao);
}
@@ -34,6 +60,54 @@ public class OutlayUseDetailServiceImpl extends BaseServiceImpl<OutlayUseDetail>
update(outlayUseDetail);
}
/**
* 按社团预算使用页面的查询口径导出汇总数据。
* 剩余额度由分配总额度减已使用额度计算,避免导出内容与页面展示不一致。
*
* @param year 统计年度,未传时使用当前年度
* @param clubId 社团主键,可为空
* @param response 下载响应,用于输出 Excel 文件
*/
@Override
public void exportClubUseDetail(Integer year, String clubId, HttpServletResponse response) {
Integer queryYear = year == null ? DateUtil.thisYear() : year;
Cnd cnd = Cnd.NEW();
cnd.and("totalQuota", "IS NOT", null);
cnd.and("year", "=", queryYear);
if (AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) {
cnd.andEX("clubId", "=", clubId);
} else {
Sys_role sysRole = sysRoleService.getByCode(RoleConstant.CLUB_PRESIDENT);
List<Sys_user_role> userRoles = dao().query(Sys_user_role.class,
Cnd.where("userId", "=", SecurityUtil.getUserId())
.and("roleId", "=", sysRole.getId()));
List<String> myClubIds = userRoles.stream().map(Sys_user_role::getClubId).toList();
cnd.andEX("clubId", "in", myClubIds);
}
cnd.asc("clubCode");
Sql sql = Sqls.create("""
SELECT *, COALESCE(totalQuota, 0) - COALESCE(usedQuota, 0) AS surplusQuota
FROM outlay_manage_club $condition
""");
sql.setCondition(cnd);
List<NutMap> clubUseDetailList = listMap(sql);
List<ExcelExportEntity> exportEntities = new ArrayList<>();
exportEntities.add(new ExcelExportEntity("年度", "year", 15));
exportEntities.add(new ExcelExportEntity("社团名称", "clubName", 25));
exportEntities.add(new ExcelExportEntity("社团编码", "clubCode", 20));
exportEntities.add(new ExcelExportEntity("分配总额度(元)", "totalQuota", 18));
exportEntities.add(new ExcelExportEntity("已使用额度(元)", "usedQuota", 18));
exportEntities.add(new ExcelExportEntity("剩余额度(元)", "surplusQuota", 18));
ExportParams exportParams = new ExportParams();
exportParams.setType(ExcelType.XSSF);
Workbook workbook = ExcelExportUtil.exportExcel(exportParams, exportEntities, clubUseDetailList);
CommonDownloadUtil.download(queryYear + "年社团预算使用情况.xlsx", workbook, response);
}
/**
* 同步预算主表的已使用额度,保证校工会、分工会、协会三类台账口径一致。
*
@@ -265,6 +265,7 @@ public class ProposalMineController {
Cnd cnd = Cnd.where("t1.sessionId", "=", sessionId);
cnd.andEX("t1.delegationId", "=", delegationId);
cnd.and("t1.roleId", "=", "72e24b5b4c4f4e90a6641e6af8487e42");
cnd.and("t1.delFlag", "!=", 1);
cnd.and("t1.loginName", "!=", SecurityUtil.getUserLoginname());
if (isInvite) {
@@ -286,11 +287,33 @@ public class ProposalMineController {
return Result.success(pagination);
}
/**
* 邀请当前提案所属教代会届次内的正式代表参与附议。
*
* @param seconders 附议人工号数组,数组中的每个工号必须属于当前提案届次的有效正式代表
* @param proposalId 提案ID,用于确定流程实例及可信的教代会届次
* @return Result;邀请成功返回 success,提案不存在、代表信息无效或人数不足时返回错误信息
*/
@At
@SaCheckPermission("proposal.mine")
@Aop(TransAop.READ_COMMITTED)
@SLog(tag = "提案", msg = "邀请附议人")
public Result inviteSeconder(@Param("seconders") String[] seconders, @Valid String proposalId) {
if (seconders == null || seconders.length == 0) {
return Result.error("请选择附议人");
}
ProposalInfo proposalInfo = dao.fetch(ProposalInfo.class, proposalId);
if (proposalInfo == null || StrUtil.isBlank(proposalInfo.getSessionId())) {
return Result.error("提案信息不存在");
}
List<String> requestedLoginNames = List.of(seconders).stream()
.filter(StrUtil::isNotBlank)
.distinct()
.toList();
if (requestedLoginNames.isEmpty()) {
return Result.error("请选择附议人");
}
// 查询已经邀请的附议人
ProcessInstance processInstance = dao.fetch(ProcessInstance.class, Cnd.where(ProcessInstance::getBusinessNo, "=", proposalId));
@@ -319,11 +342,7 @@ public class ProposalMineController {
ProposalConfig proposalConfig = dao.fetch(ProposalConfig.class, Cnd.NEW());
if (tasks.size() + seconders.length < proposalConfig.getSeconderNum()) {
return Result.error("至少邀请" + proposalConfig.getSeconderNum() + "个附议人");
}
// 必须使用提案自身的届次查询,不能只按工号匹配,否则同一代表的历史届次记录会污染代表团等快照信息。
Sql sql = Sqls.create("""
SELECT
t1.id,
@@ -340,8 +359,20 @@ public class ProposalMineController {
LEFT JOIN teacher_congress_delegation t2 ON t2.id = t1.delegationId
$condition
""");
sql.setCondition(Cnd.where("t1.loginName", "in", seconders).groupBy("t1.loginName"));
Cnd seconderCnd = Cnd.where("t1.sessionId", "=", proposalInfo.getSessionId())
.and("t1.loginName", "in", requestedLoginNames)
.and("t1.roleId", "=", "72e24b5b4c4f4e90a6641e6af8487e42")
.and("t1.delFlag", "!=", 1);
sql.setCondition(seconderCnd);
List<NutMap> list = proposalCommonService.listMap(sql);
// 每个工号在当前届次必须且只能匹配一条有效代表记录,防止无效工号或重复基础数据进入流程。
if (list.size() != requestedLoginNames.size()) {
return Result.error("部分附议人不属于当前提案届次或代表信息重复,请重新选择");
}
if (tasks.size() + list.size() < proposalConfig.getSeconderNum()) {
return Result.error("至少邀请" + proposalConfig.getSeconderNum() + "个附议人");
}
// 附议人ID
List<String> userIds = list.stream().map(item -> item.getString("userId")).toList();
// 获取正在执行的任务(邀请附议人||提案附议)
@@ -45,6 +45,7 @@ import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@IocBean(args = {"refer:dao"})
@@ -76,14 +77,24 @@ public class ProposalCommitteeFilingUnitServiceImpl extends BaseServiceImpl<Prop
throw new BaseException("没有进行中的流程任务");
}
String proposalId = StrUtil.trim(args.getStr("proposalId"));
if (StrUtil.isBlank(proposalId) || dao().fetch(ProposalInfo.class, proposalId) == null) {
throw new BaseException("提案信息不存在");
}
args.put("proposalId", proposalId);
validateAndNormalizeCaseFilingCode(args);
String proposalId = args.getStr("proposalId");
List<String> proposalIds = proposalCommonService.mergeProposal(proposalId);
if (ObjectUtil.isEmpty(proposalIds)) {
proposalIds = List.of(proposalId);
}
/*
* 先同步 proposal_info 和承办单位,再执行 WF 流转,使查看页面及下一节点读取到的都是本次最新数据。
* 当前方法处于同一事务中,后续流程执行失败时这里的业务数据会一并回滚。
*/
syncCommitteeFilingUnitData(proposalIds, args);
/*
* 并案场景下,同一个节点会在每条提案实例上各有一条待办。
* 这里统一按 taskName 找出整组正在办理中的任务,逐条执行,确保并案提案的流程状态保持一致。
@@ -97,8 +108,6 @@ public class ProposalCommitteeFilingUnitServiceImpl extends BaseServiceImpl<Prop
cloneArgs.put(FlowConst.PROCESS_TASK_ID_KEY, executeTask.getId());
flowCommonService.executeTask(cloneArgs);
}
syncCommitteeFilingUnitData(proposalIds, args);
}
/**
@@ -110,8 +119,10 @@ public class ProposalCommitteeFilingUnitServiceImpl extends BaseServiceImpl<Prop
private void validateAndNormalizeCaseFilingCode(Dict args) {
String caseFilingResultKey = FlowConst.TASK_FORM_DATA_PREFIX + "caseFilingResult";
String caseFilingCodeKey = FlowConst.TASK_FORM_DATA_PREFIX + "caseFilingCode";
String caseFilingTypeKey = FlowConst.TASK_FORM_DATA_PREFIX + "caseFilingType";
if (!"CONFIRM_FILING".equals(args.getStr(caseFilingResultKey))) {
args.put(caseFilingCodeKey, "");
args.put(caseFilingTypeKey, "");
return;
}
@@ -139,6 +150,7 @@ public class ProposalCommitteeFilingUnitServiceImpl extends BaseServiceImpl<Prop
.add("caseFilingResult", caseFilingResult)
.add("caseFilingType", caseFilingType),
Cnd.where(ProposalInfo::getId, "in", proposalIds));
verifyCommitteeFilingUnitData(proposalIds, caseFilingCode, caseFilingResult, caseFilingType);
dao().clear(ProposalReplyUnit.class, Cnd.where(ProposalReplyUnit::getProposalId, "in", proposalIds));
if (!List.of("CONFIRM_FILING", "SUGGESTION").contains(caseFilingResult)) {
@@ -175,6 +187,28 @@ public class ProposalCommitteeFilingUnitServiceImpl extends BaseServiceImpl<Prop
}
}
/**
* 校验本次立案字段是否已同步到全部提案,避免流程变量更新成功但 info 基础信息仍保留旧值。
*/
private void verifyCommitteeFilingUnitData(List<String> proposalIds, String caseFilingCode,
String caseFilingResult, String caseFilingType) {
List<ProposalInfo> proposalInfos = dao().query(ProposalInfo.class,
Cnd.where(ProposalInfo::getId, "in", proposalIds));
if (proposalInfos.size() != new LinkedHashSet<>(proposalIds).size()) {
throw new BaseException("部分提案信息不存在,无法同步立案结果");
}
boolean syncFailed = proposalInfos.stream().anyMatch(proposalInfo ->
!Objects.equals(StrUtil.nullToDefault(proposalInfo.getCaseFilingCode(), ""),
StrUtil.nullToDefault(caseFilingCode, ""))
|| !Objects.equals(StrUtil.nullToDefault(proposalInfo.getCaseFilingResult(), ""),
StrUtil.nullToDefault(caseFilingResult, ""))
|| !Objects.equals(StrUtil.nullToDefault(proposalInfo.getCaseFilingType(), ""),
StrUtil.nullToDefault(caseFilingType, "")));
if (syncFailed) {
throw new BaseException("立案结果同步失败,请稍后重试");
}
}
/**
* 一个提案可以配置多个主办单位和多个协办单位。
* 这里按页面提交顺序逐条生成承办单位记录,兼容多主办的业务场景。
@@ -95,6 +95,7 @@ public class SuggestionBoxMineController {
}
cnd.andEX("YEAR(info.submitTime)", "=", year);
cnd.desc("info.submitTime");
cnd.groupBy("info.id");
sql.setCondition(cnd);
Pagination<NutMap> pagination = suggestionBoxService.listPageMap(pageNumber, pageSize, sql);
return Result.success(pagination);
@@ -20,6 +20,7 @@ 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.google.common.net.HttpHeaders;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
@@ -29,6 +30,7 @@ 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;
@@ -59,7 +61,7 @@ public class LearningCourseDisplayController {
private SysDictService sysDictService;
@At("")
@Ok("beetl:/platform/zhgh/Learning/courseDisplay/index.html")
@Ok("beetl:/platform/zhgh/Learning/courseDisplay/indexV2.html")
@SaCheckPermission("learning.course.display")
public void index() {
}
@@ -71,7 +73,7 @@ public class LearningCourseDisplayController {
}
@At("/study")
@Ok("beetl:/platform/zhgh/Learning/courseDisplay/study.html")
@Ok("beetl:/platform/zhgh/Learning/courseDisplay/studyV2.html")
@SaCheckPermission("learning.course.display")
public void study() {
}
@@ -79,7 +81,12 @@ public class LearningCourseDisplayController {
@At
@ApiOperation("课程展示列表")
@SaCheckPermission("learning.course.display")
public Result pageData(PageForm pageForm, String keyword, String courseTypeId, String recommendFlag) {
/**
* 查询课程展示列表。
* pageForm 传分页页码和每页条数;keyword、courseTypeId、recommendFlag 分别用于关键词、分类和推荐标识筛选;
* sortType 支持 default(综合排序)和 view_count(观看次数倒序)。返回值为 Pagination,list 中包含课程基础字段、课节数、总时长和观看次数。
*/
public Result pageData(PageForm pageForm, String keyword, String courseTypeId, String recommendFlag, String sortType) {
Cnd cnd = Cnd.where("c.status", "=", "published");
if (StrUtil.isNotBlank(keyword)) {
cnd.and(Cnd.exps("c.courseName", "like", "%" + keyword + "%")
@@ -103,13 +110,22 @@ public class LearningCourseDisplayController {
Sql listSql = Sqls.create("""
SELECT c.id, c.courseName, c.courseIntro, c.startTime, c.endTime, c.openType, c.cover,
c.recommendFlags, c.courseTypeId, c.lecturerName, c.sortNum, t.typeName AS courseTypeName
c.recommendFlags, c.courseTypeId, c.lecturerName, c.sortNum,
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') AS resourceCount,
COALESCE((SELECT SUM(r.durationSeconds) FROM learning_outline_resource r
WHERE r.courseId = c.id AND r.status = 'enabled'), 0) AS totalDurationSeconds
FROM learning_course c
LEFT JOIN learning_course_type t ON t.id = c.courseTypeId
$condition
ORDER BY c.sortNum ASC, c.createdAt DESC
ORDER BY $orderBy
""");
listSql.setCondition(cnd);
// 排序字段由固定选项映射,禁止将前端参数直接拼接进SQL。
listSql.setVar("orderBy", "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);
@@ -126,13 +142,24 @@ public class LearningCourseDisplayController {
@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.startTime, c.endTime, c.openType, c.cover,
c.recommendFlags, c.lecturerName, t.typeName AS courseTypeName
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') AS resourceCount,
COALESCE((SELECT SUM(r.durationSeconds) FROM learning_outline_resource r
WHERE r.courseId = c.id AND r.status = 'enabled'), 0) AS totalDurationSeconds
FROM learning_course c
LEFT JOIN learning_course_type t ON t.id = c.courseTypeId
WHERE c.id = @id
@@ -161,12 +188,17 @@ public class LearningCourseDisplayController {
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) {
@@ -194,6 +226,9 @@ public class LearningCourseDisplayController {
}
}
for (LearningOutlineResource resource : resources) {
// 章节学习规则控制其下媒体播放行为;未配置规则时保持原有的可拖动行为。
LearningStudyRule outlineRule = outlineRuleMap.get(resource.getOutlineId());
boolean allowDrag = outlineRule == null || !Boolean.FALSE.equals(outlineRule.getAllowDrag());
NutMap node = NutMap.NEW()
.addv("id", resource.getId())
.addv("type", "resource")
@@ -207,6 +242,8 @@ public class LearningCourseDisplayController {
.addv("progressPercent", recordMap.containsKey(resource.getOutlineId()) ? recordMap.get(resource.getOutlineId()).getProgressPercent() : 0)
.addv("studySeconds", recordMap.containsKey(resource.getOutlineId()) ? recordMap.get(resource.getOutlineId()).getStudySeconds() : 0)
.addv("lastPositionSeconds", recordMap.containsKey(resource.getOutlineId()) ? value(recordMap.get(resource.getOutlineId()).getLastPositionSeconds()) : 0)
.addv("maxPositionSeconds", resourceMaxPosition(recordMap.get(resource.getOutlineId()), resource.getId()))
.addv("allowDrag", allowDrag)
.addv("completeStatus", recordMap.containsKey(resource.getOutlineId()) ? recordMap.get(resource.getOutlineId()).getCompleteStatus() : "not_started");
childrenMap.computeIfAbsent(resource.getOutlineId(), key -> new ArrayList<>()).add(node);
}
@@ -294,4 +331,23 @@ public class LearningCourseDisplayController {
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;
}
}
}
@@ -18,6 +18,8 @@ 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;
@@ -113,6 +115,8 @@ public class LearningCourseManageController {
@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) {
@@ -121,6 +125,9 @@ public class LearningCourseManageController {
if (StrUtil.isBlank(course.getStatus())) {
course.setStatus("draft");
}
if (course.getViewCount() == null) {
course.setViewCount(0);
}
dao.insert(course);
return Result.success();
}
@@ -129,6 +136,8 @@ public class LearningCourseManageController {
@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("请选择要编辑的数据");
@@ -145,6 +154,8 @@ public class LearningCourseManageController {
@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("请选择要删除的数据");
@@ -200,6 +211,9 @@ public class LearningCourseManageController {
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("请选择开课时间");
}
@@ -227,6 +241,7 @@ public class LearningCourseManageController {
columns.put("status", "c.status");
columns.put("recommendFlags", "c.recommendFlags");
columns.put("sortNum", "c.sortNum");
columns.put("viewCount", "c.viewCount");
return columns.get(prop);
}
}
@@ -24,8 +24,11 @@ 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;
@@ -151,6 +154,11 @@ public class LearningStudyRecordController {
@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) {
@@ -176,18 +184,30 @@ public class LearningStudyRecordController {
.and("outlineId", "=", outline.getId()));
if (record == null) {
record = buildRecord(userId, course, outline, now);
record.setLastPositionSeconds(sanitizePosition(positionSeconds, 0));
int startPositionSeconds = sanitizePosition(positionSeconds, 0);
record.setLastPositionSeconds(startPositionSeconds);
record.setResourceMaxPositionData(resourceMaxPositionData(resourceId, startPositionSeconds));
dao.insert(record);
} else {
int requiredSeconds = requiredSeconds(outline.getId());
dao.update(LearningStudyRecord.class, Chain.make("latestStudyTime", now)
int startPositionSeconds = sanitizePosition(positionSeconds, 0);
Map<String, Integer> resourceMaxPositions = resourceMaxPositions(record.getResourceMaxPositionData());
int maxPositionSeconds = value(resourceMaxPositions.get(resourceId));
boolean needUpdateMaxPosition = !resourceMaxPositions.containsKey(resourceId) || startPositionSeconds > maxPositionSeconds;
if (needUpdateMaxPosition) {
resourceMaxPositions.put(resourceId, Math.max(startPositionSeconds, maxPositionSeconds));
}
Chain chain = Chain.make("latestStudyTime", now)
.add("courseName", course.getCourseName())
.add("outlineName", outline.getTitle())
.add("lastPositionSeconds", sanitizePosition(positionSeconds, record.getLastPositionSeconds()))
.add("requiredSeconds", requiredSeconds)
.add("progressPercent", progress(record.getStudySeconds(), requiredSeconds))
.add("completeStatus", "completed".equals(record.getCompleteStatus()) ? "completed" : "studying"),
Cnd.where("id", "=", record.getId()));
.add("completeStatus", "completed".equals(record.getCompleteStatus()) ? "completed" : "studying");
if (needUpdateMaxPosition) {
chain.add("resourceMaxPositionData", Json.toJson(resourceMaxPositions));
}
dao.update(LearningStudyRecord.class, chain, Cnd.where("id", "=", record.getId()));
record = dao.fetch(LearningStudyRecord.class, record.getId());
}
@@ -204,19 +224,28 @@ public class LearningStudyRecordController {
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);
return Result.success(NutMap.NEW()
.addv("recordId", record.getId())
.addv("segmentId", segment.getId())
.addv("studySeconds", record.getStudySeconds())
.addv("lastPositionSeconds", record.getLastPositionSeconds())
.addv("maxPositionSeconds", resourceMaxPosition(record, resourceId))
.addv("requiredSeconds", record.getRequiredSeconds())
.addv("progressPercent", record.getProgressPercent())
.addv("completeStatus", record.getCompleteStatus()));
.addv("completeStatus", record.getCompleteStatus())
.addv("viewCount", value(updatedCourse == null ? null : updatedCourse.getViewCount())));
}
@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) {
@@ -225,21 +254,27 @@ public class LearningStudyRecordController {
return Result.error("学习时段已结束,请重新开始学习");
}
int seconds = Math.max(0, Math.min(activeSeconds == null ? 0 : activeSeconds, MAX_HEARTBEAT_SECONDS));
return Result.success(addActiveSeconds(segment, seconds, false, positionSeconds));
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("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, positionSeconds);
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);
}
@@ -247,6 +282,7 @@ public class LearningStudyRecordController {
@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) {
@@ -321,20 +357,28 @@ public class LearningStudyRecordController {
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, Integer positionSeconds) {
private NutMap addActiveSeconds(LearningStudySegment segment, int activeSeconds, boolean finish, boolean completed, Integer positionSeconds) {
Date now = new Date();
int segmentSeconds = value(segment.getActiveSeconds()) + activeSeconds;
LearningStudyRecord record = dao.fetch(LearningStudyRecord.class, segment.getRecordId());
LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, segment.getResourceId());
int studyIncrementSeconds = effectiveStudySeconds(record, resource, segment.getResourceId(), activeSeconds, positionSeconds);
int segmentSeconds = value(segment.getActiveSeconds()) + studyIncrementSeconds;
dao.update(LearningStudySegment.class, Chain.make("activeSeconds", segmentSeconds).add("lastHeartbeatTime", now), Cnd.where("id", "=", segment.getId()));
LearningStudyRecord record = dao.fetch(LearningStudyRecord.class, segment.getRecordId());
int studySeconds = value(record.getStudySeconds()) + activeSeconds;
int studySeconds = value(record.getStudySeconds()) + studyIncrementSeconds;
int requiredSeconds = requiredSeconds(record.getOutlineId());
int maxPositionSeconds = resourceMaxPosition(record, segment.getResourceId());
// 自然播放结束且当前章节仅有一个媒体资源时,补齐定时器未统计到的尾部秒数,避免跨端状态不一致。
if (completed && canCompleteByNaturalEnd(segment, positionSeconds, requiredSeconds)) {
studySeconds = Math.max(studySeconds, requiredSeconds);
}
int progressPercent = progress(studySeconds, requiredSeconds);
String completeStatus = completeStatus(studySeconds, requiredSeconds, finish);
if ("completed".equals(completeStatus)) {
@@ -350,6 +394,13 @@ public class LearningStudyRecordController {
lastPositionSeconds = positionSeconds;
chain.add("lastPositionSeconds", positionSeconds);
}
if (resource != null && isMediaResource(resource)) {
Map<String, Integer> resourceMaxPositions = resourceMaxPositions(record.getResourceMaxPositionData());
int currentPositionSeconds = Math.max(0, positionSeconds == null ? 0 : positionSeconds);
maxPositionSeconds = Math.max(value(resourceMaxPositions.get(segment.getResourceId())), currentPositionSeconds);
resourceMaxPositions.put(segment.getResourceId(), maxPositionSeconds);
chain.add("resourceMaxPositionData", Json.toJson(resourceMaxPositions));
}
if ("completed".equals(completeStatus) && record.getCompletedAt() == null) {
chain.add("completedAt", now);
}
@@ -358,6 +409,7 @@ public class LearningStudyRecordController {
.addv("studySeconds", studySeconds)
.addv("studyTimeText", formatStudySeconds(studySeconds))
.addv("lastPositionSeconds", lastPositionSeconds)
.addv("maxPositionSeconds", maxPositionSeconds)
.addv("requiredSeconds", requiredSeconds)
.addv("progressPercent", progressPercent)
.addv("completeStatus", completeStatus);
@@ -396,6 +448,85 @@ public class LearningStudyRecordController {
return Math.max(0, sql.getInt());
}
/**
* 校验媒体自然播放结束能否完成当前章节。
* 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;
@@ -110,6 +110,11 @@ public class LearningCourse extends BaseModel {
@ColDefine(type = ColType.INT)
private Integer sortNum;
@Column
@Comment("观看次数")
@ColDefine(type = ColType.INT)
private Integer viewCount;
@Column
@Comment("课程封面")
@ColDefine(type = ColType.VARCHAR, width = 500)
@@ -97,6 +97,11 @@ public class LearningStudyRecord extends BaseModel {
@ColDefine(type = ColType.INT)
private Integer lastPositionSeconds;
@Column
@Comment("各资源最高已观看位置(JSON)")
@ColDefine(type = ColType.TEXT)
private String resourceMaxPositionData;
@Column
@Comment("要求学习时长(秒)")
@ColDefine(type = ColType.INT)
@@ -17,6 +17,7 @@ import com.budwk.app.zhgh.staffmanage.member.constant.MemberChangeType;
import com.budwk.app.zhgh.staffmanage.member.models.MemberChangeRecord;
import com.budwk.app.zhgh.staffmanage.member.param.pageform.MemberChangePageForm;
import com.budwk.app.zhgh.staffmanage.member.service.MemberCommonService;
import com.budwk.app.sys.models.Sys_unit;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiOperation;
import org.nutz.aop.interceptor.ioc.TransAop;
@@ -155,6 +156,12 @@ public class MemberChangeMineController {
}
/**
* 查询会员变更记录详情。
*
* @param id 会员变更记录 ID。
* @return 包含变更记录基础字段、三级单位名称及变更明细的 JSON 对象。
*/
@At
@ApiOperation("根据id查询变更记录")
@SaCheckPermission(value = {"member.change.mine", "member.change.unionGroup.audit"}, mode = SaMode.OR)
@@ -162,6 +169,12 @@ public class MemberChangeMineController {
MemberChangeRecord record = dao.fetch(MemberChangeRecord.class, id);
NutMap nutMap = Lang.obj2nutmap(record);
// 变更记录只保存三级单位 ID,查看时补充当前单位名称供页面展示。
if (StrUtil.isNotBlank(record.getThreeUnitId())) {
Sys_unit threeUnit = dao.fetch(Sys_unit.class, record.getThreeUnitId());
nutMap.put("threeUnitName", threeUnit == null ? "" : threeUnit.getName());
}
List<NutMap> changeInfos = memberCommonService.getChangeInfos(nutMap.getString("id"));
nutMap.put("changeInfos", changeInfos);
return Result.success().addData(nutMap);
@@ -177,6 +177,11 @@ public class MemberChangeRecord extends BaseModel {
@ColDefine(type = ColType.VARCHAR, width = 20)
private String campus;
@Column
@Comment("三级单位ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String threeUnitId;
@Column
@Comment("退休日期")
@ColDefine(type = ColType.DATE)
@@ -30,6 +30,9 @@ public class MemberManagePageForm extends PageForm {
private String unitId;
/** 所属二级单位下的三级单位 ID。 */
private String threeUnitId;
private String sex;
private String personType;
@@ -91,6 +94,7 @@ public class MemberManagePageForm extends PageForm {
cnd.andEX("u.member", "=", pageForm.getIsMember());
cnd.andEX("u.welfareMember", "=", pageForm.getIsWelfareMember());
cnd.andEX("u.unitId", "=", pageForm.getUnitId());
cnd.andEX("u.threeUnitId", "=", pageForm.getThreeUnitId());
cnd.andEX("u.userState", "=", pageForm.getUserState());
cnd.andEX("u.preparedBy", "=", pageForm.getPreparedBy());
cnd.andEX("u.sex", "=", pageForm.getSex());
@@ -257,6 +257,12 @@ public class MemberManageServiceImpl extends BaseServiceImpl<Sys_user> implement
Set<String> allowChangeFieldNames = map.getAs("allowChangeFieldNames", Set.class);
Map<String, String> dictMap = map.getAs("dictMap", Map.class);
// 三级单位仅由高级管理页面传入,未传入时不参与比较,避免其他共用页面误判为清空。
if (record.getThreeUnitId() != null) {
allowChangeFieldNames.add("threeUnitId");
dictMap.put("threeUnitId", "三级单位");
}
// 新数据
NutMap newMap = Lang.obj2nutmap(record);
// 原数据
@@ -11,6 +11,7 @@ import com.budwk.app.zhgh.welfare.model.WelfareList;
import com.budwk.app.zhgh.welfare.model.WelfareUserSelection;
import com.budwk.app.zhgh.welfare.param.WelfareSelectionSituationPageForm;
import com.budwk.app.zhgh.welfare.service.WelfareListService;
import com.budwk.app.zhgh.welfare.service.WelfareProjectService;
import com.budwk.app.zhgh.welfare.service.WelfareSelectionSituationService;
import com.budwk.app.zhgh.welfare.service.WelfareStatisticsService;
import io.swagger.annotations.Api;
@@ -47,6 +48,8 @@ public class WelfareSelectionSituationController {
private WelfareStatisticsService welfareStatisticsService;
@Inject
private WelfareSelectionSituationService situationService;
@Inject
private WelfareProjectService welfareProjectService;
@At("")
@Ok("beetl:/platform/zhgh/welfare/selectionSituation/index.html")
@@ -86,6 +89,11 @@ public class WelfareSelectionSituationController {
return Result.error("此用户没有选择的权限");
}
String defaultOptionValidationMessage = welfareProjectService.validateSystemDefaultOptionSelection(projectId, selections);
if (defaultOptionValidationMessage != null) {
return Result.error(defaultOptionValidationMessage);
}
//删除上次选择的
dao.clear(WelfareUserSelection.class, Cnd.where(WelfareUserSelection::getWelfareId, "=", projectId).and(WelfareUserSelection::getSelectUserId, "=", userId));
for (WelfareUserSelection welfareUserSelection : selections) {
@@ -89,6 +89,11 @@ public class WelfareUserSelectController {
}
WelfareProject project = dao.fetch(WelfareProject.class, projectId);
String defaultOptionValidationMessage = welfareProjectService.validateSystemDefaultOptionSelection(projectId, selections);
if (defaultOptionValidationMessage != null) {
return Result.error(defaultOptionValidationMessage);
}
int sum = Arrays.stream(selections).mapToInt(selection -> Objects.requireNonNullElse(selection.getSelectNum(), 0)).sum();
if (sum > project.getMultiSelectNum()) {
return Result.error("选择的数量不能超过" + project.getMultiSelectNum());
@@ -37,8 +37,11 @@ public class WelfareFilterUserPageForm extends PageForm {
@ApiModelProperty("工会名称")
private String unionName;
@ApiModelProperty("单位id")
private String[] unitIds;
@ApiModelProperty("所属单位ID")
private String unitId;
@ApiModelProperty("三级单位ID")
private String threeUnitId;
@ApiModelProperty("单位名称")
private String unitName;
@@ -2,6 +2,7 @@ package com.budwk.app.zhgh.welfare.service;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.welfare.model.WelfareProject;
import com.budwk.app.zhgh.welfare.model.WelfareUserSelection;
import java.util.List;
@@ -16,6 +17,15 @@ public interface WelfareProjectService extends BaseService<WelfareProject> {
*/
WelfareProject projectInfo(String projectId);
/**
* 校验福利选择是否保留系统默认福利。
*
* @param projectId 福利项目 ID,用于查询该项目配置的系统默认福利
* @param selections 用户提交的福利选择数组,每项需包含 selectOptionId 和大于 0 的 selectNum
* @return 校验通过返回 {@code null};未保留系统默认福利时返回错误提示文本
*/
String validateSystemDefaultOptionSelection(String projectId, WelfareUserSelection[] selections);
/**
* 保存项目信息
* @param project
@@ -471,7 +471,8 @@ public class WelfareListServiceImpl extends BaseServiceImpl<WelfareList> impleme
cnd.andEX("MONTH(u.birthday)", "in", pageForm.getBirthMonths());
}
cnd.andEX("u.unionId", "=", pageForm.getUnionId());
cnd.andEX("u.unitId", "in", pageForm.getUnitIds());
cnd.andEX("u.unitId", "=", pageForm.getUnitId());
cnd.andEX("u.threeUnitId", "=", pageForm.getThreeUnitId());
cnd.andEX("u.personType", "in", pageForm.getPersonTypes());
cnd.andEX("u.userAttribute", "in", pageForm.getUserAttributes());
cnd.andEX("u.preparedBy", "in", pageForm.getPreparedBys());
@@ -524,7 +525,8 @@ public class WelfareListServiceImpl extends BaseServiceImpl<WelfareList> impleme
cnd.andEX("MONTH(birthday)", "in", pageForm.getBirthMonths());
}
cnd.andEX("unionId", "=", pageForm.getUnionId());
cnd.andEX("unitId", "in", pageForm.getUnitIds());
cnd.andEX("unitId", "=", pageForm.getUnitId());
cnd.andEX("threeUnitId", "=", pageForm.getThreeUnitId());
cnd.andEX("personType", "in", pageForm.getPersonTypes());
cnd.andEX("userAttribute", "in", pageForm.getUserAttributes());
cnd.andEX("preparedBy", "in", pageForm.getPreparedBys());
@@ -49,6 +49,30 @@ public class WelfareProjectServiceImpl extends BaseServiceImpl<WelfareProject> i
return project;
}
@Override
public String validateSystemDefaultOptionSelection(String projectId, WelfareUserSelection[] selections) {
List<WelfareProjectSubjectOption> systemDefaultOptions = dao().query(WelfareProjectSubjectOption.class,
Cnd.where("welfareId", "=", projectId).and("isSystemDefault", "=", true));
if (systemDefaultOptions.isEmpty()) {
return null;
}
Set<String> selectedOptionIds = Arrays.stream(selections == null ? new WelfareUserSelection[0] : selections)
.filter(Objects::nonNull)
.filter(selection -> selection.getSelectNum() != null && selection.getSelectNum() > 0)
.map(WelfareUserSelection::getSelectOptionId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
List<String> missingOptionNames = systemDefaultOptions.stream()
.filter(option -> !selectedOptionIds.contains(option.getId()))
.map(WelfareProjectSubjectOption::getOptionName)
.collect(Collectors.toList());
if (missingOptionNames.isEmpty()) {
return null;
}
return "系统默认福利“" + String.join("", missingOptionNames) + "”不可取消";
}
private void fillOptionRank(String projectId, List<WelfareProjectSubjectOption> options) {
if (options == null || options.isEmpty()) {
return;
@@ -22,25 +22,29 @@ const flowMixins = {
background: 'rgba(0, 0, 0, 0.7)'
});
console.log(this.$refs)
console.log(this.$refs.guava)
// this.oepnAudit({})
//
//
// this.$axios.post(location.pathname + '/queryTask', {taskId}).then(res => {
// if (res.code === 0) {
// // 延迟1.5秒 显示loading动画 解决Guava异步加载 获取不到实例的问题
// setTimeout(() => {
// if (res.data.taskState === 10) {
// this.openAudit(res.data);
// } else {
// this.openView(res.data);
// }
// loading.close();
// }, 1500)
// }
// })
let delayCloseLoading = false
this.$axios.post(location.pathname + "/queryTask", {taskId}).then((res) => {
if (res.code === 0 && res.data) {
delayCloseLoading = true
// Guava 为异步组件,等待组件实例完成挂载后再打开任务弹窗。
setTimeout(() => {
try {
if (res.data.taskState === 10) {
this.openAudit(res.data)
} else {
this.openView(res.data)
}
} finally {
loading.close()
}
}, 1500)
}
}).finally(() => {
// 查询失败或未返回任务数据时,必须关闭全屏 Loading。
if (!delayCloseLoading) {
loading.close()
}
})
},
}
}
@@ -71,11 +71,13 @@ const initTableMixins = {
const address = this.pageDataUrl ? this.pageDataUrl : loc() + "/pageData"
this.tableLoading = true
this.$axios.post(address, data ? data : this.pageForm).then((res) => {
this.tableLoading = false
if (res.code === 0) {
this.tableData = res.data.list
this.pageForm.totalCount = res.data.totalCount
}
}).finally(() => {
// 接口异常时同样结束加载状态,避免表格持续显示加载动画。
this.tableLoading = false
})
},
notifySuccess(msg) {
@@ -35,8 +35,9 @@
:accept="fileAccept"
:multiple="true"
list-type="picture-card"
:class="{ 'image-upload-entry-hidden': !showImageUploadEntry }"
>
<i class="el-icon-plus"></i>
<i v-if="showImageUploadEntry" class="el-icon-plus"></i>
<!-- <div class="el-upload__tip" v-if="fileList.length < upload_number">-->
<!-- <div style="margin-top: 8px">{{ upload_text }}</div>-->
<!-- </div>-->
@@ -208,6 +209,10 @@ module.exports = {
const number = Number(this.upload_number)
return Number.isFinite(number) && number > 0 ? number : 1
},
// 单图上传完成后隐藏上传入口;删除图片后 fileList 清空,入口会自动恢复。
showImageUploadEntry() {
return this.uploadLimitNumber !== 1 || this.fileList.length < this.uploadLimitNumber
},
upload_tips() {
const uploadNumber = this.uploadLimitNumber
const fileAccept = this.fileAccept
@@ -655,4 +660,10 @@ module.exports = {
height: var(--upload-height, 148px);
}
/* 单图已上传时只隐藏图片卡片上传入口,保留图片预览列表与删除按钮。 */
.image-upload-entry-hidden.el-upload--picture-card,
.image-upload-entry-hidden .el-upload--picture-card {
display: none !important;
}
</style>
@@ -93,6 +93,22 @@ layout("/layouts/v4/baseLayout.html"){
font-weight: 500;
}
/* 推荐入口的 scoped 样式会在组件挂载后注入,此处提前限制图片尺寸,避免刷新时原图短暂撑开页面。 */
#v4-home-app .entry-card .app-icon {
width: 56px;
height: 56px;
overflow: hidden;
}
#v4-home-app .entry-card .app-icon img {
display: block;
width: 56px;
height: 56px;
max-width: 56px;
max-height: 56px;
object-fit: contain;
}
</style>
<div class="v4-container" id="v4-home-app">
@@ -140,8 +140,8 @@ layout("/layouts/platform.html"){
<template slot-scope="{row}">{{getResourceTypeName(row.resourceType)}}</template>
</el-table-column>
<el-table-column label="文件格式" prop="fileExt" sortable="custom" width="110"></el-table-column>
<el-table-column label="学习时长(分钟)" prop="durationSeconds" sortable="custom" width="140">
<template slot-scope="{row}">{{formatDurationMinutes(row.durationSeconds)}}</template>
<el-table-column 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">
@@ -331,8 +331,8 @@ layout("/layouts/platform.html"){
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="学习时长(分钟)">
<el-input-number v-model="resourceForm.durationMinutes" :min="0" :precision="0" style="width: 100%"></el-input-number>
<el-form-item 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">
@@ -708,8 +708,7 @@ layout("/layouts/platform.html"){
if (row) {
this.resourceForm = Object.assign({}, row, {
outlineName: target.title,
fileData: this.parseFiles(row.fileData),
durationMinutes: this.formatDurationMinutes(row.durationSeconds)
fileData: this.parseFiles(row.fileData)
})
} else {
this.resourceForm = {
@@ -721,7 +720,6 @@ layout("/layouts/platform.html"){
fileExt: "",
fileData: [],
durationSeconds: 0,
durationMinutes: 0,
sortOrder: 1,
required: false,
allowPreview: true,
@@ -742,14 +740,12 @@ layout("/layouts/platform.html"){
this.$refs.resourceForm.validate(async valid => {
if (!valid) return
const data = Object.assign({}, this.resourceForm)
data.durationSeconds = Math.round((data.durationMinutes || 0) * 60)
data.fileExt = this.getFileExt(data.fileData)
if (!this.validResourceExt(data.resourceType, data.fileExt)) {
this.$message.warning("当前资料类型不支持上传 ." + data.fileExt + " 格式文件")
return
}
delete data.outlineName
delete data.durationMinutes
data.fileData = JSON.stringify(data.fileData || [])
const resp = await this.$axios.post(loc() + "/saveResource", data)
if (resp.code === 0) {
@@ -826,6 +822,10 @@ layout("/layouts/platform.html"){
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)
@@ -835,7 +835,8 @@ layout("/layouts/platform.html"){
media.onloadedmetadata = () => {
window.URL.revokeObjectURL(media.src)
if (isFinite(media.duration) && media.duration > 0) {
this.$set(this.resourceForm, "durationMinutes", Math.ceil(media.duration / 60))
// 媒体时长按秒保存,避免先取整到分钟再换算导致实际时长被放大。
this.$set(this.resourceForm, "durationSeconds", Math.ceil(media.duration))
}
}
media.onerror = () => {
@@ -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; min-height: 160px; overflow: hidden; background: #eaf5ee; }
.course-cover img { width: 100%; height: 100%; display: block; object-fit: cover; }
.course-cover-empty { width: 100%; height: 100%; min-height: 160px; display: flex; align-items: center; justify-content: center; color: #77a28a; font-size: 18px; background: linear-gradient(135deg, #e5f5ea, #f3faf6); }
.course-cover-empty 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) {
window.location.href = loc() + "/study?id=" + encodeURIComponent(course.id || "")
}
},
created() {
this.loadOptions().then(() => {
this.pageData()
})
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,251 @@
<!--#
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">
<video v-if="selectedResource.resourceType === 'video'" ref="mediaPlayer" class="media-player" :src="mediaUrl" controls controlslist="nodownload" @play="mediaPlay" @ended="mediaEnded" @timeupdate="saveProgress" @loadedmetadata="mediaReady" @seeking="handleMediaSeeking" @seeked="handleMediaSeeked"></video>
<div v-else-if="selectedResource.resourceType === 'audio'" class="audio-panel"><i class="el-icon-headset"></i><div class="mb20">{{selectedResource.title}}</div><audio ref="mediaPlayer" :src="fileUrl" controls controlslist="nodownload" @play="mediaPlay" @ended="mediaEnded" @timeupdate="saveProgress" @loadedmetadata="mediaReady" @seeking="handleMediaSeeking" @seeked="handleMediaSeeked"></audio></div>
<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 v-if="fileUrl" class="mt20" type="primary" icon="el-icon-view" @click="openFile">打开学习资料</el-button></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="fileUrl" size="mini" icon="el-icon-share" @click="openFile">打开资料</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 && selectedResource.id && !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",
components: {"file-preview": httpVueLoader("/components/plugins/sysFilePreview/index.vue")},
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: {
fileUrl() { return this.getFileUrl(this.selectedResource.fileData) },
mediaUrl() {
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() { const id = this.getFileId(this.selectedResource.fileData); return id ? this.apiBase + "/pdfPreview?id=" + encodeURIComponent(id) : this.fileUrl },
resourceTypeText() { const names = {video: "视频资料", audio: "音频资料", image: "图片资料", pdf: "文档资料", word: "文档资料", ppt: "演示资料"}; return names[this.selectedResource.resourceType] || "课程资料" },
resourceList() { return this.collectResources(this.treeData) },
resourceCount() { return this.resourceList.length },
completedCount() { return this.resourceList.filter((item) => item.completeStatus === "completed" || Number(item.progressPercent || 0) >= 100).length },
courseProgress() { if (!this.resourceCount) return 0; return Math.round(this.resourceList.reduce((total, item) => total + Number(item.progressPercent || 0), 0) / this.resourceCount) },
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 || 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)); 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.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 }) },
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>
<!--#
}
#-->
@@ -85,6 +85,7 @@ layout("/layouts/platform.html"){
</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}">
@@ -159,11 +160,16 @@ layout("/layouts/platform.html"){
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<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>
@@ -224,6 +230,7 @@ layout("/layouts/platform.html"){
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"] }]
}
}
@@ -239,11 +246,12 @@ layout("/layouts/platform.html"){
const address = this.pageDataUrl ? this.pageDataUrl : loc() + "/pageData"
this.tableLoading = true
this.$axios.post(address, data ? data : this.buildPageParams()).then((res) => {
this.tableLoading = false
if (res.code === 0) {
this.tableData = res.data.list
this.pageForm.totalCount = res.data.totalCount
}
}).finally(() => {
this.tableLoading = false
})
},
resetSearch() {
@@ -269,6 +277,7 @@ layout("/layouts/platform.html"){
status: "draft",
recommendFlagList: [],
sortNum: 0,
viewCount: 0,
cover: ""
}
this.dialogVisible = true
@@ -280,7 +289,8 @@ layout("/layouts/platform.html"){
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)
recommendFlagList: this.splitValue(row.recommendFlags),
viewCount: row.viewCount == null ? 0 : row.viewCount
})
this.dialogVisible = true
this.$nextTick(() => {
@@ -296,7 +306,7 @@ layout("/layouts/platform.html"){
}
},
operation() {
this.$refs.courseForm.validate(async (valid) => {
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("请选择开课时间")
@@ -310,15 +320,17 @@ layout("/layouts/platform.html"){
delete data.recommendFlagList
const method = data.id ? "/doEdit" : "/doAdd"
this.subDis = true
const resp = await this.$axios.post(loc() + method, data)
this.subDis = false
if (resp.code === 0) {
this.dialogVisible = false
this.$message.success(resp.msg)
this.pageData()
} else {
this.$message.warning(resp.msg)
}
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) {
@@ -326,25 +338,28 @@ layout("/layouts/platform.html"){
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(async () => {
const resp = await this.$axios.post(loc() + "/doDelete", { id: row.id })
}).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.$message.success(resp.msg)
this.pageData()
} else {
this.$message.error(resp.msg)
this.courseTypeOptions = resp.data
}
})
},
async getCourseTypes() {
const resp = await this.$axios.post(loc() + "/courseTypes")
if (resp.code === 0) {
this.courseTypeOptions = resp.data
}
},
async getLearningDict(name) {
const resp = await this.$axios.post(loc() + "/learningDictOptions", { name })
return resp.code === 0 ? 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) : []
@@ -383,11 +398,12 @@ layout("/layouts/platform.html"){
return time ? this.$moment(time).format("YYYY-MM-DD HH:mm:ss") : ""
}
},
async created() {
created() {
this.pageForm.courseTime = []
await this.getCourseTypes()
this.recommendOptions = await this.getLearningDict("推荐标识")
this.pageData()
Promise.all([this.getCourseTypes(), this.getLearningDict("推荐标识")]).then((values) => {
this.recommendOptions = values[1]
this.pageData()
})
}
})
</script>
@@ -44,7 +44,7 @@ layout("/layouts/platform.html"){
border
@sort-change="pageOrder">
<el-table-column :index="indexMethod" align="center" header-align="center" label="序号" type="index" width="70"></el-table-column>
<el-table-column align="center" header-align="center" label="课程名称" prop="courseName" sortable="custom" show-overflow-tooltip min-width="220" width="360"></el-table-column>
<el-table-column align="center" header-align="center" label="课程名称" prop="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="avgStudySeconds" sortable="custom" width="170">
@@ -32,6 +32,9 @@ layout("/layouts/platform.html"){
<el-card shadow="never" class="mt10">
<table-tool label="温馨提示:请先更新拨付金额后再点击核对按钮">
<template v-if="$auth.hasRoleOr(['SYSADMIN', 'SCHOOL_UNION_CLUB_ADMIN'])">
<el-button @click="exportSummary" size="mini" type="primary">
导出
</el-button>
<el-button @click="closeAudit(false)" size="mini" type="primary">
开启核对入口
</el-button>
@@ -169,6 +172,12 @@ layout("/layouts/platform.html"){
})
})
},
exportSummary() {
this.$downLoad("/platform/outlay/outlayManage/query/exportSummary", {
year: this.pageForm.year,
clubId: this.pageForm.clubId
})
},
doMoney(row) {
this.$confirm('更新前请前往【缴费管理】菜单,核对是否缴费。核对准确后,再点击更新拨付金额,确定要更新吗?', '提示', {
confirmButtonText: '更新',
@@ -37,7 +37,7 @@ layout("/layouts/platform.html"){
</el-card>
<el-card shadow="never" class="mt10">
<table-tool label="预算使用情况">
<el-button size="mini" type="primary" @click="doExport">导出</el-button>
</table-tool>
<el-table :data="tableData" style="width: 100%" row-key="id"
v-loading="tableLoading" :size="tableSize" class="vi-table">
@@ -110,6 +110,12 @@ layout("/layouts/platform.html"){
this.$refs.detailInfo.open(row)
})
},
doExport() {
this.$downLoad("/platform/outlay/outlayManage/clubUseDetail/doExport", {
year: this.pageForm.year,
clubId: this.pageForm.clubId
})
},
},
async created() {
@@ -109,10 +109,10 @@ const PROPOSAL_INFO = {
</div>
</el-dialog>
<!--审核信息采用手风琴展示,同一时间最多展开条记录,避免流程较长时页面内容一次性全部铺开。-->
<!--审核信息支持按需同时展开条记录,初次加载和切换提案时仍保持全部折叠。-->
<template v-if="doneTasks.length">
<div class="process-title">审核信息</div>
<el-collapse v-model="activeTaskId" accordion>
<el-collapse v-model="activeTaskIds">
<el-collapse-item v-for="task in doneTasks" :key="task.id" :name="task.id">
<template slot="title">
<!--折叠标题使用独立的单行布局,避免复用 process-title 后产生外边距和高度冲突。-->
@@ -121,7 +121,7 @@ const PROPOSAL_INFO = {
{{ task.displayName }}
</span>
<span style="flex-shrink: 0;margin-right: 8px;color: var(--color-primary);font-weight: 400">
{{ activeTaskId === task.id ? '收起' : '展开' }}
{{ activeTaskIds.includes(task.id) ? '收起' : '展开' }}
</span>
</div>
</template>
@@ -232,7 +232,7 @@ const PROPOSAL_INFO = {
return {
viewData: {},
doneTasks: [],
activeTaskId: null,
activeTaskIds: [],
row: null,
viewDialogVisible: false
}
@@ -244,7 +244,7 @@ const PROPOSAL_INFO = {
this.visible = true
// 切换提案时先清空上一条提案的审核记录和展开状态,避免异步加载期间展示旧数据。
this.doneTasks = []
this.activeTaskId = null
this.activeTaskIds = []
this.getInfo()
this.getDoneTasks()
},
@@ -273,7 +273,7 @@ const PROPOSAL_INFO = {
const tasks = res.data || []
this.doneTasks = tasks
// 审核记录加载完成后默认全部折叠,由用户按需展开查看。
this.activeTaskId = null
this.activeTaskIds = []
// 通知外层页面已办节点数据已加载完成,便于当前环节做表单回显。
this.$emit("done-tasks", tasks)
}
@@ -82,7 +82,7 @@ layout("/layouts/platform.html"){
</el-form-item>
</el-form>
<el-row type="flex" justify="end" v-if="row.curTaskCode ==='second'">
<el-button @click="$refs.guava.index()" size="small">返回返回</el-button>
<el-button @click="$refs.guava.index()" size="small">返回</el-button>
<el-button @click="handleTaskAction(20)" size="small" type="danger">不同意</el-button>
<el-button @click="handleTaskAction(1)" size="small" type="primary">同意</el-button>
</el-row>
@@ -5,15 +5,13 @@ layout("/layouts/platform.html"){
<style></style>
<div id="app" v-cloak>
<template>
<el-card shadow="never">
<snaker-start slot="header" label="撰写提案" define_key="JDHTA_NC">
<template slot="header-right-label">
<el-link type="primary" @click="openImport" v-if="!formData.id" style="margin-right: 15px">导入提案</el-link>
</template>
</snaker-start>
<template>
<el-form :model="formData" :rules="formRules" label-width="120px" ref="addForm">
<custom-card>
<snaker-start slot="header" label="撰写提案" define_key="JDHTA_NC">
<template slot="header-right-label">
<el-link type="primary" @click="openImport" v-if="!formData.id" style="margin-right: 15px">导入提案</el-link>
</template>
</snaker-start>
<el-form :model="formData" :rules="formRules" label-width="120px" ref="addForm">
<el-row :gutter="20" type="flex">
<el-col :span="12">
<el-form-item label="代表姓名" prop="createUserName">
@@ -162,15 +160,13 @@ layout("/layouts/platform.html"){
<!-- <el-form-item label="电子签名" prop="signature">-->
<!-- <pc-signature v-model="formData.signature"></pc-signature>-->
<!-- </el-form-item>-->
</el-form>
<el-row justify="end" type="flex" v-if="isWriteTime">
<el-button type="primary" @click="onSave">保存</el-button>
<el-button type="primary" @click="onSubmit" v-if="!taskId">提交</el-button>
<el-button type="primary" @click="onSubmitAgain" v-else>提交</el-button>
</el-row>
</template>
</el-card>
</template>
</el-form>
<template slot="footer" v-if="isWriteTime">
<el-button type="primary" @click="onSave" :loading="formLoading">保存</el-button>
<el-button type="primary" @click="onSubmit" v-if="!taskId" :loading="formLoading">提交</el-button>
<el-button type="primary" @click="onSubmitAgain" v-else :loading="formLoading">提交</el-button>
</template>
</custom-card>
<el-dialog title="提案撰写须知" :visible.sync="noticeDialogVisible" :show-close="false" width="50%">
<div v-html="proposalConfig.writeRemind" style="max-height: 50vh; overflow-y: auto"></div>
@@ -291,29 +287,36 @@ layout("/layouts/platform.html"){
}
},
methods: {
async onSave() {
const valid = await this.$refs["addForm"].validate()
if (valid) {
onSave() {
this.$refs["addForm"].validate((valid) => {
if (!valid) {
return
}
if (!this.formData.name || this.formData.name.trim().length < 1) {
this.$message.warning("请填写提案名称")
return
}
this.formLoading = true
this.$axios.post("/platform/proposal/write/save", {info: JSON.stringify(this.formData)}).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg)
this.formData = res.data
commonUtil.pjaxPush('/platform/proposal/mine')
}
}).finally(() => {
this.formLoading = false
})
}
})
},
// 提交
async onSubmit() {
const valid = await this.$refs["addForm"].validate()
if (valid) {
onSubmit() {
this.$refs["addForm"].validate((valid) => {
if (!valid) {
return
}
if (!this.formData.name || this.formData.name.trim().length < 1) {
this.$message.warning("请填写提案名称")
return
@@ -330,9 +333,8 @@ layout("/layouts/platform.html"){
cancelButtonText: "取消",
type: "warning"
}).then(() => {
const loading = createLoading('正在提交中')
this.$axios.post('/platform/proposal/write/submit', {info: JSON.stringify(this.formData)}).then(res => {
loading.close()
this.formLoading = true
this.$axios.post('/platform/proposal/write/submit', {info: JSON.stringify(this.formData)}).then((res) => {
if (res.code === 0) {
this.$message.success("提交成功")
if (this.formData.source==="PERSONAL"){
@@ -343,7 +345,6 @@ layout("/layouts/platform.html"){
}).then(() => {
commonUtil.pjaxPush('/platform/proposal/mine?bizId=' + res.data.id + "&operation=invite")
}).catch(() => {
loading.close()
this.$message.info("请到我的提案界面邀请附议人")
commonUtil.pjaxPush('/platform/proposal/mine')
})
@@ -352,15 +353,15 @@ layout("/layouts/platform.html"){
}
}
}).catch(() => {
loading.close()
}).finally(() => {
this.formLoading = false
})
})
}
})
},
// 打开导入窗口
async openImport() {
openImport() {
this.importData = {
fileList: []
}
@@ -368,7 +369,7 @@ layout("/layouts/platform.html"){
},
// 下载导入模板
async downloadTemplate() {
downloadTemplate() {
let templateType = '.xlsx'
if(this.importTemplateType === 2) {
templateType = '.docx'
@@ -377,7 +378,7 @@ layout("/layouts/platform.html"){
},
// 开始导入数据
async doImport() {
doImport() {
if (!this.importData.fileList.length) {
this.notifyWarning("请选择文件!")
return
@@ -386,7 +387,7 @@ layout("/layouts/platform.html"){
this.importData.fileList.forEach((val) => {
data.append("file", val.raw, val.raw.name);
});
this.importLoading = false
this.importLoading = true
$.ajax({
url: loc() + "/importProposal?templateType=" + this.importTemplateType,
type: "post",
@@ -403,20 +404,21 @@ layout("/layouts/platform.html"){
} else {
this.notifyWarning(data.msg)
}
this.importLoading = false
},
error: (data) => {
error: () => {
this.notifyWarning("导入失败")
this.importLoading = false
}
});
}).always(() => {
this.importLoading = false
})
},
// 重新提交
async onSubmitAgain() {
const valid = await this.$refs["addForm"].validate()
if (valid) {
onSubmitAgain() {
this.$refs["addForm"].validate((valid) => {
if (!valid) {
return
}
if (!this.formData.name || this.formData.name.trim().length < 1) {
this.$message.warning("请填写提案名称")
return
@@ -433,17 +435,20 @@ layout("/layouts/platform.html"){
cancelButtonText: "取消",
type: "warning"
}).then(() => {
this.formLoading = true
this.$axios.post('/platform/proposal/write/submitAgain', {
info: JSON.stringify(this.formData),
taskId: GetQueryString("taskId")
}).then(res => {
}).then((res) => {
if (res.code === 0) {
this.$message.success("提交成功")
commonUtil.pjaxPush('/platform/proposal/mine')
}
}).finally(() => {
this.formLoading = false
})
})
}
})
},
// 根据当前教代会届次获取允许撰写的提案类型
@@ -471,7 +476,7 @@ layout("/layouts/platform.html"){
},
//教代会change
async meetingChange(val) {
meetingChange(val) {
// 切换届次后,原提案类型不再有效,需按新届次重新选择
this.$set(this.formData, "typeId", null)
this.typeOptions = []
@@ -480,8 +485,6 @@ layout("/layouts/platform.html"){
this.formData.committeeId = null
this.listDelegation()
this.searchMineDelegation()
// this.delegationOptions = await proposal.getDelegation(val)
// this.committeeOptions = await this.getInstitutions(val)
},
@@ -505,19 +508,19 @@ layout("/layouts/platform.html"){
// 查询开启的教代会
listOpenSession(isModify = false) {
this.$axios.post("/platform/proposal/common/listOpenSession").then(async (res) => {
return this.$axios.post("/platform/proposal/common/listOpenSession").then((res) => {
if (res.code === 0) {
this.sessionOptions = res.data
let delegationPromise = Promise.resolve()
if (!isModify && this.sessionOptions) {
this.$set(this.formData, "sessionId", this.sessionOptions[0].id)
// 获取代表团
await this.searchMineDelegation()
// 检查是否在撰写时间内
this.checkWriteTime()
delegationPromise = this.searchMineDelegation().then(() => {
// 检查是否在撰写时间内
this.checkWriteTime()
})
}
await this.listDelegation()
this.listProposalType()
// await this.listSource()
return delegationPromise.then(() => this.listDelegation()).then(() => this.listProposalType())
}
})
},
@@ -532,10 +535,10 @@ layout("/layouts/platform.html"){
},
// 查询字典 撰写方式
async getDictByCode(code) {
const res = await $.get("/platform/proposal/write/listSourceByCode", {code: code})
console.log('getDictByCode', res)
return res.data;
getDictByCode(code) {
return this.$axios.post("/platform/proposal/write/listSourceByCode", {code: code}).then((res) => {
return res.data
})
},
//查询自己有权限的代表团
@@ -561,7 +564,7 @@ layout("/layouts/platform.html"){
})
},
async init() {
init() {
if (this.bizId) {
this.$axios.post("/platform/proposal/write/detail", {id: this.bizId}).then((res) => {
if (res.code === 0) {
@@ -582,8 +585,10 @@ layout("/layouts/platform.html"){
}
// 撰写方式
this.allSourceOptions = await this.getDictByCode("PROPOSAL_SOURCE")
this.sourceOptions = this.allSourceOptions
this.getDictByCode("PROPOSAL_SOURCE").then((data) => {
this.allSourceOptions = data
this.sourceOptions = data
})
}
},
created() {
@@ -23,13 +23,13 @@ layout("/layouts/platform.html"){
<el-table-column prop="userName" label="姓名"></el-table-column>
<el-table-column prop="unionName" label="所属工会"></el-table-column>
<el-table-column prop="unitName" label="所属单位"></el-table-column>
<el-table-column prop="taskName" label="当前节点"></el-table-column>
<!--<el-table-column prop="taskName" label="当前节点"></el-table-column>
<el-table-column prop="instanceState" label="流程状态">
<template slot-scope="{row}">
<enum-tag :value="row.instanceState" name="ProcessInstanceStateEnum" label_key="message"
size="small"></enum-tag>
</template>
</el-table-column>
</el-table-column>-->
<el-table-column label="操作" fixed="right" width="300px">
<template slot-scope="{row}">
<el-button @click="openView(row)" size="mini" type="primary">查看</el-button>
@@ -108,6 +108,11 @@ layout("/layouts/platform.html"){
data() {
return {
pageForm: {
pageNumber: 1,
pageSize: 10,
totalCount: 0,
pageOrderName: "",
pageOrderBy: "",
approval: false
},
unionOptions: [],
@@ -21,9 +21,10 @@ const INFO = {
<el-descriptions-item label="学位">{{ viewData.academicDegree }}</el-descriptions-item>
<el-descriptions-item label="党政职务">{{ viewData.position }}</el-descriptions-item>
<el-descriptions-item label="工作单位">{{ viewData.unitname }}</el-descriptions-item>
<el-descriptions-item label="所属工会">{{ viewData.unionname }}</el-descriptions-item>
<el-descriptions-item label="所属校区">{{ viewData.campus }}</el-descriptions-item>
<el-descriptions-item label="工作单位">{{ viewData.unitName }}</el-descriptions-item>
<el-descriptions-item label="所属工会">{{ viewData.unionName }}</el-descriptions-item>
<el-descriptions-item v-if="showThreeUnit" label="三级单位">{{ viewData.threeUnitName || '暂无' }}</el-descriptions-item>
<el-descriptions-item v-else label="所属校区">{{ viewData.campus }}</el-descriptions-item>
<el-descriptions-item label="身份证号码">{{ viewData.idCard }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ viewData.mobile }}</el-descriptions-item>
@@ -44,11 +45,7 @@ const INFO = {
<el-descriptions-item label="会员状态">{{ viewData.member ? '会员' : '非会员' }}
</el-descriptions-item>
<el-descriptions-item label="福利会员状态">{{ viewData.welfareMember ? '福利会员' : '非福利会员'
}}
</el-descriptions-item>
<el-descriptions-item></el-descriptions-item>
<!-- <template v-if="viewData.loginname == $store.state.user.loginname">-->
<template>
<el-descriptions-item label="家庭主要成员" :span="3">
@@ -134,6 +131,9 @@ const INFO = {
`,
store,
mixins: [initTableMixins],
props: {
showThreeUnit: { type: Boolean, default: false }
},
dicts: ["PROCESS_TASK_SUBMIT_TYPE"],
data(){
return {
@@ -101,12 +101,21 @@ const MEMBER_CHANGE = {
</el-form-item>
</el-descriptions-item>
</template>
<el-descriptions-item label="所属校区">
<el-form-item prop="campus">
<dict-select style="width: 100%" placeholder="请选择所属校区" v-model="formData.campus"
:disabled="allowFields('campus')" code="USER_CAMPUS"></dict-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item v-if="showThreeUnit" label="三级单位">
<el-form-item prop="threeUnitId">
<el-select v-model="formData.threeUnitId" clearable filterable
placeholder="请选择三级单位" style="width: 100%">
<el-option v-for="item in threeUnits" :key="item.id" :label="item.name"
:value="item.id"></el-option>
</el-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item v-else label="所属校区">
<el-form-item prop="campus">
<dict-select style="width: 100%" placeholder="请选择所属校区" v-model="formData.campus"
:disabled="allowFields('campus')" code="USER_CAMPUS"></dict-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="身份证号码">
<el-form-item prop="idCard">
@@ -278,6 +287,7 @@ const MEMBER_CHANGE = {
`,
props: {
id: { type: String, default: '' },
showThreeUnit: { type: Boolean, default: false },
},
mixins: [initTableMixins],
store,
@@ -287,6 +297,7 @@ const MEMBER_CHANGE = {
user: {},
units: [],
unions: [],
threeUnits: [],
// 允许变更的字段
allowChangeFields: [],
formData: {
@@ -381,6 +392,31 @@ const MEMBER_CHANGE = {
allowFields(prop) {
return !this.allowChangeFields.map(v => v.code).includes(prop)
},
// 管理页仅查询当前被编辑人员所属二级单位的直属三级单位,避免越级选择其他单位数据。
initThreeUnits() {
this.threeUnits = []
if (!this.showThreeUnit) {
return Promise.resolve()
}
if (!this.formData.unitId) {
this.$message.warning("当前人员未配置所属单位,无法查询三级单位")
return Promise.resolve()
}
return this.$axios.post("/platform/sys/unit/child", {pid: this.formData.unitId})
.then((resp) => {
if (resp.code === 0) {
this.threeUnits = resp.data || []
if (this.threeUnits.length === 0) {
this.$message.warning("当前所属单位未配置三级单位")
}
} else {
this.$message.error(resp.msg || "三级单位查询失败")
}
})
.catch(() => {
this.$message.error("三级单位查询失败,请稍后重试")
})
},
doSave(){
this.$confirm("保存后可在我的申请里面再次编辑,您确定要保存吗?", "提示", { type: "warning" })
.then(() => {
@@ -424,6 +460,7 @@ const MEMBER_CHANGE = {
this.units = await this.$businessTool.listUnit()
}
this.normalizeUnitField()
this.initThreeUnits()
},
async getUserById(userId){
const resp = await $.post("/platform/member/change/apply/getUserByIdForMemberChange", { userId })
@@ -461,6 +498,7 @@ const MEMBER_CHANGE = {
unit,
union,
campus,
threeUnitId,
userState,
personType,
preparedBy,
@@ -488,6 +526,7 @@ const MEMBER_CHANGE = {
this.$set(this.formData, "education", education)
this.$set(this.formData, "academicDegree", academicDegree)
this.$set(this.formData, "campus", campus)
this.$set(this.formData, "threeUnitId", threeUnitId)
this.$set(this.formData, "userState", userState)
this.$set(this.formData, "personType", personType)
this.$set(this.formData, "preparedBy", preparedBy)
@@ -16,11 +16,18 @@ layout("/layouts/platform.html"){
</el-select>
</search-item>
<search-item label="所属单位">
<el-select clearable filterable placeholder="请选择所属单位" style="width: 100%"
v-model="pageForm.unitId">
<el-option :key="item.id" :label="item.name" :value="item.id"
v-for="item in units"></el-option>
</el-select>
<el-select @change="flushThreeUnits" @clear="flushThreeUnits" clearable filterable
placeholder="请选择所属单位" style="width: 100%" v-model="pageForm.unitId">
<el-option :key="item.id" :label="item.name" :value="item.id"
v-for="item in units"></el-option>
</el-select>
</search-item>
<search-item label="三级单位">
<el-select clearable filterable placeholder="请选择三级单位" style="width: 100%"
v-model="pageForm.threeUnitId">
<el-option :key="item.id" :label="item.name" :value="item.id"
v-for="item in threeUnits"></el-option>
</el-select>
</search-item>
<search-item label="性别">
<dict-select v-model="pageForm.sex" placeholder="性别" @change="doSearch"
@@ -224,7 +231,7 @@ layout("/layouts/platform.html"){
</template>
<template #public>
<member-change ref="memberChangeRef" @do-back="$refs.guava.index()" @do-submit="doSubmit"></member-change>
<member-change ref="memberChangeRef" :show-three-unit="true" @do-back="$refs.guava.index()" @do-submit="doSubmit"></member-change>
</template>
<template #edit>
@@ -509,6 +516,7 @@ layout("/layouts/platform.html"){
pageForm: {
unionId: "",
unitId: "",
threeUnitId: "",
sex: "",
searchKeyword: "",
changed: 2,
@@ -519,6 +527,7 @@ layout("/layouts/platform.html"){
},
unions: [],
units: [],
threeUnits: [],
id: "",
checkUsers: [],
batchEditLoading: false,
@@ -675,14 +684,35 @@ layout("/layouts/platform.html"){
this.changeTypeData = await this.$businessTool.getEnumOptions("MemberChangeType")
this.allowChangeFields = await this.$businessTool.getDictOptions("ALLOW_CHANGE_FIELDS")
},
async flushUnits() {
flushUnits() {
this.$set(this.pageForm, "unitId", null)
this.$set(this.pageForm, "threeUnitId", null)
this.units = []
this.threeUnits = []
if (this.pageForm.unionId) {
this.$businessTool.listUnit(this.pageForm.unionId).then(data => {
this.units = data
})
}
},
// 所属单位变化时只加载其直属三级单位,避免跨单位筛选。
flushThreeUnits(unitId) {
this.$set(this.pageForm, "threeUnitId", null)
this.threeUnits = []
if (!unitId) {
return
}
this.$axios.post("/platform/sys/unit/child", {pid: unitId})
.then((res) => {
if (res.code === 0) {
this.threeUnits = res.data || []
} else {
this.$message.error(res.msg || "三级单位查询失败")
}
})
.catch(() => {
this.$message.error("三级单位查询失败,请稍后重试")
})
},
checkAll() {
this.checkedFields = this.tableColumns.map(c => c.prop);
@@ -50,7 +50,7 @@ layout("/layouts/platform.html"){
</el-card>
<template #view>
<info ref="infoRef"></info>
<info ref="infoRef" :show-three-unit="true"></info>
</template>
</guava>
</div>
@@ -216,7 +216,7 @@ layout("/layouts/platform.html"){
personTypeOptions: [],
popoverConfigs: [
{text: '设置会员', func: this.onMember, visible: false},
{text: '设置福利会员', func: this.onWelfare, visible: false},
// {text: '设置福利会员', func: this.onWelfare, visible: false},
],
}
},
@@ -138,8 +138,8 @@ layout("/layouts/platform.html"){
show-overflow-tooltip
align="center"
header-align="center"
v-for="column in tableColumns"
v-if="checkedFields.includes(column.prop)"
v-for="column in showColumns"
:key="column.prop"
:label="column.label"
:fixed="column.fixed"
:prop="column.prop"
@@ -482,6 +482,14 @@ layout("/layouts/platform.html"){
watch: {
filterText(val) {
this.$refs.treeRef.filter(val)
},
checkedFields() {
// 列设置变更后,重新计算固定列与普通列的宽度,避免表头、表体出现错位。
this.$nextTick(() => {
if (this.$refs.memberTableRef) {
this.$refs.memberTableRef.doLayout()
}
})
}
},
components: {
@@ -62,11 +62,9 @@ const filterUser = {
</search-item>
<search-item label="所属单位">
<el-select clearable filterable
multiple
collapse-tags
<el-select @change="unitChange" clearable filterable
placeholder="请选择所属单位" style="width: 100%"
v-model="pageForm.unitIds">
v-model="pageForm.unitId">
<el-option
:key="item.id"
:label="item.name"
@@ -76,6 +74,18 @@ const filterUser = {
</el-select>
</search-item>
<search-item label="三级单位">
<el-select clearable filterable placeholder="请先选择所属单位" style="width: 100%"
v-model="pageForm.threeUnitId">
<el-option
:key="item.id"
:label="item.name"
:value="item.id"
v-for="item in threeUnitOptions">
</el-option>
</el-select>
</search-item>
<search-item label="人员类型:">
<dict-select clearable code="USER_PERSON_TYPE"
multiple
@@ -160,11 +170,13 @@ const filterUser = {
personTypes: [],
preparedBys: [],
userAttributes: [],
unitIds: []
unitId: null,
threeUnitId: null
},
sexOptions: ["男", "女"],
unionOptions: [],
unitOptions: [],
threeUnitOptions: [],
tableColumns: [
{ prop: "loginname", label: "工号" },
{ prop: "username", label: "姓名" },
@@ -195,7 +207,9 @@ const filterUser = {
this.$set(this.pageForm, "birthday", null)
this.$set(this.pageForm, "birthMonths", [])
this.$set(this.pageForm, "unionId", null)
this.$set(this.pageForm, "unitIds", [])
this.$set(this.pageForm, "unitId", null)
this.$set(this.pageForm, "threeUnitId", null)
this.threeUnitOptions = []
this.$set(this.pageForm, "personTypes", [])
this.$set(this.pageForm, "preparedBys", [])
this.$set(this.pageForm, "userAttributes", [])
@@ -203,6 +217,26 @@ const filterUser = {
this.$set(this.pageForm, "isMember", null)
},
// 所属单位改变后,仅加载该单位直属的三级单位,避免跨单位筛选。
unitChange(unitId) {
this.$set(this.pageForm, "threeUnitId", null)
this.threeUnitOptions = []
if (!unitId) {
return
}
this.$axios.post("/platform/sys/unit/child", { pid: unitId })
.then((res) => {
if (res.code === 0) {
this.threeUnitOptions = res.data || []
} else {
this.$message.error(res.msg || "三级单位查询失败")
}
})
.catch(() => {
this.$message.error("三级单位查询失败,请稍后重试")
})
},
pageData() {
this.tableLoading = true
this.$axios
@@ -13,20 +13,22 @@ const welfareOption = {
</el-table-column>
<el-table-column align="center" header-align="center" label="系统默认选择" width="140">
<template slot-scope="{$index}">
<template slot-scope="{row}">
<el-radio
v-model="systemDefaultPlaceholderIndex"
:label="$index">
v-model="systemDefaultOption"
:label="row"
@change="setSystemDefault(row)">
&nbsp;
</el-radio>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="图片" width="200">
<el-table-column align="center" header-align="center" label="图片" width="100">
<template slot-scope="{row}">
<file-upload :upload_number="1" :value.sync="row.imgUrl"
accept=".jpg,.jpeg,.png"
class="imgUrl"
style="--upload-width: 80px; --upload-height: 80px;"
upload_result_type="url"
complete_result upload_mode="image"
upload_result_category="interval"></file-upload>
@@ -83,7 +85,7 @@ const welfareOption = {
data() {
return {
welfareList: [],
systemDefaultPlaceholderIndex: null,
systemDefaultOption: null,
descriptionDialog: {
visible: false,
content: '',
@@ -94,6 +96,10 @@ const welfareOption = {
created() {
this.welfareList = this.value ? JSON.parse(JSON.stringify(this.value)) : [];
this.systemDefaultOption = this.welfareList.find((item) => item.isSystemDefault) || null;
if (this.systemDefaultOption) {
this.setSystemDefault(this.systemDefaultOption);
}
},
watch: {
@@ -112,7 +118,15 @@ const welfareOption = {
optionName: '福利' + newSort,
optionSort: newSort,
imgUrl: '',
description: ''
description: '',
isSystemDefault: false
});
},
// 每个福利项目只允许配置一个系统默认选项,保存时会随福利选项一并提交。
setSystemDefault(defaultOption) {
this.welfareList.forEach((item) => {
this.$set(item, 'isSystemDefault', item === defaultOption);
});
},
@@ -120,11 +134,10 @@ const welfareOption = {
this.$confirm('确认删除该福利选项?', '提示', {
type: 'warning'
}).then(() => {
const deletedOption = this.welfareList[index];
this.welfareList.splice(index, 1);
if (this.systemDefaultPlaceholderIndex === index) {
this.systemDefaultPlaceholderIndex = null;
} else if (this.systemDefaultPlaceholderIndex > index) {
this.systemDefaultPlaceholderIndex--;
if (this.systemDefaultOption === deletedOption) {
this.systemDefaultOption = null;
}
this.updateSortNumbers();
});
@@ -180,16 +193,6 @@ const welfareOption = {
padding: 0;
}
/deep/ .welfare-option .el-upload-list__item{
width: 100px;
height: 100px;
}
/deep/ .welfare-option .imgUrl .el-upload--picture-card {
width: 100px;
height: 100px;
}
/deep/ .welfare-option .imgUrl .el-upload--picture-card i {
position: absolute;
top: 50%;
@@ -90,7 +90,8 @@ const optionSelect = {
<!-- 单选模式使用单选按钮 -->
<div class="option-radio" v-if="projectInfo.isCheckBox === 'radio'">
<el-radio v-model="selectedRadioId" :label="option.id">{{ null }}</el-radio>
<el-radio v-model="selectedRadioId" :label="option.id"
:disabled="systemDefaultOption && systemDefaultOption.id !== option.id">{{ null }}</el-radio>
</div>
<!-- 多选模式使用步进器 -->
@@ -98,7 +99,7 @@ const optionSelect = {
<el-input-number
:key="'input-number-'+index+'-'+option.selectNumKey|| 0"
v-model="option.selectNum"
:min="0"
:min="option.isSystemDefault ? 1 : 0"
:max="projectInfo.multiSelectNum || 99"
size="small"
@change="selectNumChange(index,option.selectNum)"
@@ -288,6 +289,12 @@ const optionSelect = {
"address-dialog": ADDRESS_DIALOG
},
computed: {
// 系统默认福利必须被保留,管理端限制每个项目最多配置一个。
systemDefaultOption() {
if (!this.projectInfo.options) return null
return this.projectInfo.options.find((option) => option.isSystemDefault) || null
},
// 是否有选择
hasSelection() {
if (this.projectInfo.isCheckBox === "radio") {
@@ -486,6 +493,11 @@ const optionSelect = {
}
}
// 首次进入且没有历史选择时,自动带出系统默认福利。
if (this.projectInfo.options && this.userSelection.length === 0) {
this.applySystemDefaultOption()
}
// 设置已选择的选项
if (this.projectInfo.options && this.userSelection.length > 0) {
// 单选模式
@@ -608,6 +620,11 @@ const optionSelect = {
// 单选选项选择
selectRadioOption(optionId) {
if (this.systemDefaultOption && this.systemDefaultOption.id !== optionId) {
this.selectedRadioId = this.systemDefaultOption.id
this.$message.warning("系统默认福利不可取消")
return
}
this.selectedRadioId = optionId
// 重置所有选项的selectNum
@@ -660,6 +677,11 @@ const optionSelect = {
},
selectNumChange(index, newValue) {
const option = this.projectInfo.options[index];
if (option.isSystemDefault && Number(newValue) < 1) {
this.$set(option, "selectNum", 1)
this.$message.warning("系统默认福利不可取消")
return
}
const maxSelect = this.projectInfo.multiSelectNum || this.projectInfo.options.length;
// 计算其他所有选项的总和(不包括当前修改的选项)
@@ -690,6 +712,15 @@ const optionSelect = {
// 无论是否需要调整值,都需要触发 key 更新以确保 el-input-number 重新渲染
this.$set(option, 'selectNumKey', (option.selectNumKey || 0) + 1);
}
},
// 默认项只在首次选择时自动带入,已有历史选择不自动覆盖。
applySystemDefaultOption() {
if (!this.systemDefaultOption) return
if (this.projectInfo.isCheckBox === "radio") {
this.selectRadioOption(this.systemDefaultOption.id)
} else {
this.$set(this.systemDefaultOption, "selectNum", 1)
}
}
},
watch: {
@@ -20,7 +20,7 @@ layout("/layouts/platform_h5.html"){
<template v-slot="{index,row}">
<table-column label="申请人">{{row.userName}}</table-column>
<table-column label="申请时间">{{row.submitTime}}</table-column>
<table-column label="当前节点">{{row.taskName}}</table-column>
<!-- <table-column label="当前节点">{{row.taskName}}</table-column>-->
</template>
<template #actions="{index,row}">
<div class="action-btn" @click="onView(row)">
@@ -69,10 +69,11 @@ layout("/layouts/platform_h5.html"){
}
.learning-filter-card {
flex-shrink: 0;
margin: 14px 14px 0;
padding: 10px;
border-radius: 8px;
margin: 10px 10px 0;
padding: 4px 10px 8px;
border-radius: 10px;
background: #ffffff;
box-shadow: 0 3px 12px rgba(31, 72, 122, .08);
}
.learning-list-scroll {
min-height: 0;
@@ -82,80 +83,81 @@ layout("/layouts/platform_h5.html"){
box-sizing: border-box;
}
.learning-filter-group {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
min-height: 72px;
overflow: hidden;
border-radius: 6px;
background: #eef6ff;
border-bottom: 1px solid #edf1f7;
}
.learning-filter-group + .learning-filter-group {
margin-top: 8px;
margin-top: 0;
}
.learning-filter-group:last-child {
border-bottom: 0;
}
.learning-filter-category {
display: flex;
align-items: center;
padding: 12px;
color: #ffffff;
justify-content: space-between;
min-height: 40px;
padding: 0 2px;
color: #26354c;
box-sizing: border-box;
}
.learning-filter-category-text {
min-width: 0;
.learning-filter-category-main,
.learning-filter-category-actions {
display: flex;
align-items: center;
}
.learning-filter-category-title {
font-size: 15px;
margin-left: 6px;
font-size: 14px;
font-weight: 800;
line-height: 1.2;
}
.learning-filter-category-main .van-icon {
color: #1677ff;
font-size: 17px;
}
.learning-filter-selected-count {
color: #97a4b7;
font-size: 11px;
}
.learning-filter-reset,
.learning-filter-collapse {
margin-left: 12px;
color: #4d92ec;
font-size: 11px;
}
.learning-filter-collapse .van-icon {
margin-left: 2px;
font-size: 11px;
vertical-align: -1px;
}
.learning-filter-options {
display: flex;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
background: rgba(255, 255, 255, .55);
}
.learning-filter-options::-webkit-scrollbar {
display: none;
flex-wrap: wrap;
gap: 7px 9px;
padding: 0 1px 10px;
}
.learning-filter-item {
flex: 0 0 72px;
min-width: 72px;
padding: 8px 2px 7px;
border-left: 1px solid rgba(255, 255, 255, .72);
flex: 1 0 calc((100% - 18px) / 3);
min-width: 0;
height: 22px;
padding: 0 7px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #dce5f1;
border-radius: 12px;
text-align: center;
color: #5f6b7a;
color: #718096;
font-size: 11px;
box-sizing: border-box;
}
.learning-filter-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin: 0 auto 5px;
border-radius: 50%;
color: #4f87c8;
font-size: 18px;
background: rgba(255, 255, 255, .78);
}
.learning-filter-item.no-icon {
display: flex;
align-items: center;
justify-content: center;
min-height: 58px;
color: #0b75bd;
font-weight: 700;
}
.learning-filter-item.active {
color: #0b75bd;
font-weight: 700;
background: rgba(255, 255, 255, .86);
}
.learning-filter-item.active .learning-filter-icon {
color: #ffffff;
background: #0b75bd;
font-weight: 700;
background: #287ff0;
border-color: #287ff0;
box-shadow: 0 2px 5px rgba(40, 127, 240, .2);
}
.learning-filter-name {
overflow: hidden;
@@ -275,28 +277,9 @@ layout("/layouts/platform_h5.html"){
padding: 22px 0 36px;
}
@media (max-width: 360px) {
.learning-filter-card {
margin-left: 10px;
margin-right: 10px;
padding: 8px;
}
.learning-filter-group {
grid-template-columns: 94px minmax(0, 1fr);
}
.learning-filter-category {
padding: 10px 8px;
}
.learning-filter-category-title {
font-size: 13px;
}
.learning-filter-icon {
width: 26px;
height: 26px;
font-size: 17px;
}
.learning-filter-item {
font-size: 11px;
}
.learning-filter-card { margin-left: 8px; margin-right: 8px; padding-left: 8px; padding-right: 8px; }
.learning-filter-reset, .learning-filter-collapse { margin-left: 8px; }
.learning-filter-item { padding-left: 4px; padding-right: 4px; }
.learning-course-cover {
flex-basis: 120px;
width: 120px;
@@ -328,21 +311,24 @@ layout("/layouts/platform_h5.html"){
<div class="learning-filter-card">
<div class="learning-filter-group" v-for="group in filterGroups" :key="group.key">
<div class="learning-filter-category" :style="{background: group.color}">
<div class="learning-filter-category-text">
<div class="learning-filter-category">
<div class="learning-filter-category-main">
<van-icon :name="group.icon"></van-icon>
<div class="learning-filter-category-title">{{ group.name }}</div>
</div>
<div class="learning-filter-category-actions">
<span class="learning-filter-selected-count">已选{{ selectedFilterCount(group) }}项</span>
<span class="learning-filter-reset" @click.stop="resetGroupFilter(group)">重置</span>
<span class="learning-filter-collapse" @click.stop="toggleFilterGroup(group.key)">{{ filterCollapsed[group.key] ? '展开' : '收起' }}<van-icon :name="filterCollapsed[group.key] ? 'arrow-down' : 'arrow-up'"></van-icon></span>
</div>
</div>
<div class="learning-filter-options">
<div v-show="!filterCollapsed[group.key]" class="learning-filter-options">
<div
v-for="item in group.items"
:key="item.key"
class="learning-filter-item"
:class="{active: isFilterActive(item), 'no-icon': !item.icon}"
:class="{active: isFilterActive(item)}"
@click="selectFilter(item)">
<div v-if="item.icon" class="learning-filter-icon">
<van-icon :name="item.icon"></van-icon>
</div>
<div class="learning-filter-name">{{ item.name }}</div>
</div>
</div>
@@ -351,7 +337,7 @@ layout("/layouts/platform_h5.html"){
<div class="learning-list-scroll">
<div class="learning-section-head">
<div class="learning-section-title">{{ listTitle }}</div>
<div class="learning-section-title">我的课堂</div>
<div class="learning-section-total">共 {{ pageForm.totalCount || 0 }} 门</div>
</div>
@@ -416,36 +402,35 @@ layout("/layouts/platform_h5.html"){
loading: false,
finished: false,
refreshing: false,
requestSeq: 0
requestSeq: 0,
filterCollapsed: { recommend: false, type: false }
}
},
computed: {
filterGroups() {
const recommendItems = this.recommendOptions.map((item, index) => ({
const recommendItems = [{ key: "recommend_all", type: "recommend", value: "", name: "全部" }].concat(this.recommendOptions.map((item) => ({
key: "recommend_" + item.code,
type: "recommend",
value: item.code,
name: item.name,
icon: this.recommendIcon(index)
}))
const typeItems = this.courseTypeOptions.map((item, index) => ({
name: item.name
})))
const typeItems = [{ key: "type_all", type: "type", value: "", name: "全部" }].concat(this.courseTypeOptions.map((item) => ({
key: "type_" + item.id,
type: "type",
value: item.id,
name: item.typeName,
icon: this.typeIcon(index)
}))
name: item.typeName
})))
return [
{
key: "recommend",
name: "推荐标识",
color: "linear-gradient(135deg, #ff8a3d, #ffbf5e)",
icon: "label-o",
items: recommendItems
},
{
key: "type",
name: "课程类型",
color: "linear-gradient(135deg, #2878d7, #64a8f4)",
icon: "apps-o",
items: typeItems
}
]
@@ -538,6 +523,26 @@ layout("/layouts/platform_h5.html"){
}
this.doSearch()
},
// 切换指定筛选组的展开状态,不改变该组已选择的查询条件。
toggleFilterGroup(groupKey) {
this.$set(this.filterCollapsed, groupKey, !this.filterCollapsed[groupKey])
},
// 重置指定筛选组并重新查询;推荐标识和课程类型可独立恢复为全部。
resetGroupFilter(group) {
if (!group) return
if (group.key === "recommend") {
this.query.recommendFlag = ""
} else if (group.key === "type") {
this.query.courseTypeId = ""
}
this.doSearch()
},
selectedFilterCount(group) {
if (!group) return 0
if (group.key === "recommend") return this.query.recommendFlag ? 1 : 0
if (group.key === "type") return this.query.courseTypeId ? 1 : 0
return 0
},
isFilterActive(item) {
if (!item) return false
if (item.type === "recommend") {
@@ -364,25 +364,33 @@ layout("/layouts/platform_h5.html"){
v-if="selectedResource.resourceType === 'video'"
ref="mediaPlayer"
class="study-media"
:src="fileUrl"
:src="mediaUrl"
controls
playsinline
webkit-playsinline
x5-video-player-type="h5"
x5-video-orientation="landscape|portrait"
x-webkit-airplay="allow"
@loadedmetadata="restoreMediaPosition"
@seeked="handleMediaSeeked"
@seeking="handleMediaSeeking"
@play="startStudy"
@pause="saveMediaPosition(false)"
@timeupdate="saveProgress"
@ended="finishStudy(false)">
@ended="finishStudy(false, true)">
</video>
<div v-else-if="selectedResource.resourceType === 'audio'" class="study-audio-wrap">
<audio
ref="mediaPlayer"
:src="fileUrl"
controls
@loadedmetadata="restoreMediaPosition"
@seeked="handleMediaSeeked"
@seeking="handleMediaSeeking"
@play="startStudy"
@pause="saveMediaPosition(false)"
@timeupdate="saveProgress"
@ended="finishStudy(false)">
@ended="finishStudy(false, true)">
</audio>
</div>
<img v-else-if="selectedResource.resourceType === 'image'" class="study-image-preview" :src="fileUrl" :alt="selectedResource.title">
@@ -412,6 +420,14 @@ layout("/layouts/platform_h5.html"){
studying: false,
pendingSeconds: 0,
heartbeatTimer: null,
resumePositionSeconds: 0,
resumeSeekRetryCount: 0,
resumePositionRestored: false,
lastSavedPositionSeconds: 0,
lastPositionSyncAt: 0,
lastAllowedPositionSeconds: 0,
restoringMediaPosition: false,
pageVisibilityHandler: null,
navigatingBack: false
}
},
@@ -422,6 +438,12 @@ layout("/layouts/platform_h5.html"){
fileUrl() {
return this.getFileUrl(this.selectedResource.fileData)
},
mediaUrl() {
if (this.selectedResource.resourceType !== "video") return this.fileUrl
const fileId = this.getFileId(this.selectedResource.fileData)
// 视频必须使用支持 Range 的播放接口,否则手动拖动和历史位置恢复都会失效。
return fileId ? "/platform/sys/file/videoPlay?id=" + encodeURIComponent(fileId) : this.fileUrl
},
inlinePreviewUrl() {
const id = this.getFileId(this.selectedResource.fileData)
return id ? this.apiBase + "/pdfPreview?id=" + encodeURIComponent(id) : this.fileUrl
@@ -502,31 +524,99 @@ layout("/layouts/platform_h5.html"){
this.openResource(chapter.resources[0])
}
},
progressKey(resource) {
const target = resource || this.selectedResource
return "learning-progress-" + this.courseId + "-" + (target.id || "")
},
getResumePosition(resource) {
// 仅以当前登录人的服务端学习记录续播,避免共用设备中的本地缓存导致首次学习被错误快进。
const serverPosition = Number(resource.lastPositionSeconds || 0)
return Math.max(0, serverPosition)
},
openResource(resource) {
if (!resource || !resource.id) return
this.pauseMedia()
this.finishStudy(false)
this.selectedResource = resource
this.resumePositionSeconds = this.getResumePosition(resource)
this.resumeSeekRetryCount = 0
this.resumePositionRestored = this.resumePositionSeconds <= 0
this.lastSavedPositionSeconds = 0
this.lastPositionSyncAt = 0
this.lastAllowedPositionSeconds = Number(resource.maxPositionSeconds || 0)
this.restoringMediaPosition = false
this.playerVisible = true
if (!["video", "audio"].includes(resource.resourceType)) {
this.$nextTick(() => this.startStudy())
} else {
this.$nextTick(() => this.resumeMediaPosition())
this.$nextTick(() => this.restoreMediaPosition())
}
},
resumeMediaPosition() {
restoreMediaPosition() {
const player = this.$refs.mediaPlayer
const position = Number(this.selectedResource.lastPositionSeconds || 0)
if (player && position > 0) {
player.currentTime = position
if (!player || !this.selectedResource.id || this.resumePositionRestored || this.resumePositionSeconds <= 0 || player.readyState < 1) return
let targetPosition = this.resumePositionSeconds
if (Number.isFinite(player.duration) && player.duration > 0) {
// 已完成或接近结束的记录回退两秒,防止恢复后立即触发播放结束。
targetPosition = Math.min(this.resumePositionSeconds, Math.max(0, Math.floor(player.duration) - 2))
}
if (targetPosition <= 0) {
this.lastAllowedPositionSeconds = 0
this.restoringMediaPosition = false
this.resumePositionRestored = true
return
}
this.resumePositionSeconds = targetPosition
this.restoringMediaPosition = true
player.currentTime = targetPosition
},
verifyMediaPosition() {
const player = this.$refs.mediaPlayer
if (!player || this.resumePositionRestored || this.resumePositionSeconds <= 0) return
const actualPosition = Number(player.currentTime || 0)
if (Math.abs(actualPosition - this.resumePositionSeconds) <= 1) {
this.lastAllowedPositionSeconds = actualPosition
this.restoringMediaPosition = false
this.resumePositionRestored = true
return
}
if (this.resumeSeekRetryCount < 1) {
this.resumeSeekRetryCount += 1
// 部分移动端容器首次定位会被播放器重置,收到 seeked 后再校验并重试一次。
setTimeout(() => this.restoreMediaPosition(), 100)
} else {
this.lastAllowedPositionSeconds = actualPosition
this.restoringMediaPosition = false
this.resumePositionRestored = true
}
},
// 原生播放器点击进度条会在 seeking 后再次写入时间,因此开始定位和完成定位均需限制向前跳转;回退复习不受影响。
restoreBlockedMediaPosition() {
const player = this.$refs.mediaPlayer
if (!player || this.selectedResource.allowDrag !== false || this.restoringMediaPosition) return false
const allowedPosition = Math.max(0, Number(this.lastAllowedPositionSeconds || 0))
if (Number(player.currentTime || 0) <= allowedPosition + 0.25) return false
player.currentTime = allowedPosition
setTimeout(() => {
if (!this.restoringMediaPosition && this.selectedResource.allowDrag === false && Number(player.currentTime || 0) > allowedPosition + 0.25) {
player.currentTime = allowedPosition
}
}, 0)
return true
},
handleMediaSeeking() {
this.restoreBlockedMediaPosition()
},
handleMediaSeeked() {
this.restoreBlockedMediaPosition()
this.verifyMediaPosition()
},
async startStudy() {
if (!this.selectedResource.id || this.studying) return
const resp = await this.$axios.post(this.recordApi + "/start", {
courseId: this.courseId,
resourceId: this.selectedResource.id,
positionSeconds: this.getMediaPosition()
positionSeconds: this.resumePositionRestored ? this.getMediaPosition() : this.resumePositionSeconds
})
if (resp.code !== 0) {
this.$toast(resp.msg || "开始学习失败")
@@ -551,7 +641,8 @@ layout("/layouts/platform_h5.html"){
this.applyRecordState(resp.data)
}
},
async finishStudy(force) {
// force 用于页面销毁时静默结束;completed 仅在视频或音频自然播放结束时传 true。
async finishStudy(force, completed) {
this.pauseMedia()
if (!this.currentSegmentId) return
const segmentId = this.currentSegmentId
@@ -563,7 +654,8 @@ layout("/layouts/platform_h5.html"){
await this.$axios.post(this.recordApi + "/finish", {
segmentId: segmentId,
activeSeconds: seconds,
positionSeconds: this.getMediaPosition()
positionSeconds: this.getMediaPosition(),
completed: !!completed
})
if (force) return
this.loadTree()
@@ -591,7 +683,55 @@ layout("/layouts/platform_h5.html"){
saveProgress() {
const position = this.getMediaPosition()
if (!position || !this.selectedResource.id) return
if (this.selectedResource.allowDrag === false && !this.restoringMediaPosition && position > Number(this.lastAllowedPositionSeconds || 0) + 2) {
this.restoreBlockedMediaPosition()
return
}
this.lastAllowedPositionSeconds = Math.max(Number(this.lastAllowedPositionSeconds || 0), position)
this.$set(this.selectedResource, "lastPositionSeconds", position)
try {
localStorage.setItem(this.progressKey(), String(position))
} catch (e) {
// 无痕模式或受限容器可能禁用本地存储,继续使用服务端记录。
}
if (Date.now() - this.lastPositionSyncAt >= 5000) {
this.saveServerPosition(position, false)
}
},
saveMediaPosition(useBeacon) {
const position = this.getMediaPosition()
if (!this.selectedResource.id || position <= 0) return
try {
localStorage.setItem(this.progressKey(), String(position))
} catch (e) {
// 无痕模式或受限容器可能禁用本地存储,继续使用服务端记录。
}
this.saveServerPosition(position, useBeacon)
},
saveServerPosition(position, useBeacon) {
if (!this.selectedResource.id || !["video", "audio"].includes(this.selectedResource.resourceType)) return
const seconds = Math.max(0, Math.floor(Number(position || 0)))
this.lastPositionSyncAt = Date.now()
this.lastSavedPositionSeconds = seconds
if (useBeacon && navigator.sendBeacon) {
const formData = new URLSearchParams()
formData.append("courseId", this.courseId)
formData.append("resourceId", this.selectedResource.id)
formData.append("positionSeconds", String(seconds))
const body = new Blob([formData.toString()], { type: "application/x-www-form-urlencoded;charset=UTF-8" })
navigator.sendBeacon(this.recordApi + "/position", body)
return
}
// 播放中每五秒、暂停时立即同步,确保服务端续播位置不会只依赖学习心跳。
this.$axios.post(this.recordApi + "/position", {
courseId: this.courseId,
resourceId: this.selectedResource.id,
positionSeconds: seconds
}).then((resp) => {
if (resp.code !== 0) {
this.lastSavedPositionSeconds = 0
}
})
},
applyRecordState(data) {
if (!this.selectedResource.id || !data) return
@@ -601,6 +741,11 @@ layout("/layouts/platform_h5.html"){
if (data.lastPositionSeconds !== undefined) {
this.$set(this.selectedResource, "lastPositionSeconds", Number(data.lastPositionSeconds || 0))
}
if (data.maxPositionSeconds !== undefined) {
const maxPositionSeconds = Number(data.maxPositionSeconds || 0)
this.$set(this.selectedResource, "maxPositionSeconds", maxPositionSeconds)
this.lastAllowedPositionSeconds = Math.max(Number(this.lastAllowedPositionSeconds || 0), maxPositionSeconds)
}
},
getMediaPosition() {
if (!["video", "audio"].includes(this.selectedResource.resourceType)) return 0
@@ -709,6 +854,11 @@ layout("/layouts/platform_h5.html"){
fragment: "#container",
timeout: 8000
})
},
handlePageVisibility(event) {
if (document.hidden || (event && event.type === "pagehide")) {
this.saveMediaPosition(true)
}
}
},
async mounted() {
@@ -719,10 +869,18 @@ layout("/layouts/platform_h5.html"){
}
await this.loadCourse()
await this.loadTree()
this.pageVisibilityHandler = (event) => this.handlePageVisibility(event)
document.addEventListener("visibilitychange", this.pageVisibilityHandler)
window.addEventListener("pagehide", this.pageVisibilityHandler)
},
beforeDestroy() {
this.saveMediaPosition(true)
this.finishStudy(true)
this.stopHeartbeatTimer()
if (this.pageVisibilityHandler) {
document.removeEventListener("visibilitychange", this.pageVisibilityHandler)
window.removeEventListener("pagehide", this.pageVisibilityHandler)
}
}
})
</script>
@@ -698,7 +698,7 @@ layout("/layouts/platform_h5.html"){
:name="option.id"
v-model="selectedRadioId"
@click.stop="isDeadlinePassed ? $toast.fail('已过选择截止时间,无法修改') : selectRadioOption(option.id)"
:disabled="isDeadlinePassed"
:disabled="isDeadlinePassed || (systemDefaultOption && systemDefaultOption.id !== option.id)"
></van-radio>
</div>
@@ -717,7 +717,7 @@ layout("/layouts/platform_h5.html"){
integer
disable-input
:default-value="0"
:min="0"
:min="option.isSystemDefault ? 1 : 0"
:disabled="isDeadlinePassed"
input-width="40px"
button-size="22px"
@@ -899,6 +899,12 @@ layout("/layouts/platform_h5.html"){
computed: {
// 系统默认福利必须被保留,管理端限制每个项目最多配置一个。
systemDefaultOption() {
if (!this.projectInfo.options) return null
return this.projectInfo.options.find((option) => option.isSystemDefault) || null
},
hasSelection() {
if (this.projectInfo.isCheckBox === "radio") {
return !!this.selectedRadioId
@@ -961,6 +967,11 @@ layout("/layouts/platform_h5.html"){
},
selectNumChange(index, newValue) {
const option = this.projectInfo.options[index];
if (option.isSystemDefault && Number(newValue) < 1) {
this.$set(option, "selectNum", 1)
this.$toast.fail("系统默认福利不可取消")
return
}
const maxSelect = this.projectInfo.multiSelectNum || this.projectInfo.options.length;
// 计算其他所有选项的总和(不包括当前修改的选项)
@@ -1046,6 +1057,11 @@ layout("/layouts/platform_h5.html"){
}
}
// 首次进入且没有历史选择时,自动带出系统默认福利。
if (this.projectInfo.options && this.userSelection.length === 0) {
this.applySystemDefaultOption()
}
// 设置已选择的选项
if (this.projectInfo.options && this.userSelection.length > 0) {
// 单选模式
@@ -1270,6 +1286,11 @@ layout("/layouts/platform_h5.html"){
this.$toast.fail("已过选择截止时间,无法修改")
return
}
if (this.systemDefaultOption && this.systemDefaultOption.id !== optionId) {
this.selectedRadioId = this.systemDefaultOption.id
this.$toast.fail("系统默认福利不可取消")
return
}
this.selectedRadioId = optionId
// 重置所有选项的selectNum
@@ -1284,6 +1305,16 @@ layout("/layouts/platform_h5.html"){
}
},
// 默认项只在首次选择时自动带入,已有历史选择不自动覆盖。
applySystemDefaultOption() {
if (!this.systemDefaultOption) return
if (this.projectInfo.isCheckBox === "radio") {
this.selectRadioOption(this.systemDefaultOption.id)
} else {
this.$set(this.systemDefaultOption, "selectNum", 1)
}
},
// 显示选项详情
showOptionDetail(option) {
this.selectedOption = option