commit
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
+2
-1
@@ -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"))));
|
||||
|
||||
+8
-4
@@ -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());
|
||||
|
||||
+15
@@ -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("提交核对")
|
||||
|
||||
+16
@@ -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("某个社团预算使用详情")
|
||||
|
||||
+11
@@ -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);
|
||||
}
|
||||
|
||||
+11
@@ -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);
|
||||
}
|
||||
|
||||
+80
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+74
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步预算主表的已使用额度,保证校工会、分工会、协会三类台账口径一致。
|
||||
*
|
||||
|
||||
+37
-6
@@ -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();
|
||||
// 获取正在执行的任务(邀请附议人||提案附议)
|
||||
|
||||
+37
-3
@@ -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("立案结果同步失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个提案可以配置多个主办单位和多个协办单位。
|
||||
* 这里按页面提交顺序逐条生成承办单位记录,兼容多主办的业务场景。
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+63
-7
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+143
-12
@@ -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)
|
||||
|
||||
+13
@@ -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)
|
||||
|
||||
+4
@@ -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());
|
||||
|
||||
+6
@@ -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);
|
||||
// 原数据
|
||||
|
||||
+8
@@ -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">
|
||||
|
||||
+9
@@ -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: '更新',
|
||||
|
||||
+7
-1
@@ -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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+68
-63
@@ -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 {
|
||||
|
||||
+45
-6
@@ -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>
|
||||
|
||||
+1
-1
@@ -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)">
|
||||
|
||||
</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
|
||||
|
||||
Reference in New Issue
Block a user