diff --git a/src/main/java/io/v/nutz/sys/controllers/platform/sys/SysCompletedController.java b/src/main/java/io/v/nutz/sys/controllers/platform/sys/SysCompletedController.java index e2fef7e..232941d 100644 --- a/src/main/java/io/v/nutz/sys/controllers/platform/sys/SysCompletedController.java +++ b/src/main/java/io/v/nutz/sys/controllers/platform/sys/SysCompletedController.java @@ -33,14 +33,14 @@ public class SysCompletedController { @Inject private MainPageNeedItemsService mainPageNeedItemsService; + @Inject + private io.v.nutz.sys.services.SysCompletedService completedService; + + /** 返回当前登录人的手机已办,已在 service 中校验目标页面权限。 */ @At @ViReturn public Object getCompletes(){ - CndPlus cnd = CndPlus.create(); - cnd.and("auditBy","=", ShiroUtil.getPrincipalProperty("id")); - cnd.and("YEAR(auditTime)","=",DateUtil.thisYear()); - cnd.desc("auditTime"); - return baseService.dao().query(Sys_completed.class,cnd); + return completedService.getCompletes(); } @@ -51,7 +51,7 @@ public class SysCompletedController { //审核完成前的待办 List itemsList = Arrays.asList(items); //审核完成后的待办 - List needItemsList = mainPageNeedItemsService.getNeedItems(); + List needItemsList = mainPageNeedItemsService.getNeedItems(true); //过滤没有移动端地址和数量为0的待办 List needItems = needItemsList.stream().filter(v-> v.getCount()>0 && Strings.isNotBlank(v.getMobileHref())).collect(Collectors.toList()); //审核前与审核后待办取差集 diff --git a/src/main/java/io/v/nutz/sys/controllers/platform/sys/SysLocalProcessController.java b/src/main/java/io/v/nutz/sys/controllers/platform/sys/SysLocalProcessController.java index 42fb742..5f55061 100644 --- a/src/main/java/io/v/nutz/sys/controllers/platform/sys/SysLocalProcessController.java +++ b/src/main/java/io/v/nutz/sys/controllers/platform/sys/SysLocalProcessController.java @@ -1,22 +1,13 @@ package io.v.nutz.sys.controllers.platform.sys; import cn.wizzer.framework.base.Result; -import io.v.nutz.web.commons.utils.ShiroUtil; import lombok.extern.slf4j.Slf4j; import org.apache.shiro.authz.annotation.RequiresAuthentication; -import org.nutz.dao.Cnd; -import org.nutz.dao.Dao; -import org.nutz.dao.Sqls; -import org.nutz.dao.sql.Sql; -import org.nutz.dao.util.Daos; -import org.nutz.dao.util.cri.Static; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; -import org.nutz.lang.util.NutMap; import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; -import java.util.List; /** * 任务待办 @@ -27,67 +18,14 @@ import java.util.List; public class SysLocalProcessController { @Inject - private Dao dao; + private io.v.nutz.sys.services.SysLocalProcessService sysLocalProcessService; - /** - * 查询待办 - * @param mode 1待处理 2已处理 3我发起的 - * @return - */ + /** mode=1 待办、2 已办、3 本人发起;mobile=true 手机端,默认 PC;返回 code/data,data 为包含双端处理地址的任务数组。 */ @At @RequiresAuthentication @Ok("json:full") - public Result todoList(Integer mode) { - if (mode == 1 || mode == 2) { - Sql sql = Sqls.create(""" - SELECT - t.taskNodeName, - t.createdByUserName, - t.formUrl, - t.formUrlView, - t.createdOn, - t2.processName, - t2.nodeName - FROM - sys_local_process_instance_task t - LEFT JOIN sys_local_process_instance t2 ON t2.processUniqueId = t.processUniqueId - $condition - """); - Cnd cnd = Cnd.NEW(); - cnd.and(new Static("JSON_CONTAINS( t.assignments, '\"" + ShiroUtil.getPrincipalProperty("loginname") + "\"')")); - cnd.and("t.status", "=", mode); - cnd.and("t.taskDeleteFlag", "=", 0); - cnd.desc("t2.processInitiationTime"); - sql.setCondition(cnd); - List list = (List) Daos.query(dao, sql.toString(), Sqls.callback.maps()); - return Result.success(list); - } else if (mode == 3) { - Sql sql = Sqls.create(""" - SELECT - t.id, - t.processUniqueId, - t.processName, - t.nodeName, - t.pcUrl, - t.mobileUrl, - t.pcUrl as formUrl, - t.processInstanceStatus, - t.processInitiationTime as createdOn, - u.username as createdByUserName - FROM - sys_local_process_instance t - LEFT JOIN sys_user u ON u.id = t.processInitiatorId - WHERE - t.processInitiatorId = @id - AND t.processDeleteFlag = 0 - AND t.delFlag = 0 - ORDER BY t.processInitiationTime DESC - """); - sql.setParam("id", ShiroUtil.getUserId()); - List list = (List) Daos.query(dao, sql.toString(), Sqls.callback.maps()); - return Result.success(list); - } - return null; + public Result todoList(Integer mode, Boolean mobile) { + if (mode == null || mode < 1 || mode > 3) return Result.error("请选择有效的待办查询类型"); + return Result.success(sysLocalProcessService.todoList(mode, Boolean.TRUE.equals(mobile))); } - } diff --git a/src/main/java/io/v/nutz/sys/controllers/platform/sys/club/examine/ClubExamineRegisterAgentController.java b/src/main/java/io/v/nutz/sys/controllers/platform/sys/club/examine/ClubExamineRegisterAgentController.java index 7b9b0aa..33b6431 100644 --- a/src/main/java/io/v/nutz/sys/controllers/platform/sys/club/examine/ClubExamineRegisterAgentController.java +++ b/src/main/java/io/v/nutz/sys/controllers/platform/sys/club/examine/ClubExamineRegisterAgentController.java @@ -59,7 +59,8 @@ public class ClubExamineRegisterAgentController { Sys_user user = (Sys_user) ShiroUtil.getPrincipal(); List menus = Optional.ofNullable(user).orElse(new Sys_user()).getMenus(); - return result.stream().filter(v -> Strings.isNotBlank(v.getString("url")) && menus.stream().anyMatch(x -> Strings.isNotBlank(x.getHref()) && v.getString("url").contains(x.getHref()))).distinct().collect(Collectors.toList()); + // 保留原有任务数据范围,再按当前账号的目标页面权限过滤入口。 + return result.stream().filter(v -> Strings.isNotBlank(v.getString("url")) && menus.stream().anyMatch(x -> Strings.isNotBlank(x.getHref()) && v.getString("url").contains(x.getHref()))).filter(v -> io.v.nutz.sys.services.TodoAccessService.canAccess(v.getString("url"))).distinct().collect(Collectors.toList()); } diff --git a/src/main/java/io/v/nutz/sys/controllers/platform/sys/club/needItems/ClubNeedItemsController.java b/src/main/java/io/v/nutz/sys/controllers/platform/sys/club/needItems/ClubNeedItemsController.java index b47925f..73f24f7 100644 --- a/src/main/java/io/v/nutz/sys/controllers/platform/sys/club/needItems/ClubNeedItemsController.java +++ b/src/main/java/io/v/nutz/sys/controllers/platform/sys/club/needItems/ClubNeedItemsController.java @@ -60,7 +60,8 @@ public class ClubNeedItemsController { Sys_user user = (Sys_user) ShiroUtil.getPrincipal(); List menus = Optional.ofNullable(user).orElse(new Sys_user()).getMenus(); - return result.stream().filter(v -> Strings.isNotBlank(v.getString("url")) && menus.stream().anyMatch(x -> Strings.isNotBlank(x.getHref()) && v.getString("url").contains(x.getHref()))).distinct().collect(Collectors.toList()); + // 保留原有任务数据范围,再按当前账号的目标页面权限过滤入口。 + return result.stream().filter(v -> Strings.isNotBlank(v.getString("url")) && menus.stream().anyMatch(x -> Strings.isNotBlank(x.getHref()) && v.getString("url").contains(x.getHref()))).filter(v -> io.v.nutz.sys.services.TodoAccessService.canAccess(v.getString("url"))).distinct().collect(Collectors.toList()); } private int getClubRegistCount(Boolean flag, String roleId, Integer state) { @@ -87,7 +88,8 @@ public class ClubNeedItemsController { Sys_user user = (Sys_user) ShiroUtil.getPrincipal(); List menus = Optional.ofNullable(user).orElse(new Sys_user()).getMenus(); - return result.stream().filter(v -> Strings.isNotBlank(v.getString("url")) && menus.stream().anyMatch(x -> Strings.isNotBlank(x.getHref()) && v.getString("url").contains(x.getHref()))).distinct().collect(Collectors.toList()); + // 保留原有任务数据范围,再按当前账号的目标页面权限过滤入口。 + return result.stream().filter(v -> Strings.isNotBlank(v.getString("url")) && menus.stream().anyMatch(x -> Strings.isNotBlank(x.getHref()) && v.getString("url").contains(x.getHref()))).filter(v -> io.v.nutz.sys.services.TodoAccessService.canAccess(v.getString("url"))).distinct().collect(Collectors.toList()); } private int getClubApplyCount(String roleId, Integer state) { @@ -116,7 +118,8 @@ public class ClubNeedItemsController { Sys_user user = (Sys_user) ShiroUtil.getPrincipal(); List menus = Optional.ofNullable(user).orElse(new Sys_user()).getMenus(); - return result.stream().filter(v -> Strings.isNotBlank(v.getString("url")) && menus.stream().anyMatch(x -> Strings.isNotBlank(x.getHref()) && v.getString("url").contains(x.getHref()))).distinct().collect(Collectors.toList()); + // 保留原有任务数据范围,再按当前账号的目标页面权限过滤入口。 + return result.stream().filter(v -> Strings.isNotBlank(v.getString("url")) && menus.stream().anyMatch(x -> Strings.isNotBlank(x.getHref()) && v.getString("url").contains(x.getHref()))).filter(v -> io.v.nutz.sys.services.TodoAccessService.canAccess(v.getString("url"))).distinct().collect(Collectors.toList()); } private int getClubManageCount() { @@ -139,7 +142,8 @@ public class ClubNeedItemsController { Sys_user user = (Sys_user) ShiroUtil.getPrincipal(); List menus = Optional.ofNullable(user).orElse(new Sys_user()).getMenus(); - return result.stream().filter(v -> Strings.isNotBlank(v.getString("url")) && menus.stream().anyMatch(x -> Strings.isNotBlank(x.getHref()) && v.getString("url").contains(x.getHref()))).distinct().collect(Collectors.toList()); + // 保留原有任务数据范围,再按当前账号的目标页面权限过滤入口。 + return result.stream().filter(v -> Strings.isNotBlank(v.getString("url")) && menus.stream().anyMatch(x -> Strings.isNotBlank(x.getHref()) && v.getString("url").contains(x.getHref()))).filter(v -> io.v.nutz.sys.services.TodoAccessService.canAccess(v.getString("url"))).distinct().collect(Collectors.toList()); } private int getClubEvaluateCount(Boolean flag, String roleId, Integer state) { diff --git a/src/main/java/io/v/nutz/sys/services/SysCompletedService.java b/src/main/java/io/v/nutz/sys/services/SysCompletedService.java new file mode 100644 index 0000000..9aee1f7 --- /dev/null +++ b/src/main/java/io/v/nutz/sys/services/SysCompletedService.java @@ -0,0 +1,27 @@ +package io.v.nutz.sys.services; + +import cn.hutool.core.date.DateUtil; +import io.v.nutz.base.dao.CndPlus; +import io.v.nutz.sys.models.Sys_completed; +import io.v.nutz.web.commons.utils.ShiroUtil; +import org.nutz.dao.Dao; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import java.util.List; +import java.util.stream.Collectors; + +/** 旧版手机已办查询,权限过滤保留原有本人和年度范围。 */ +@IocBean +public class SysCompletedService { + @Inject private Dao dao; + + /** 无外部身份参数;返回当前登录人本年度、手机查看入口仍有权限的已办记录。 */ + public List getCompletes() { + CndPlus cnd = CndPlus.create(); + cnd.and("auditBy", "=", ShiroUtil.getUserId()); + cnd.and("YEAR(auditTime)", "=", DateUtil.thisYear()); + cnd.desc("auditTime"); + return dao.query(Sys_completed.class, cnd).stream() + .filter(row -> TodoAccessService.canAccess(row.getMobileUrl())).collect(Collectors.toList()); + } +} diff --git a/src/main/java/io/v/nutz/sys/services/SysLocalProcessService.java b/src/main/java/io/v/nutz/sys/services/SysLocalProcessService.java index 3634919..5e6ac1c 100644 --- a/src/main/java/io/v/nutz/sys/services/SysLocalProcessService.java +++ b/src/main/java/io/v/nutz/sys/services/SysLocalProcessService.java @@ -4,6 +4,10 @@ import javax.validation.constraints.NotNull; import java.util.List; public interface SysLocalProcessService { + /** mode=1 待办、2 已办、3 本人发起;返回含原 PC 字段及手机处理/查看地址的任务列表,身份取当前登录人。 */ + List todoList(Integer mode); + /** mobile=true 按手机跳转地址鉴权,false 按 PC 地址鉴权;返回已过滤的任务数组。 */ + List todoList(Integer mode, boolean mobile); /** * 开始一个实例流程 diff --git a/src/main/java/io/v/nutz/sys/services/TodoAccessService.java b/src/main/java/io/v/nutz/sys/services/TodoAccessService.java new file mode 100644 index 0000000..c095a23 --- /dev/null +++ b/src/main/java/io/v/nutz/sys/services/TodoAccessService.java @@ -0,0 +1,86 @@ +package io.v.nutz.sys.services; + +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.authz.annotation.*; +import org.apache.shiro.subject.Subject; +import org.nutz.mvc.*; +import org.nutz.mvc.impl.MappingNode; +import org.nutz.lang.util.NutMap; +import java.lang.reflect.AnnotatedElement; +import java.net.URI; +import java.util.*; +import java.util.stream.Collectors; + +/** 待办入口访问判断:读取 MVC 实际注册的路由及权限,不执行目标方法、不修改业务数据。 */ +public class TodoAccessService { + private static final String KEY = TodoAccessService.class.getName(); + private final Map exact = new HashMap<>(); + private final MappingNode patterns = new MappingNode<>(); + + /** MVC 启动时登记入口;注册表跟随本次应用配置,重启后重新建立,不缓存用户权限。 */ + public static synchronized void register(NutConfig config, ActionInfo info) { + // 待办点击使用 GET;同一路径的提交接口不能覆盖页面自身权限。 + if (info.getHttpMethods() != null && !info.getHttpMethods().isEmpty() + && info.getHttpMethods().stream().noneMatch(method -> "GET".equalsIgnoreCase(method))) return; + TodoAccessService service = (TodoAccessService) config.getAttribute(KEY); + if (service == null) { service = new TodoAccessService(); config.setAttribute(KEY, service); } + for (String path : info.getPaths()) { + if (!path.startsWith("/")) path = "/" + path; + service.exact.put(path, info); + service.patterns.add(path, info); + } + } + + /** url 为待办真实跳转地址;返回当前登录账号能否访问。不存在的入口不展示。 */ + public static boolean canAccess(String url) { + NutConfig config = Mvcs.getNutConfig(); + TodoAccessService service = config == null ? null : (TodoAccessService) config.getAttribute(KEY); + return service != null && service.allowed(url, SecurityUtils.getSubject()); + } + + /** 去掉查询参数、锚点和应用上下文后匹配路由;权限保留原来的 AND/OR 组合语义。 */ + public boolean allowed(String url, Subject subject) { + if (url == null || url.trim().isEmpty() || subject == null || !subject.isAuthenticated()) return false; + try { + URI uri = URI.create(url.trim()); + // 允许配置为本系统绝对地址的旧任务,外部域名不能按本系统权限推断。 + if (uri.isAbsolute() || uri.getRawAuthority() != null) { + String domain = io.v.nutz.web.commons.base.Globals.AppDomain; + if (domain == null || domain.isBlank()) return false; + URI app = URI.create(domain); + if (!Objects.equals(app.getScheme(), uri.getScheme()) || !Objects.equals(app.getRawAuthority(), uri.getRawAuthority())) return false; + } + String path = uri.getPath(); + if (path == null || !path.startsWith("/")) return false; + if (Mvcs.getReq() != null) { + String context = Mvcs.getReq().getContextPath(); + if (context != null && !context.isEmpty() && path.startsWith(context + "/")) path = path.substring(context.length()); + } + ActionInfo info = exact.get(path); + if (info == null && path.endsWith("/")) info = exact.get(path.substring(0, path.length()-1)); + if (info == null) info = patterns.get(new ActionContext().setPath(path), path); + return info != null && check(info.getModuleType(), subject) && check(info.getMethod(), subject); + } catch (IllegalArgumentException e) { return false; } + } + + private boolean check(AnnotatedElement element, Subject subject) { + if (element == null) return true; + if (element.isAnnotationPresent(RequiresGuest.class)) return subject.getPrincipal() == null; + RequiresPermissions permission = element.getAnnotation(RequiresPermissions.class); + if (permission != null) { + boolean passed = permission.logical() == Logical.OR + ? Arrays.stream(permission.value()).anyMatch(subject::isPermitted) + : Arrays.stream(permission.value()).allMatch(subject::isPermitted); + if (!passed) return false; + } + RequiresRoles roles = element.getAnnotation(RequiresRoles.class); + return roles == null || (roles.logical() == Logical.OR + ? Arrays.stream(roles.value()).anyMatch(subject::hasRole) + : Arrays.stream(roles.value()).allMatch(subject::hasRole)); + } + + /** rows 为已有数据范围内的待办;urlField 是本端点击使用的地址字段,返回过滤后的列表。 */ + public static List filter(List rows, String urlField) { + return rows.stream().filter(row -> canAccess(row.getString(urlField))).collect(Collectors.toList()); + } +} diff --git a/src/main/java/io/v/nutz/sys/services/impl/SysLocalProcessServiceImpl.java b/src/main/java/io/v/nutz/sys/services/impl/SysLocalProcessServiceImpl.java index 1d01020..fd92787 100644 --- a/src/main/java/io/v/nutz/sys/services/impl/SysLocalProcessServiceImpl.java +++ b/src/main/java/io/v/nutz/sys/services/impl/SysLocalProcessServiceImpl.java @@ -29,6 +29,80 @@ public class SysLocalProcessServiceImpl implements SysLocalProcessService { @Inject private Dao dao; + /** + * mode 为 1 待办、2 已办、3 本人发起;返回字段保留 PC 兼容,并增加 id、formMobileUrl、formMobileUrlView、endOn。 + * 待办/已办按接收人工号查询,发起记录按当前用户 ID 查询;删除标记的任务和流程不再展示。 + */ + @Override + public List todoList(Integer mode) { + return todoList(mode, false); + } + + @Override + public List todoList(Integer mode, boolean mobile) { + if (mode == null || mode < 1 || mode > 3) throw new IllegalArgumentException("请选择有效的待办查询类型"); + Sql sql; + if (mode == 1 || mode == 2) { + sql = Sqls.create(""" + select t.id,t.processUniqueId,t.taskNodeName,t.createdByUserName,t.formUrl,t.formUrlView, + t.formMobileUrl,t.formMobileUrlView,t.createdOn,t.endOn,t2.processName,t2.nodeName + from sys_local_process_instance_task t + join sys_local_process_instance t2 on t2.processUniqueId=t.processUniqueId + where JSON_CONTAINS(t.assignments,JSON_QUOTE(@loginname)) and t.status=@mode + and coalesce(t.taskDeleteFlag,0)=0 and coalesce(t.delFlag,0)=0 + and coalesce(t2.processDeleteFlag,0)=0 and coalesce(t2.delFlag,0)=0 + order by t2.processInitiationTime desc,t.id desc + """).setParam("loginname",io.v.nutz.web.commons.utils.ShiroUtil.getPrincipalProperty("loginname")) + .setParam("mode",mode); + } else { + sql = Sqls.create(""" + select t.id,t.processUniqueId,t.processName,t.nodeName,t.pcUrl,t.mobileUrl,t.pcUrl as formUrl, + t.mobileUrl as formMobileUrl,t.mobileUrl as formMobileUrlView,t.processInstanceStatus, + t.processInitiationTime as createdOn,u.username as createdByUserName + from sys_local_process_instance t left join sys_user u on u.id=t.processInitiatorId + where t.processInitiatorId=@id and coalesce(t.processDeleteFlag,0)=0 and coalesce(t.delFlag,0)=0 + order by t.processInitiationTime desc + """).setParam("id",io.v.nutz.web.commons.utils.ShiroUtil.getUserId()); + } + sql.setCallback(Sqls.callback.maps()); + dao.execute(sql); + List rows = sql.getList(org.nutz.lang.util.NutMap.class); + // 兼容历史会员申请任务:只修正返回的手机地址,不回写数据库,也不改变 PC 地址和查询权限。 + for (org.nutz.lang.util.NutMap row : rows) { + if (!row.getString("processUniqueId", "").startsWith("MEMBER_APPLY@")) continue; + for (String field : List.of("formMobileUrl", "formMobileUrlView", "mobileUrl")) { + if (row.containsKey(field)) row.put(field, memberApplyMobileUrl(row.getString(field))); + } + } + // 权限仅进一步收紧已有候选人/本人范围;已办按查看入口判断,不能因历史授权继续展示。 + return rows.stream().filter(row -> { + String url = mobile ? row.getString("formMobileUrl") : row.getString("formUrl"); + if (mode == 2) { + String view = row.getString(mobile ? "formMobileUrlView" : "formUrlView"); + if (view != null && !view.isBlank()) url = view; + } + return io.v.nutz.sys.services.TodoAccessService.canAccess(url); + }).collect(java.util.stream.Collectors.toList()); + } + + /** + * 输入历史手机 URL,返回对应 H5 地址;仅匹配已存在 H5 页面的三个会员申请入口。 + * 保留查询参数与锚点,空值、正确 H5 地址及其他业务地址原样返回,避免重复添加 /h5。 + */ + private String memberApplyMobileUrl(String url) { + if (url == null || url.isEmpty()) return url; + int end = url.length(); + if (url.indexOf('?') >= 0) end = Math.min(end, url.indexOf('?')); + if (url.indexOf('#') >= 0) end = Math.min(end, url.indexOf('#')); + String path = url.substring(0, end); + if (path.endsWith("/")) path = path.substring(0, path.length() - 1); + if (List.of("/platform/member/apply/mine", "/platform/member/apply/branchUnion/audit", + "/platform/member/apply/schoolUnion/audit").contains(path)) { + return path + "/h5" + url.substring(end); + } + return url; + } + @Override public void startProcess(String processName, String processUniqueId, String nodeName, String processInitiatorId, String pcUrl, String mobileUrl) { int count = dao.count(Sys_local_process_instance.class, Cnd.where("processUniqueId", "=", processUniqueId)); diff --git a/src/main/java/io/v/nutz/web/commons/proc/NutShiroProcessor.java b/src/main/java/io/v/nutz/web/commons/proc/NutShiroProcessor.java index 6d7df21..21f7529 100644 --- a/src/main/java/io/v/nutz/web/commons/proc/NutShiroProcessor.java +++ b/src/main/java/io/v/nutz/web/commons/proc/NutShiroProcessor.java @@ -56,6 +56,7 @@ public class NutShiroProcessor extends AbstractProcessor { throw new IllegalStateException("this Processor have bean inited!!"); } super.init(config, ai); + io.v.nutz.sys.services.TodoAccessService.register(config, ai); match = NutShiro.match(ai.getMethod()); init = true; } diff --git a/src/main/java/io/v/nutz/zhgh/activity/controller/site/SiteManageController.java b/src/main/java/io/v/nutz/zhgh/activity/controller/site/SiteManageController.java index a454139..d67c72f 100644 --- a/src/main/java/io/v/nutz/zhgh/activity/controller/site/SiteManageController.java +++ b/src/main/java/io/v/nutz/zhgh/activity/controller/site/SiteManageController.java @@ -7,6 +7,8 @@ import io.v.nutz.base.annontation.ViReturn; import io.v.nutz.base.utils.PageUtil; import org.apache.shiro.authz.annotation.RequiresPermissions; import org.nutz.dao.Cnd; +import org.nutz.ioc.aop.Aop; +import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.Strings; @@ -70,22 +72,27 @@ public class SiteManageController { @At @ViReturn @RequiresPermissions("activity.site.manage") + /** data 为新增场地表单,返回统一 code/msg;类型关联和校区校验由 service 完成。 */ + @Aop(TransAop.READ_COMMITTED) public Object doAdd(@Param("data") ActivitySiteInfo activitySiteInfo) { - siteInfoService.insert(activitySiteInfo); + siteInfoService.saveManagedSite(activitySiteInfo, false); return null; } @At @ViReturn @RequiresPermissions("activity.site.manage") + /** data 为包含 id 的场地表单,返回统一 code/msg,成功后可重新查询校区回显。 */ + @Aop(TransAop.READ_COMMITTED) public Object doEdit(@Param("data") ActivitySiteInfo activitySiteInfo) { - siteInfoService.updateIgnoreNull(activitySiteInfo); + siteInfoService.saveManagedSite(activitySiteInfo, true); return null; } @At @ViReturn @RequiresPermissions("activity.site.manage") + @Aop(TransAop.READ_COMMITTED) public Object doDelete(String id) { siteInfoService.delete(id); return null; diff --git a/src/main/java/io/v/nutz/zhgh/activity/controller/site/SiteRecordController.java b/src/main/java/io/v/nutz/zhgh/activity/controller/site/SiteRecordController.java index bbd4193..dc4c71e 100644 --- a/src/main/java/io/v/nutz/zhgh/activity/controller/site/SiteRecordController.java +++ b/src/main/java/io/v/nutz/zhgh/activity/controller/site/SiteRecordController.java @@ -28,6 +28,8 @@ import org.nutz.mvc.annotation.Param; @At("/platform/activity/site/record") public class SiteRecordController { + @Inject private io.v.nutz.zhgh.activity.services.impl.SiteBookingService siteBookingService; + @Inject private SiteReserveService siteReserveService; @@ -57,8 +59,7 @@ public class SiteRecordController { ass.`stateName` state_name, ass.`stateColor` state_color, ass.stateAuditType, - count(sqid) days, - GROUP_CONCAT(reserve_day order by reserve_day asc) concat_day + count(distinct reserve_day) days FROM activity_site_reserve asr LEFT JOIN @@ -96,7 +97,7 @@ public class SiteRecordController { cnd.groupBy("sqid"); sql.setCondition(cnd); - return siteReserveService.listPage(pageNumber, pageSize, sql); + return siteBookingService.reservationPage(pageNumber, pageSize, sql, month); } diff --git a/src/main/java/io/v/nutz/zhgh/activity/controller/site/SiteReserveController.java b/src/main/java/io/v/nutz/zhgh/activity/controller/site/SiteReserveController.java index f875e5c..8f25349 100644 --- a/src/main/java/io/v/nutz/zhgh/activity/controller/site/SiteReserveController.java +++ b/src/main/java/io/v/nutz/zhgh/activity/controller/site/SiteReserveController.java @@ -99,8 +99,7 @@ public class SiteReserveController { ass.`stateName` state_name, ass.`stateColor` state_color, ass.stateAuditType, - count(sqid) days, - GROUP_CONCAT(reserve_day order by reserve_day asc) concat_day + count(distinct reserve_day) days FROM activity_site_reserve asr LEFT JOIN @@ -140,15 +139,19 @@ public class SiteReserveController { cnd.groupBy("sqid"); sql.setCondition(cnd); - return siteReserveService.listPage(pageNumber, pageSize, sql); + return siteBookingService.reservationPage(pageNumber, pageSize, sql, time); } @At @ViReturn @RequiresPermissions("activity.site.reserve") @Aop(TransAop.READ_COMMITTED) + /** id 为预约记录主键,op=1 删除已撤销申请、op=2 撤销申请;成功返回空 data,由 ViReturn 包装结果。 */ public Object doDelete(String id, Integer op) { - siteBookingService.cancel(id); + if (org.nutz.lang.Strings.isBlank(id)) throw new IllegalArgumentException("请选择预约记录"); + if (Integer.valueOf(1).equals(op)) siteBookingService.deleteCancelled(id); + else if (Integer.valueOf(2).equals(op)) siteBookingService.cancel(id); + else throw new IllegalArgumentException("不支持的预约操作"); return null; } @@ -165,10 +168,16 @@ public class SiteReserveController { @RequiresPermissions("activity.site.reserve") /** data 为场地、类型、协会及事由;days 为日期数组,起止时间取 data;返回新申请 sqid。 */ @Aop(TransAop.READ_COMMITTED) - public Object doAdd(@Param("data") ActivitySiteReserve activitySiteReserve, @Param("days") String[] days) { + public Object doAdd(@Param("data") ActivitySiteReserve activitySiteReserve, @Param("days") String[] days, @Param("times") String[] times) { List slots = new ArrayList<>(); if (activitySiteReserve == null || days == null) throw new IllegalArgumentException("请选择预约日期"); - for (String day : days) slots.add(NutMap.NEW().setv("day",day).setv("start_time",activitySiteReserve.getStart_time()).setv("end_time",activitySiteReserve.getEnd_time())); + // times为所选HH:mm-HH:mm场次数组;保留旧客户端单场次参数的兼容。 + if(times==null || times.length==0)times=new String[]{activitySiteReserve.getStart_time()+"-"+activitySiteReserve.getEnd_time()}; + for(String day:days)for(String time:times){ + String[] parts=time.split("-",-1); + if(parts.length!=2)throw new IllegalArgumentException("请选择有效预约场次"); + slots.add(NutMap.NEW().setv("day",day).setv("start_time",parts[0]).setv("end_time",parts[1])); + } return siteBookingService.submit(activitySiteReserve,slots); } @@ -253,4 +262,8 @@ public class SiteReserveController { /** 返回当前用户有效协会数组(id/name),供个人所属协会展示和协会预约选择。 */ @At @ViReturn @org.apache.shiro.authz.annotation.RequiresAuthentication public Object myClubs() { return siteBookingService.myClubs(); } + + /** 无入参;返回统一code/data,data.id/name为当前申请人所属分工会,仅供表单展示。 */ + @At @ViReturn @RequiresPermissions("activity.site.reserve") + public Object myUnion() { return siteBookingService.myBookingUnion(); } } diff --git a/src/main/java/io/v/nutz/zhgh/activity/models/ActivitySiteInfo.java b/src/main/java/io/v/nutz/zhgh/activity/models/ActivitySiteInfo.java index 2cd11fd..f47e037 100644 --- a/src/main/java/io/v/nutz/zhgh/activity/models/ActivitySiteInfo.java +++ b/src/main/java/io/v/nutz/zhgh/activity/models/ActivitySiteInfo.java @@ -28,13 +28,38 @@ public class ActivitySiteInfo { @ColDefine(type = ColType.VARCHAR, width = 100) private String name; + @Column + @Comment("校区名称,取自系统校区选项") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String campus; + + @Column + @Comment("预约时间段类型:1分段预约,2全天候预约;旧场地按分段兼容") + @ColDefine(type = ColType.INT) + private Integer reserveTimeType; + + @Column + @Comment("禁用时段:date、startTime、endTime") + @ColDefine(type = ColType.MYSQL_JSON) + private List notApplyTimeList; + + @Column + @Comment("分段场次原始配置,保留切换前的时间及timeUnit分钟单位") + @ColDefine(type = ColType.MYSQL_JSON) + private List segmentedOpenHours; + + @Column + @Comment("全天候起止时间及timeUnit分钟单位") + @ColDefine(type = ColType.MYSQL_JSON) + private org.nutz.lang.util.NutMap fullDayOpenHour; + @Column @Comment("场地地址") @ColDefine(type = ColType.VARCHAR, width = 100) private String address; @Column - @Comment("联系人") + @Comment("场地管理员") @ColDefine(type = ColType.VARCHAR, width = 32) private String contact_person; @@ -84,7 +109,7 @@ public class ActivitySiteInfo { private Integer sexLimit; @Column - @Comment("个人预约限定人数") + @Comment("限定人数") @ColDefine(type = ColType.INT) private Integer limitNum; } diff --git a/src/main/java/io/v/nutz/zhgh/activity/models/ActivitySiteReserve.java b/src/main/java/io/v/nutz/zhgh/activity/models/ActivitySiteReserve.java index 5d48b13..d45c56b 100644 --- a/src/main/java/io/v/nutz/zhgh/activity/models/ActivitySiteReserve.java +++ b/src/main/java/io/v/nutz/zhgh/activity/models/ActivitySiteReserve.java @@ -137,7 +137,7 @@ public class ActivitySiteReserve extends BaseModel { private String joinUser; @Column - @Comment("预约类型(1.个人预约,2.单位预约,3.协会预约)") + @Comment("预约类型(1.个人预约,2.分工会预约,3.协会预约)") @ColDefine(type = ColType.INT, width = 10) private Integer reserve_type; } diff --git a/src/main/java/io/v/nutz/zhgh/activity/services/SiteInfoService.java b/src/main/java/io/v/nutz/zhgh/activity/services/SiteInfoService.java index 990e14c..56a416d 100644 --- a/src/main/java/io/v/nutz/zhgh/activity/services/SiteInfoService.java +++ b/src/main/java/io/v/nutz/zhgh/activity/services/SiteInfoService.java @@ -5,4 +5,12 @@ import cn.wizzer.framework.base.service.BaseService; import io.v.nutz.zhgh.activity.models.ActivitySiteInfo; public interface SiteInfoService extends BaseService { + /** form 为场地表单,editing 区分新增和编辑;保存校区并由后端关联职工之家类型,返回 void。 */ + void saveManagedSite(ActivitySiteInfo form, boolean editing); + /** 校验 day(yyyy-MM-dd)、start/end(HH:mm,结束可24:00)属于有效场次且不与禁用时间交叉;不通过抛出参数异常。 */ + void validateBookingSlot(ActivitySiteInfo site, String day, String start, String end); + /** 查询某天可预约场次,reserveType=1/2/3;返回 start_time/end_time、code(1可约)、msg 和人数。 */ + java.util.List availableSlots(String siteId, String day, Integer reserveType); + /** 返回start/end范围内实际同时占用的峰值人数,避免配置扩大后把相邻历史场次累加成超额。 */ + int occupiedCount(java.util.List rows, String start, String end); } diff --git a/src/main/java/io/v/nutz/zhgh/activity/services/impl/SiteBookingService.java b/src/main/java/io/v/nutz/zhgh/activity/services/impl/SiteBookingService.java index a247dab..b0929e3 100644 --- a/src/main/java/io/v/nutz/zhgh/activity/services/impl/SiteBookingService.java +++ b/src/main/java/io/v/nutz/zhgh/activity/services/impl/SiteBookingService.java @@ -30,11 +30,15 @@ import java.util.stream.Collectors; @IocBean(args = {"refer:dao"}) public class SiteBookingService extends BaseServiceImpl { public static final int UNION = 4000, CLUB = 4010, SCHOOL = 4020, PASS = 4030, REJECT = 4040, CANCEL = 4050; + /** 校工会节点统一按专属场地管理员角色分配待办和校验审核资格。 */ + private static final String SCHOOL_REVIEW_ROLE = "SchoolUnionActivityVenueAdmin"; /** 兼容现有日历组件的状态字段;名称统一读取 state,不再读取 audit_state。 */ public static final String STATES = "(select state_id stateId,state_name stateName,state_color stateColor," + "case when state_id=4030 then 3 when state_id in (4040,4050) then 1 else 0 end stateAuditType " + "from state where belong='activity_site')"; @Inject private SysLocalProcessService sysLocalProcessService; + @Inject private io.v.nutz.zhgh.activity.services.SiteInfoService siteInfoService; + @Inject private io.v.nutz.base.utils.MsgApi msgApi; public SiteBookingService(Dao dao) { super(dao); } @@ -48,17 +52,24 @@ public class SiteBookingService extends BaseServiceImpl { + "order by c.name").setParam("uid", uid())); } - /** 预约类型 1 个人、2 单位、3 协会,返回应进入的首个状态 ID。 */ + /** 无入参,返回当前申请人的id/name所属分工会;采用与提交相同的user视图组织来源,未配置时返回空字符串。 */ + public NutMap myBookingUnion() { + List rows=listMap(Sqls.create("select u.unionid id,un.unionname name from `user` u left join sys_union un on un.id=u.unionid where u.id=@id") + .setParam("id",uid())); + return rows.isEmpty() ? NutMap.NEW().setv("id","").setv("name","") : rows.get(0); + } + + /** 预约类型 1 历史个人、2 分工会、3 协会,返回应进入的首个状态 ID。 */ public int firstState(Integer type) { if (Integer.valueOf(1).equals(type)) return UNION; - if (Integer.valueOf(2).equals(type)) return SCHOOL; + if (Integer.valueOf(2).equals(type)) return UNION; if (Integer.valueOf(3).equals(type)) return CLUB; throw new IllegalArgumentException("请选择有效的预约类型"); } /** 按节点角色及组织范围查实际审核人;不允许空组织退化为全校范围。 */ private List reviewers(int node, ActivitySiteReserve booking) { - String role = node == UNION ? "gh01" : node == CLUB ? "club01" : "sysadmin"; + String role = node == UNION ? "gh01" : node == CLUB ? "club01" : SCHOOL_REVIEW_ROLE; String filter = ""; if (node == UNION) { if (Strings.isBlank(booking.getReserve_person_unionid())) throw new IllegalArgumentException("申请人未关联分工会,请先完善所属分工会"); @@ -78,7 +89,7 @@ public class SiteBookingService extends BaseServiceImpl { List names = reviewers(node, booking).stream().map(v -> v.getString("loginname")) .filter(Strings::isNotBlank).distinct().collect(Collectors.toList()); if (names.isEmpty()) throw new IllegalArgumentException(node == UNION ? "所属分工会未配置可用的分工会主席" - : node == CLUB ? "所选协会未配置可用的协会会长" : "未配置可用的校工会审核人(系统管理员)"); + : node == CLUB ? "所选协会未配置可用的协会会长" : "未配置可用的校工会场地管理员"); return names; } @@ -102,6 +113,8 @@ public class SiteBookingService extends BaseServiceImpl { public String submit(ActivitySiteReserve form, List slots) { if (form == null || Strings.isBlank(form.getSite_id())) throw new IllegalArgumentException("请选择场地"); int first = firstState(form.getReserve_type()); + // 个人日期及共享容量规则按业务类型判断,不能随首审节点变更套用到分工会预约。 + boolean personal = Integer.valueOf(1).equals(form.getReserve_type()); if (slots == null || slots.isEmpty()) throw new IllegalArgumentException("请选择预约时段"); listMap(Sqls.create("select id from activity_site_info where id=@id for update").setParam("id", form.getSite_id())); ActivitySiteInfo site = dao().fetch(ActivitySiteInfo.class, form.getSite_id()); @@ -125,15 +138,25 @@ public class SiteBookingService extends BaseServiceImpl { assignments(first, form); assignments(SCHOOL, form); List open = Json.fromJsonAsList(NutMap.class, Json.toJson(site.getOpen_hours())); + // 全天候按每个日期连续选择时间单位,避免跨禁用间隔或拆成多段。 + if (Integer.valueOf(2).equals(site.getReserveTimeType())) { + Map> byDay=slots.stream().collect(Collectors.groupingBy(v->v.getString("day"))); + for(List selected:byDay.values()) { + selected.sort(Comparator.comparing(v->v.getString("start_time"))); + for(int i=1;i distinct = new HashSet<>(); for (NutMap slot : slots) { String day = slot.getString("day"), start = slot.getString("start_time"), end = slot.getString("end_time"); if (Strings.isBlank(day) || Strings.isBlank(start) || Strings.isBlank(end)) throw new IllegalArgumentException("预约日期和起止时间不能为空"); + siteInfoService.validateBookingSlot(site,day,start,end); LocalDate date = LocalDate.parse(day); LocalTime startTime = LocalTime.parse(start), endTime = "24:00".equals(end) ? LocalTime.MIDNIGHT : LocalTime.parse(end); if (!"24:00".equals(end) && !endTime.isAfter(startTime)) throw new IllegalArgumentException("结束时间必须晚于开始时间"); if (!LocalDateTime.of(date, startTime).isAfter(LocalDateTime.now())) throw new IllegalArgumentException("预约时段已过期"); - if (first == UNION && !date.equals(LocalDate.now().plusDays(1))) throw new IllegalArgumentException("个人预约只支持预约明天的时间"); + if (personal && !date.equals(LocalDate.now().plusDays(1))) throw new IllegalArgumentException("个人预约只支持预约明天的时间"); if (Boolean.TRUE.equals(site.getWorkday()) && date.getDayOfWeek().getValue() >= 6) throw new IllegalArgumentException("该场地仅支持工作日预约"); if (open == null || open.stream().noneMatch(v -> start.equals(v.getString("start_time")) && end.equals(v.getString("end_time")))) throw new IllegalArgumentException("所选时段不在场地开放场次中,请刷新重选"); @@ -141,9 +164,9 @@ public class SiteBookingService extends BaseServiceImpl { List existing = query(Cnd.where("site_id", "=", site.getId()).and("reserve_day", "=", day) .and("start_time", "<", end).and("end_time", ">", start).and("reserve_state", "not in", Arrays.asList(REJECT, CANCEL))); if (existing.stream().anyMatch(v -> uid().equals(v.getReserve_person_id()))) throw new IllegalArgumentException("您已预约该时间段"); - if (first != UNION && !existing.isEmpty()) throw new IllegalArgumentException("该时段已有预约,协会或单位预约需要空闲时段"); - if (first == UNION && (existing.stream().anyMatch(v -> !Integer.valueOf(1).equals(v.getReserve_type())) - || site.getLimitNum() == null || existing.size() >= site.getLimitNum())) throw new IllegalArgumentException("该时段预约人数已满或已被单位/协会预约"); + if (!personal && !existing.isEmpty()) throw new IllegalArgumentException("该时段已有预约,协会或分工会预约需要空闲时段"); + if (personal && (existing.stream().anyMatch(v -> !Integer.valueOf(1).equals(v.getReserve_type())) + || site.getLimitNum() == null || siteInfoService.occupiedCount(existing,start,end) >= site.getLimitNum())) throw new IllegalArgumentException("该时段预约人数已满或已被分工会/协会预约"); } String sqid = UUID.randomUUID().toString().replace("-", ""); for (NutMap slot : slots) { @@ -160,6 +183,7 @@ public class SiteBookingService extends BaseServiceImpl { sysLocalProcessService.startProcess("场地预约:" + site.getName(), process(form), title(first), uid(), "/platform/activity/site/reserve", "/mobile/activity/site/info/my"); createTask(first, form); + notifyBooking(form,first,null); return sqid; } @@ -173,9 +197,52 @@ public class SiteBookingService extends BaseServiceImpl { sysLocalProcessService.updateProcessNodeName(process(booking), title(node)); } + /** + * booking 为整条申请,state 为新待审节点或最终 PASS/REJECT,opinion 为最终审核意见(待审时为空)。 + * 返回 void;每次按 sqid 组织一条钉钉消息、接收人工号去重,复用消息开关与发送日志。 + * 只由首次提交和审核流转调用,撤回审核、新建恢复待办、撤销和删除不发送。 + */ + @Aop(TransAop.READ_COMMITTED) + private void notifyBooking(ActivitySiteReserve booking,int state,String opinion) { + try { + boolean result = state == PASS || state == REJECT; + List receivers; + if (result) { + Sys_user applicant = dao().fetch(Sys_user.class,booking.getReserve_person_id()); + receivers = applicant == null || Strings.isBlank(applicant.getLoginname()) + ? Collections.emptyList() : Collections.singletonList(applicant.getLoginname()); + } else receivers = assignments(state,booking); + if (receivers.isEmpty()) throw new IllegalArgumentException("消息接收人工号为空"); + ActivitySiteInfo site = dao().fetch(ActivitySiteInfo.class,booking.getSite_id()); + List slots = listMap(Sqls.create("select reserve_day,start_time,end_time from activity_site_reserve where sqid=@sqid") + .setParam("sqid",booking.getSqid())); + List groups = groupReservationTimes(slots); + // 长申请只列前五个日期,完整时段在业务页查看,避免超出消息长度限制。 + List days = new ArrayList<>(); + for (NutMap group : groups.stream().limit(5).collect(Collectors.toList())) { + List ranges = (List) group.get("ranges"); + String times = ranges.stream().limit(4).map(range -> range.getString("start") + "–" + range.getString("end")) + .collect(Collectors.joining("、")); + days.add(group.getString("date") + " " + times + (ranges.size()>4 ? "等时段" : "")); + } + String status = state == PASS ? "审核通过" : state == REJECT ? "审核拒绝" : "待" + title(state); + String content = "申请人:" + booking.getReserve_person() + ";场地:" + (site == null ? "" : site.getName()) + + ";预约时段:" + String.join(";",days) + (groups.size()>5 ? "等,共" + groups.size() + "天" : "") + + ";状态:" + status + (result && Strings.isNotBlank(opinion) ? ";审核意见:" + opinion : "") + + (result ? "。请点击查看我的预约。" : "。请点击进入审核页面处理。"); + String path = result ? "/mobile/activity/site/info/my" : "/mobile/activity/site/audit/" + stage(state); + String domain = Strings.sNull(io.v.nutz.web.commons.base.Globals.AppDomain).replaceAll("/+$",""); + msgApi.sendMsgInsertLog(Collections.singletonList("DingTalk"),receivers.stream().distinct().collect(Collectors.toList()), + 2,result ? "场地预约" + status : "场地预约审核通知",content,"",domain + path,"场地预约"); + } catch (Exception e) { + // 外部发送失败不回滚已完成的预约操作;记录申请编号以便排查,避免用户重试造成重复申请。 + org.nutz.log.Logs.get().error("场地预约钉钉通知失败,sqid=" + booking.getSqid() + ",state=" + state,e); + } + } + /** 返回指定审核入口的数据范围,分工会/协会使用角色关系上的组织字段。 */ private String scope(int node) { - if (node == SCHOOL) return "exists(select 1 from sys_user_role ur join sys_role r on r.id=ur.roleId where ur.userId=@uid and r.code='sysadmin')"; + if (node == SCHOOL) return "exists(select 1 from sys_user_role ur join sys_role r on r.id=ur.roleId where ur.userId=@uid and r.code='" + SCHOOL_REVIEW_ROLE + "')"; return "exists(select 1 from sys_user_role ur join sys_role r on r.id=ur.roleId where ur.userId=@uid and r.code='" + (node == UNION ? "gh01" : "club01") + "' and " + (node == UNION ? "ur.unionid=b.reserve_person_unionid" : "ur.stid=b.clubId") + ")"; @@ -210,16 +277,75 @@ public class SiteBookingService extends BaseServiceImpl { .setParam("uid", uid()).setParam("keyword", Strings.sNull(keyword)).setParam("like", "%" + Strings.sNull(keyword) + "%") .setParam("month", Strings.sNull(month)).setParam("typeId", Strings.sNull(typeId)); Pagination result = listPageMap(Math.max(1,page), Math.min(100,Math.max(1,size)), sql); + // 按当前页的申请一次读取原始时段,不依赖可能被数据库截断的group_concat。 + List sqids=result.>getList().stream().map(row->String.valueOf(row.get("sqid"))).collect(Collectors.toList()); + Map> slotsByBooking=new HashMap<>(); + if(!sqids.isEmpty()) { + List slots=listMap(Sqls.create("select sqid,reserve_day,start_time,end_time from activity_site_reserve where sqid in (@sqids)").setParam("sqids",sqids)); + slotsByBooking=slots.stream().collect(Collectors.groupingBy(row->row.getString("sqid"))); + } // 两端共用后端资格判断;真正撤回仍在加锁后复查,不能信任列表中的旧状态。 for (Map row : result.>getList()) { ActivitySiteReserve booking = fetch(String.valueOf(row.get("id"))); String reason = revokeReason(node, booking); row.put("canRevoke", reason == null); row.put("revokeReason", reason); + row.put("reservationTimeGroups",groupReservationTimes(slotsByBooking.getOrDefault(String.valueOf(row.get("sqid")),Collections.emptyList()))); } return result; } + /** + * pageNumber/pageSize 为分页参数,sql 为调用页已包含权限及筛选的分组查询,month 为可空的 YYYY-MM。 + * 返回原分页结构,list 每行补充 reservationTimeGroups(日期及连续时段)和去重后的 days;不扩大月份范围。 + */ + public Pagination reservationPage(int pageNumber, int pageSize, Sql sql, String month) { + Pagination page = listPage(pageNumber,pageSize,sql); + List rows = page.getList(); + if (rows.isEmpty()) return page; + List sqids = rows.stream().map(row -> row.getString("sqid")).collect(Collectors.toList()); + List slots = listMap(Sqls.create("select sqid,reserve_day,start_time,end_time from activity_site_reserve " + + "where sqid in (@sqids) and (@month='' or left(reserve_day,7)=@month)") + .setParam("sqids",sqids).setParam("month",Strings.sNull(month))); + Map> grouped = slots.stream().collect(Collectors.groupingBy(row -> row.getString("sqid"))); + // 仅批量读取当前页申请,避免逐条查询及 group_concat 长度限制导致的时段丢失。 + List> output = new ArrayList<>(); + for (Record row : rows) { + List groups = groupReservationTimes(grouped.getOrDefault(row.getString("sqid"),Collections.emptyList())); + // Record 会将新增键转成小写;转换后保留既有字段名及 Vue 所需的驼峰分组字段。 + Map item = new LinkedHashMap<>(row); + item.put("reservationTimeGroups",groups); + item.put("days",groups.size()); + output.add(item); + } + page.setList(output); + return page; + } + + /** + * slots为原始reserve_day/start_time/end_time记录,返回按日期排序的[{date,ranges:[{start,end}]}]。 + * 仅合并同日首尾相接的时段,跨日和有间隔的场次独立保留;不改变原始预约记录。 + */ + public List groupReservationTimes(List slots) { + List ordered=new ArrayList<>(slots); + ordered.sort(Comparator.comparing((NutMap row)->row.getString("reserve_day")) + .thenComparing(row->row.getString("start_time")).thenComparing(row->row.getString("end_time"))); + List groups=new ArrayList<>(); + String day=null; + List ranges=null; + for(NutMap slot:ordered) { + String date=slot.getString("reserve_day"),start=slot.getString("start_time"),end=slot.getString("end_time"); + if(!Objects.equals(day,date)) { + day=date;ranges=new ArrayList<>(); + groups.add(NutMap.NEW().setv("date",date).setv("ranges",ranges)); + } + NutMap previous=ranges.isEmpty() ? null : ranges.get(ranges.size()-1); + if(previous!=null && Objects.equals(previous.getString("end"),start))previous.setv("end",end); + else ranges.add(NutMap.NEW().setv("start",start).setv("end",end)); + } + return groups; + } + /** 公共展示字段,保留既有组件所需别名,并显示预约类型和所属协会。 */ private String baseSelect() { return "select b.*,u.loginname,u.sex,s.name site_name,c.name club_name,un.unionname union_name,st.stateName state_name,st.stateColor state_color,st.stateAuditType," @@ -245,8 +371,10 @@ public class SiteBookingService extends BaseServiceImpl { NutMap result = listMap(Sqls.create(baseSelect() + " where b.id=@id").setParam("id",id)).get(0); result.put("auditListTable",history); // 日历仅携带本次申请的时段,避免通过详情获取其他申请人的资料。 - result.put("site_info", listMap(Sqls.create("select b.*,st.stateAuditType from activity_site_reserve b left join " - + STATES + " st on st.stateId=b.reserve_state where b.sqid=@sqid").setParam("sqid",b.getSqid()))); + List slots=listMap(Sqls.create("select b.*,st.stateAuditType from activity_site_reserve b left join " + + STATES + " st on st.stateId=b.reserve_state where b.sqid=@sqid").setParam("sqid",b.getSqid())); + result.put("site_info",slots); + result.put("reservationTimeGroups",groupReservationTimes(slots)); return result; } @@ -260,6 +388,7 @@ public class SiteBookingService extends BaseServiceImpl { if (ids == null || ids.length == 0 || pass == null) throw new IllegalArgumentException("请选择申请及审核结果"); if (Strings.isBlank(opinion) || opinion.length() > 500) throw new IllegalArgumentException("请填写不超过500字的审核意见"); Set handled = new HashSet<>(); + List notifications = new ArrayList<>(); // 固定加锁顺序降低批量审核之间的死锁风险。 List ordered = Arrays.stream(ids).sorted().collect(Collectors.toList()); for (String id : ordered) { @@ -288,7 +417,11 @@ public class SiteBookingService extends BaseServiceImpl { if (next == SCHOOL) createTask(SCHOOL,b); else if (next == REJECT) sysLocalProcessService.refuseProcess(process(b),"审核拒绝,流程结束"); else sysLocalProcessService.completeProcess(process(b)); + b.setReserve_state(next); + notifications.add(b); } + // 整批审核业务处理完成后再通知,避免后续申请校验失败时提前发送前几条消息。 + for (ActivitySiteReserve booking : notifications) notifyBooking(booking,booking.getReserve_state(),opinion); } /** 返回不可撤回原因;仅最近一次有效审核的本人且仍拥有该组织审核资格可以撤回。 */ @@ -332,6 +465,8 @@ public class SiteBookingService extends BaseServiceImpl { throw new IllegalArgumentException("场地或场地类型已停用,不能恢复待审核预约"); assignments(node,booking); List slots = query(Cnd.where("sqid","=",booking.getSqid())); + // 恢复预约同样受最新开放配置与禁用时间约束,不复活已禁用的时段。 + for (ActivitySiteReserve slot : slots) siteInfoService.validateBookingSlot(site,slot.getReserve_day(),slot.getStart_time(),slot.getEnd_time()); if (Integer.valueOf(REJECT).equals(booking.getReserve_state())) { for (ActivitySiteReserve slot : slots) { List occupied = query(Cnd.where("site_id","=",booking.getSite_id()) @@ -341,7 +476,7 @@ public class SiteBookingService extends BaseServiceImpl { boolean conflict = Integer.valueOf(1).equals(booking.getReserve_type()) ? occupied.stream().anyMatch(row -> !Integer.valueOf(1).equals(row.getReserve_type()) || row.getReserve_person_id().equals(slot.getReserve_person_id())) - || site.getLimitNum() == null || occupied.size() >= site.getLimitNum() + || site.getLimitNum() == null || siteInfoService.occupiedCount(occupied,slot.getStart_time(),slot.getEnd_time()) >= site.getLimitNum() : !occupied.isEmpty(); if (conflict) throw new IllegalArgumentException("原预约时段已被占用或人数已满,不能撤回拒绝结果"); } @@ -378,6 +513,26 @@ public class SiteBookingService extends BaseServiceImpl { createTask(node,booking); } + /** + * id 为申请中任一时段的记录主键,返回 void;仅系统管理员可删除全部已撤销的申请。 + * 按 sqid 锁定并删除所有时段,关联流程和待办沿用系统删除标记,保留流程痕迹。 + */ + @Aop(TransAop.READ_COMMITTED) + public void deleteCancelled(String id) { + if (!ShiroUtil.hasRole("sysadmin")) throw new IllegalArgumentException("仅系统管理员可删除已撤销预约"); + ActivitySiteReserve booking = fetch(id); + if (booking == null) throw new IllegalArgumentException("预约不存在或已删除"); + if (Strings.isBlank(booking.getSqid())) throw new IllegalArgumentException("预约申请编号缺失,不能删除"); + List slots = listMap(Sqls.create("select id,reserve_state from activity_site_reserve where sqid=@sqid order by id for update") + .setParam("sqid",booking.getSqid())); + // 锁后再次检查整组状态,避免重复删除或误删同一申请中尚未撤销的时段。 + if (slots.isEmpty()) throw new IllegalArgumentException("预约不存在或已删除"); + if (slots.stream().anyMatch(slot -> slot.getInt("reserve_state") != CANCEL)) + throw new IllegalArgumentException("只能删除已撤销的预约"); + sysLocalProcessService.deleteProcessInstance(process(booking)); + dao().clear(ActivitySiteReserve.class,Cnd.where("sqid","=",booking.getSqid())); + } + /** 仅申请人可撤销尚未处理的首节点申请;保留取消状态及流程记录。 */ @Aop(TransAop.READ_COMMITTED) public void cancel(String id) { diff --git a/src/main/java/io/v/nutz/zhgh/activity/services/impl/SiteInfoServiceImpl.java b/src/main/java/io/v/nutz/zhgh/activity/services/impl/SiteInfoServiceImpl.java index 22b2ebb..43e3280 100644 --- a/src/main/java/io/v/nutz/zhgh/activity/services/impl/SiteInfoServiceImpl.java +++ b/src/main/java/io/v/nutz/zhgh/activity/services/impl/SiteInfoServiceImpl.java @@ -6,6 +6,17 @@ import io.v.nutz.zhgh.activity.models.ActivitySiteInfo; import io.v.nutz.zhgh.activity.services.SiteInfoService; import org.nutz.dao.Dao; import org.nutz.ioc.loader.annotation.IocBean; +import io.v.nutz.zhgh.activity.models.ActivityType; +import org.nutz.dao.Cnd; +import org.nutz.lang.Strings; +import org.nutz.ioc.aop.Aop; +import org.nutz.aop.interceptor.ioc.TransAop; +import java.util.*; +import java.time.*; +import org.nutz.json.Json; +import org.nutz.lang.util.NutMap; +import io.v.nutz.zhgh.activity.models.ActivitySiteReserve; +import io.v.nutz.web.commons.utils.ShiroUtil; /** * @author: Aaron @@ -18,4 +29,149 @@ public class SiteInfoServiceImpl extends BaseServiceImpl imple public SiteInfoServiceImpl(Dao dao) { super(dao); } + + /** + * form 包含名称、校区、地址、管理员、电话、人数、开放时段和开关;editing=true 时必须携带有效 id。 + * 返回 void;校区按 sys_dq 校验,typeId 不信任前端传值,避免隐藏类型选择后产生不可预约的场地。 + */ + @Aop(TransAop.READ_COMMITTED) + public void saveManagedSite(ActivitySiteInfo form, boolean editing) { + if (form == null || !"职工之家".equals(form.getName())) throw new IllegalArgumentException("场地名称请选择职工之家"); + if (Arrays.asList("青山湖科创中心", "不固定校区").contains(form.getCampus()) || Strings.isBlank(form.getCampus()) || form.getCampus().length() > 100 + || dao().count("sys_dq", Cnd.where("dq_name", "=", form.getCampus())) == 0) + throw new IllegalArgumentException("请选择有效校区"); + if (Strings.isBlank(form.getAddress()) || form.getAddress().length() > 100) + throw new IllegalArgumentException("请填写不超过100字的场地地址"); + if (Strings.isBlank(form.getContact_person()) || form.getContact_person().length() > 32) + throw new IllegalArgumentException("请填写不超过32字的场地管理员"); + if (Strings.isBlank(form.getContact_phone()) || form.getContact_phone().length() > 30) + throw new IllegalArgumentException("请填写不超过30字的联系方式"); + if (form.getLimitNum() == null || form.getLimitNum() < 1 || form.getLimitNum() > 100) + throw new IllegalArgumentException("限定人数应为1至100"); + if (form.getState() == null) throw new IllegalArgumentException("请选择开启状态"); + ActivitySiteInfo old = editing && Strings.isNotBlank(form.getId()) ? fetch(form.getId()) : null; + if (editing && old == null) throw new IllegalArgumentException("场地不存在,请刷新列表"); + List types = dao().query(ActivityType.class, + Cnd.where("meetingTypeName", "=", "职工之家").and("enabled", "=", true)); + if (types.size() != 1) throw new IllegalArgumentException("请配置唯一且启用的职工之家场地类型"); + // 保存和提交使用同一场地行锁,避免配置变更与预约提交并发穿透。 + if (editing) { + org.nutz.dao.sql.Sql lock = org.nutz.dao.Sqls.create("select id from activity_site_info where id=@id for update").setParam("id", form.getId()); + dao().execute(lock); + } + normalizeSchedule(form, editing); + form.setTypeId(types.get(0).getId()); + // 控件已隐藏:新增不限制性别,编辑保留原值,避免未展示的字段被请求篡改。 + form.setSexLimit(editing && old.getSexLimit()!=null ? old.getSexLimit() : 0); + if (editing) { + form.setCreate_username(old.getCreate_username()); + form.setCreate_time(old.getCreate_time()); + updateIgnoreNull(form); + } else { + form.setId(null); + insert(form); + } + } + + /** 严格解析分钟,24:00仅用于结束边界,不接受跨日或秒级配置。 */ + private int minute(String value, boolean end) { + if (end && "24:00".equals(value)) return 1440; + if (value == null || !value.matches("[0-2][0-9]:[0-5][0-9]")) throw new IllegalArgumentException("请填写有效的HH:mm时间"); + try { LocalTime t = LocalTime.parse(value); return t.getHour()*60+t.getMinute(); } + catch (RuntimeException e) { throw new IllegalArgumentException("时间必须在00:00至24:00之间"); } + } + + private String clockText(int value) { + return String.format("%02d:%02d",value/60,value%60); + } + + /** 同一分钟先合并结束和开始事件,首尾相接的预约不会重复占用。 */ + public int occupiedCount(List rows,String start,String end) { + SortedMap events=new TreeMap<>(); + int from=minute(start,false),to=minute(end,true),current=0,peak=0; + for(ActivitySiteReserve row:rows){ + int a=Math.max(from,minute(row.getStart_time(),false)),b=Math.min(to,minute(row.getEnd_time(),true)); + if(a ranges = mode==2 ? Collections.singletonList(form.getFullDayOpenHour()) : form.getSegmentedOpenHours(); + // 老场地第一次编辑没有独立配置时沿用原始场次,不擅自缩短预约时长。 + if (mode==1 && (ranges==null || ranges.isEmpty())) ranges=Json.fromJsonAsList(NutMap.class,Json.toJson(form.getOpen_hours())); + if (ranges==null || ranges.isEmpty()) throw new IllegalArgumentException("请配置至少一个开放时间段"); + List normalized=new ArrayList<>(), slots=new ArrayList<>(); + for (NutMap row:ranges) { + if(row==null) throw new IllegalArgumentException("请配置开放起止时间"); + int start=minute(row.getString("start_time"),false), end=minute(row.getString("end_time"),true); + if(end<=start) throw new IllegalArgumentException("开放结束时间必须晚于开始时间"); + int unit=end-start; + if(row.get("timeUnit")!=null) { + try { unit=Integer.parseInt(String.valueOf(row.get("timeUnit"))); } + catch(RuntimeException e){throw new IllegalArgumentException("预约时间单位必须为正整数分钟");} + } else if(mode==2) throw new IllegalArgumentException("请配置全天候预约时间单位"); + if(unit<1 || unit>end-start || (end-start)%unit!=0) throw new IllegalArgumentException("预约时间单位必须为正整数,且能整除开放时长"); + for(NutMap previous:normalized) if(startminute(previous.getString("start_time"),false)) + throw new IllegalArgumentException("分段场次不能互相重叠"); + normalized.add(NutMap.NEW().setv("start_time",clockText(start)).setv("end_time",clockText(end)).setv("timeUnit",unit)); + for(int cursor=start;cursorrow.getString("start_time"))); + if(mode==1)form.setSegmentedOpenHours(normalized);else form.setFullDayOpenHour(normalized.get(0)); + form.setOpen_hours(slots); + List disabled=form.getNotApplyTimeList()==null ? new ArrayList<>() : form.getNotApplyTimeList(); + for(NutMap row:disabled){ + if(row==null)throw new IllegalArgumentException("请填写完整的禁用时间"); + try{LocalDate.parse(row.getString("date"));}catch(RuntimeException e){throw new IllegalArgumentException("请选择有效的禁用日期");} + if(minute(row.getString("endTime"),true)<=minute(row.getString("startTime"),false))throw new IllegalArgumentException("禁用结束时间必须晚于开始时间"); + } + form.setNotApplyTimeList(disabled); + } + + /** 半开区间判定:结束恰好等于禁用开始允许预约,有实际交叉才拦截。 */ + public void validateBookingSlot(ActivitySiteInfo site,String day,String start,String end) { + LocalDate date; + try{date=LocalDate.parse(day);}catch(RuntimeException e){throw new IllegalArgumentException("请选择有效预约日期");} + int from=minute(start,false),to=minute(end,true); + if(to<=from)throw new IllegalArgumentException("预约结束时间必须晚于开始时间"); + if(Boolean.TRUE.equals(site.getWorkday()) && date.getDayOfWeek().getValue()>=6)throw new IllegalArgumentException("该场地仅支持工作日预约"); + List open=Json.fromJsonAsList(NutMap.class,Json.toJson(site.getOpen_hours())); + if(open==null || open.stream().noneMatch(row->start.equals(row.getString("start_time")) && end.equals(row.getString("end_time")))) + throw new IllegalArgumentException("所选时段不在当前开放场次中,请刷新重选"); + if(site.getNotApplyTimeList()!=null)for(NutMap range:site.getNotApplyTimeList()){ + if(day.equals(range.getString("date")) && fromminute(range.getString("startTime"),false)) + throw new IllegalArgumentException("所选时段与禁用时间冲突"); + } + } + + /** PC/H5共享可约判定,按重叠时段查占用,兼容历史整段预约与新拆分时段。 */ + public List availableSlots(String siteId,String day,Integer reserveType){ + ActivitySiteInfo site=fetch(siteId); + if(site==null || !Boolean.TRUE.equals(site.getState()))throw new IllegalArgumentException("场地不存在或已停用"); + LocalDate date=LocalDate.parse(day); + String uid=String.valueOf(ShiroUtil.getPrincipalProperty("id")); + List slots=Json.fromJsonAsList(NutMap.class,Json.toJson(site.getOpen_hours())); + if(slots==null)return new ArrayList<>(); + for(NutMap slot:slots){ + String start=slot.getString("start_time"),end=slot.getString("end_time"),reason=null; + List occupied=dao().query(ActivitySiteReserve.class,Cnd.where("site_id","=",siteId).and("reserve_day","=",day) + .and("start_time","<",end).and("end_time",">",start).and("reserve_state","not in",Arrays.asList(4040,4050))); + try{validateBookingSlot(site,day,start,end);}catch(IllegalArgumentException e){reason=e.getMessage();} + if(reason==null && !LocalDateTime.of(date,LocalTime.parse(start)).isAfter(LocalDateTime.now()))reason="预约时段已过期"; + if(reason==null && occupied.stream().anyMatch(row->uid.equals(row.getReserve_person_id())))reason="已预约"; + if(reason==null && (occupied.stream().anyMatch(row->!Integer.valueOf(1).equals(row.getReserve_type())) + || site.getLimitNum()==null || occupiedCount(occupied,start,end)>=site.getLimitNum() + || reserveType!=null && reserveType!=1 && !occupied.isEmpty()))reason="已约满"; + slot.setv("code",reason==null ? 1 : -2).setv("msg",reason==null ? "可预约" : reason) + .setv("limitNum",site.getLimitNum()).setv("reserveNum",occupiedCount(occupied,start,end)).setv("disabled",reason!=null) + .setv("backColor",reason==null ? "#e8ffef" : "").setv("color",reason==null ? "#52986a" : ""); + } + return slots; + } } diff --git a/src/main/java/io/v/nutz/zhgh/ghkh/controller/GhkhLssjcxController.java b/src/main/java/io/v/nutz/zhgh/ghkh/controller/GhkhLssjcxController.java index 7794324..1e50bf6 100644 --- a/src/main/java/io/v/nutz/zhgh/ghkh/controller/GhkhLssjcxController.java +++ b/src/main/java/io/v/nutz/zhgh/ghkh/controller/GhkhLssjcxController.java @@ -134,7 +134,8 @@ public class GhkhLssjcxController { if (ShiroUtil.hasAnyRoles("sysadmin,SchoolUnionAdmin")) { result.add(new NutMap().setv("title", title).setv("url", "/platform/ghkh/xghsh").setv("iconClass", vi.getIconByPath("/platform/ghkh/xghsh")).setv("label", "校工会审核").setv("number", getXghCount())); } - return result; + // 保留原有任务数据范围,再按当前账号的目标页面权限过滤入口。 + return io.v.nutz.sys.services.TodoAccessService.filter(result, "url"); } diff --git a/src/main/java/io/v/nutz/zhgh/jdh/controller/db/dbYxAgentController.java b/src/main/java/io/v/nutz/zhgh/jdh/controller/db/dbYxAgentController.java index 850d0ae..3186435 100644 --- a/src/main/java/io/v/nutz/zhgh/jdh/controller/db/dbYxAgentController.java +++ b/src/main/java/io/v/nutz/zhgh/jdh/controller/db/dbYxAgentController.java @@ -46,7 +46,8 @@ public class dbYxAgentController { if (ShiroUtil.hasAnyRoles("sysadmin,SchoolUnionAdmin")) { result.add(new NutMap().setv("title", title).setv("url", UNION).setv("iconClass", vi.getIconByPath(UNION)).setv("label", "待校工会审核").setv("number", getUnionCount())); } - return result; + // 保留原有任务数据范围,再按当前账号的目标页面权限过滤入口。 + return io.v.nutz.sys.services.TodoAccessService.filter(result, "url"); } diff --git a/src/main/java/io/v/nutz/zhgh/jf/controller/activitybx/ActivitybxAgentController.java b/src/main/java/io/v/nutz/zhgh/jf/controller/activitybx/ActivitybxAgentController.java index 9562a35..601fe46 100644 --- a/src/main/java/io/v/nutz/zhgh/jf/controller/activitybx/ActivitybxAgentController.java +++ b/src/main/java/io/v/nutz/zhgh/jf/controller/activitybx/ActivitybxAgentController.java @@ -80,7 +80,8 @@ public class ActivitybxAgentController { } } - return result; + // 保留原有任务数据范围,再按当前账号的目标页面权限过滤入口。 + return io.v.nutz.sys.services.TodoAccessService.filter(result, "url"); } diff --git a/src/main/java/io/v/nutz/zhgh/lxy/controller/LxyAgentController.java b/src/main/java/io/v/nutz/zhgh/lxy/controller/LxyAgentController.java index 1baf4ba..dbfb612 100644 --- a/src/main/java/io/v/nutz/zhgh/lxy/controller/LxyAgentController.java +++ b/src/main/java/io/v/nutz/zhgh/lxy/controller/LxyAgentController.java @@ -43,7 +43,8 @@ public class LxyAgentController { result.add(new NutMap().setv("title", title).setv("url", FGHSH).setv("iconClass", vi.getIconByPath(FGHSH)).setv("label", "待院级工会审核").setv("number", getFghshCount())); result.add(new NutMap().setv("title", title).setv("url", XGHSH).setv("iconClass", vi.getIconByPath(XGHSH)).setv("label", "待校工会审核").setv("number", getXghshCount().size())); } - return result; + // 保留原有任务数据范围,再按当前账号的目标页面权限过滤入口。 + return io.v.nutz.sys.services.TodoAccessService.filter(result, "url"); } private List getXghshCount() { diff --git a/src/main/java/io/v/nutz/zhgh/mainPage/controller/MainPageNeedItemsController.java b/src/main/java/io/v/nutz/zhgh/mainPage/controller/MainPageNeedItemsController.java index 0f1c156..cd414e7 100644 --- a/src/main/java/io/v/nutz/zhgh/mainPage/controller/MainPageNeedItemsController.java +++ b/src/main/java/io/v/nutz/zhgh/mainPage/controller/MainPageNeedItemsController.java @@ -171,8 +171,8 @@ public class MainPageNeedItemsController { @At @ViReturn @RequiresAuthentication - public Object getNeedItems() { - return needItemsService.getNeedItems(); + public Object getNeedItems(Boolean mobile) { + return needItemsService.getNeedItems(Boolean.TRUE.equals(mobile)); } } diff --git a/src/main/java/io/v/nutz/zhgh/mainPage/service/MainPageNeedItemsService.java b/src/main/java/io/v/nutz/zhgh/mainPage/service/MainPageNeedItemsService.java index 0828485..08fdb05 100644 --- a/src/main/java/io/v/nutz/zhgh/mainPage/service/MainPageNeedItemsService.java +++ b/src/main/java/io/v/nutz/zhgh/mainPage/service/MainPageNeedItemsService.java @@ -8,4 +8,6 @@ import java.util.List; public interface MainPageNeedItemsService extends ViService { List getNeedItems(); + /** mobile 指明跳转端,返回当前权限可访问且数量大于零的待办。 */ + List getNeedItems(boolean mobile); } diff --git a/src/main/java/io/v/nutz/zhgh/mainPage/service/impl/MainPageNeedItemsServiceImpl.java b/src/main/java/io/v/nutz/zhgh/mainPage/service/impl/MainPageNeedItemsServiceImpl.java index 2228044..66f31f5 100644 --- a/src/main/java/io/v/nutz/zhgh/mainPage/service/impl/MainPageNeedItemsServiceImpl.java +++ b/src/main/java/io/v/nutz/zhgh/mainPage/service/impl/MainPageNeedItemsServiceImpl.java @@ -27,7 +27,10 @@ public class MainPageNeedItemsServiceImpl extends ViServiceImpl imple } @Override - public List getNeedItems() { + public List getNeedItems() { return getNeedItems(false); } + + @Override + public List getNeedItems(boolean mobile) { //查询数据库中的待办事项 List needItemsList = dao().queryByJoin(NeedItems.class, "^needItemsSource$", Cnd.NEW()); @@ -91,6 +94,8 @@ public class MainPageNeedItemsServiceImpl extends ViServiceImpl imple List result = needItemsList.stream().filter(v -> Strings.isNotBlank(v.getHref()) && menus.stream().anyMatch(x -> Strings.isNotBlank(x.getHref()) && x.getHref().equals(v.getHref()))).distinct().collect(Collectors.toList()); - return result.stream().filter(v -> v.getCount() > 0).collect(Collectors.toList()); + return result.stream().filter(v -> v.getCount() > 0) + .filter(v -> io.v.nutz.sys.services.TodoAccessService.canAccess(mobile ? v.getMobileHref() : v.getHref())) + .collect(Collectors.toList()); } } diff --git a/src/main/java/io/v/nutz/zhgh/manuscript/controller/ManuscriptAgentController.java b/src/main/java/io/v/nutz/zhgh/manuscript/controller/ManuscriptAgentController.java index 0b6a875..1c093df 100644 --- a/src/main/java/io/v/nutz/zhgh/manuscript/controller/ManuscriptAgentController.java +++ b/src/main/java/io/v/nutz/zhgh/manuscript/controller/ManuscriptAgentController.java @@ -50,7 +50,8 @@ public class ManuscriptAgentController { Sys_user user = (Sys_user) ShiroUtil.getPrincipal(); List menus = Optional.ofNullable(user).orElse(new Sys_user()).getMenus(); - return result.stream().filter(v -> Strings.isNotBlank(v.getString("url")) && menus.stream().anyMatch(x -> Strings.isNotBlank(x.getHref()) && v.getString("url").contains(x.getHref()))).distinct().collect(Collectors.toList()); + // 保留原有任务数据范围,再按当前账号的目标页面权限过滤入口。 + return result.stream().filter(v -> Strings.isNotBlank(v.getString("url")) && menus.stream().anyMatch(x -> Strings.isNotBlank(x.getHref()) && v.getString("url").contains(x.getHref()))).filter(v -> io.v.nutz.sys.services.TodoAccessService.canAccess(v.getString("url"))).distinct().collect(Collectors.toList()); } diff --git a/src/main/java/io/v/nutz/zhgh/mobile/activity/site/SiteInfoMobileController.java b/src/main/java/io/v/nutz/zhgh/mobile/activity/site/SiteInfoMobileController.java index 6074475..7400f26 100644 --- a/src/main/java/io/v/nutz/zhgh/mobile/activity/site/SiteInfoMobileController.java +++ b/src/main/java/io/v/nutz/zhgh/mobile/activity/site/SiteInfoMobileController.java @@ -148,8 +148,7 @@ public class SiteInfoMobileController { info.name, info.address, `as`.stateAuditType, - count(sqid) days, - GROUP_CONCAT(reserve_day order by reserve_day asc) concat_day + count(distinct reserve_day) days from activity_site_reserve ar left join activity_site_info info on info.id = ar.site_id @@ -176,7 +175,8 @@ public class SiteInfoMobileController { sql.setCondition(cnd); - return siteInfoService.listPage(pageForm.getPageNumber(), pageForm.getPageSize(), sql); + return siteBookingService.reservationPage(pageForm.getPageNumber(), pageForm.getPageSize(), sql, + Boolean.TRUE.equals(timeSwitch) ? null : time); } @At("/getDetail") @@ -207,65 +207,9 @@ public class SiteInfoMobileController { @At("/getReserve") @Ok("json:full") @ViReturn - public Object getReserve(String siteId, String day) { - - //获取场地的开放时间 - ActivitySiteInfo fetch = siteInfoService.fetch(siteId); - List hours = fetch.getOpen_hours(); - Integer limitNum = fetch.getLimitNum(); - - hours.forEach(item -> { - String startTime = item.getString("start_time"); - String endTime = item.getString("end_time"); - item.put("code", 1); - item.put("msg", "可预约"); - item.put("backColor", "#e8ffef"); - item.put("color", "#52986a"); - item.put("limitNum", limitNum); - //如果这个时间段过了,显示已过期 - String time = day + " " + startTime; - int compare = DateUtil.compare(new Date(), DateUtil.parse(time), "yyyy-MM-dd HH:mm"); - if(compare > 0) { - item.put("code", -1); - item.put("msg", "已过期"); - item.put("backColor", ""); - item.put("color", ""); - } - - List list = siteReserveService.query(Cnd.NEW() - .and("reserve_day", "=", day) - .and("start_time", "=", startTime) - .and("end_time", "=", endTime) - .and("site_id", "=", siteId) - .and("reserve_state", "not in", java.util.Arrays.asList(4040,4050))); - - //如果这个时间段被单位预约了,直接显示约满 - ActivitySiteReserve reserve1 = list.stream().filter(o -> o.getReserve_type() != 1).findAny().orElse(null); - if(reserve1 != null) { - item.put("code", -2); - item.put("msg", "已约满"); - item.put("backColor", ""); - item.put("color", ""); - } - //如果这个时间段预约的人数满了,显示约满 - if(list.size() >= limitNum) { - item.put("code", -1); - item.put("msg", "已约满"); - item.put("backColor", ""); - item.put("color", ""); - } - //如果预约过了,显示已预约 - ActivitySiteReserve reserve = list.stream().filter(o -> o.getReserve_person_id().equals(ShiroUtil.getPrincipalProperty("id"))).findAny().orElse(null); - if(reserve != null && reserve.getReserve_type() != 2) { - item.put("code", -1); - item.put("msg", "已预约"); - item.put("backColor", "#0e78c5"); - item.put("color", "#fff3f3"); - } - item.put("reserveNum", list.size()); - }); - - return hours; + /** siteId为场地,day为日期,reserveType为1个人/2单位/3协会;返回带code/msg的可预约场次数组。 */ + public Object getReserve(String siteId, String day, Integer reserveType) { + return siteInfoService.availableSlots(siteId,day,reserveType); } @At("/reserveDo") diff --git a/src/main/java/io/v/nutz/zhgh/staffmanage/member/controller/MemberAgendaController.java b/src/main/java/io/v/nutz/zhgh/staffmanage/member/controller/MemberAgendaController.java index 65ddca8..c730aca 100644 --- a/src/main/java/io/v/nutz/zhgh/staffmanage/member/controller/MemberAgendaController.java +++ b/src/main/java/io/v/nutz/zhgh/staffmanage/member/controller/MemberAgendaController.java @@ -109,6 +109,7 @@ public class MemberAgendaController { Sys_user user = (Sys_user) ShiroUtil.getPrincipal(); List menus = Optional.ofNullable(user).orElse(new Sys_user()).getMenus(); - return list.stream().filter(v -> Strings.isNotBlank(v.getString("url")) && menus.stream().anyMatch(x -> Strings.isNotBlank(x.getHref()) && v.getString("url").contains(x.getHref()))).distinct().collect(Collectors.toList()); + // 保留原有任务数据范围,再按当前账号的目标页面权限过滤入口。 + return list.stream().filter(v -> Strings.isNotBlank(v.getString("url")) && menus.stream().anyMatch(x -> Strings.isNotBlank(x.getHref()) && v.getString("url").contains(x.getHref()))).filter(v -> io.v.nutz.sys.services.TodoAccessService.canAccess(v.getString("url"))).distinct().collect(Collectors.toList()); } } diff --git a/src/main/java/io/v/nutz/zhgh/staffmanage/member/controller/MemberSelfAgentController.java b/src/main/java/io/v/nutz/zhgh/staffmanage/member/controller/MemberSelfAgentController.java index 13a6bc7..3aa5106 100644 --- a/src/main/java/io/v/nutz/zhgh/staffmanage/member/controller/MemberSelfAgentController.java +++ b/src/main/java/io/v/nutz/zhgh/staffmanage/member/controller/MemberSelfAgentController.java @@ -56,7 +56,8 @@ public class MemberSelfAgentController { .addv("mobileHref", "/mobile/member/edit?taskId=" + v.getId())); }); } - return list; + // 保留原有任务数据范围,再按当前账号的目标页面权限过滤入口。 + return io.v.nutz.sys.services.TodoAccessService.filter(list, "mobileHref"); } diff --git a/src/main/java/io/v/nutz/zhgh/staffmanage/member/handle/MemberApplyToDoHandler.java b/src/main/java/io/v/nutz/zhgh/staffmanage/member/handle/MemberApplyToDoHandler.java index 68842df..bcae7d4 100644 --- a/src/main/java/io/v/nutz/zhgh/staffmanage/member/handle/MemberApplyToDoHandler.java +++ b/src/main/java/io/v/nutz/zhgh/staffmanage/member/handle/MemberApplyToDoHandler.java @@ -9,6 +9,8 @@ import io.v.nutz.zhgh.staffmanage.member.model.MemberApplyRecord; import io.v.nutz.zhgh.staffmanage.member.service.MemberCommonService; import io.v.nutz.zhgh.staffmanage.member.service.impl.MemberCommonServiceImpl; import lombok.Getter; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.dao.Sqls; @@ -24,6 +26,7 @@ import java.util.stream.Collectors; * @name:MemberApplyController * @Date 2025/1/20 19:01 * @注释 会员申请流程 + * 手机处理和查看地址必须指向 /h5;PC 页面依赖不能在手机 PJAX 容器内加载。 */ @Getter @@ -34,6 +37,7 @@ public enum MemberApplyToDoHandler { */ START_PROCESS() { @Override + @Aop(TransAop.READ_COMMITTED) public void exec(MemberApplyRecord record, NutMap extra) { User user = dao.fetch(User.class, Cnd.where("id", "=", record.getUserId())); localProcessService.startProcess( @@ -42,7 +46,7 @@ public enum MemberApplyToDoHandler { "", record.getUserId(), "/platform/member/apply/mine", - "/platform/member/apply/mine" + "/platform/member/apply/mine/h5" ); } }, @@ -53,6 +57,7 @@ public enum MemberApplyToDoHandler { */ CREATE_APPLY_RE_MODIFY_TASK() { @Override + @Aop(TransAop.READ_COMMITTED) public void exec(MemberApplyRecord record, NutMap extra) { Sql sql = Sqls.fetchString(Sqls.create("select loginname from sys_user where id = @id").setParam("id", record.getUserId()).toString()); dao.execute(sql); @@ -66,8 +71,8 @@ public enum MemberApplyToDoHandler { List.of(loginname), "/platform/member/apply/mine", "/platform/member/apply/mine", - "/platform/member/apply/mine", - "/platform/member/apply/mine" + "/platform/member/apply/mine/h5", + "/platform/member/apply/mine/h5" ); localProcessService.updateProcessNodeName("MEMBER_APPLY@" + record.getId(), "退回重新修改"); @@ -98,6 +103,7 @@ public enum MemberApplyToDoHandler { */ CREATE_SCHOOL_TASK() { @Override + @Aop(TransAop.READ_COMMITTED) public void exec(MemberApplyRecord record, NutMap extra) { // 获取校工会会员管理员 List schoolLeaderLoginNames = memberService.getSchoolOrBranchUnionMemberAdminLoginNames("school", record.getUserId()); @@ -109,8 +115,8 @@ public enum MemberApplyToDoHandler { schoolLeaderLoginNames, "/platform/member/apply/schoolUnion/audit", "/platform/member/apply/schoolUnion/audit", - "/platform/member/apply/schoolUnion/audit", - "/platform/member/apply/schoolUnion/audit" + "/platform/member/apply/schoolUnion/audit/h5", + "/platform/member/apply/schoolUnion/audit/h5" ); localProcessService.updateProcessNodeName("MEMBER_APPLY@" + record.getId(), "校工会审核"); @@ -141,6 +147,7 @@ public enum MemberApplyToDoHandler { */ CREATE_UNION_TASK() { @Override + @Aop(TransAop.READ_COMMITTED) public void exec(MemberApplyRecord record, NutMap extra) { List unionLeaderLoginNames = extra.getAsList("unionLeaderLoginNames", String.class); localProcessService.createTask( @@ -151,8 +158,8 @@ public enum MemberApplyToDoHandler { unionLeaderLoginNames, "/platform/member/apply/branchUnion/audit", "/platform/member/apply/branchUnion/audit", - "/platform/member/apply/branchUnion/audit", - "/platform/member/apply/branchUnion/audit" + "/platform/member/apply/branchUnion/audit/h5", + "/platform/member/apply/branchUnion/audit/h5" ); localProcessService.updateProcessNodeName("MEMBER_APPLY@" + record.getId(), "分工会审核"); } diff --git a/src/main/java/io/v/nutz/zhgh/welfare/controller/WelfareAgendaController.java b/src/main/java/io/v/nutz/zhgh/welfare/controller/WelfareAgendaController.java index a92c669..fa4e179 100644 --- a/src/main/java/io/v/nutz/zhgh/welfare/controller/WelfareAgendaController.java +++ b/src/main/java/io/v/nutz/zhgh/welfare/controller/WelfareAgendaController.java @@ -39,7 +39,8 @@ public class WelfareAgendaController { public Object getAgenda() { List list = new ArrayList<>(); if (!ShiroUtil.hasAnyRoles("flwyh01,sysadmin")) { - return list; + // 保留原有任务数据范围,再按当前账号的目标页面权限过滤入口。 + return io.v.nutz.sys.services.TodoAccessService.filter(list, "url"); } int welfareMemberAuditNumber = service.count(Sqls.create("SELECT count(1) from welfare_member_apply_record WHERE stateId = @state").setParam("state", WelfareMemberApplyState.SCHOOL_UNION)); @@ -48,6 +49,7 @@ public class WelfareAgendaController { // int welfareMemberChangeAuditNumber = service.count(Sqls.create("SELECT count(1) from member_change_record WHERE recordMode = @mode and stateId = @state").setParam("mode", MemberChangeRecordMode.WELFARE_MEMBER.getCode()).setParam("state", MemberChangeApplyState.SCHOOL_UNION)); // list.add(new NutMap().addv("iconClass", vi.getIconByPath("/platform/welfare/member/change/audit")).addv("label", "福利会员变更审核").addv("number", welfareMemberChangeAuditNumber).addv("url", "/platform/welfare/member/change/audit")); - return list; + // 保留原有任务数据范围,再按当前账号的目标页面权限过滤入口。 + return io.v.nutz.sys.services.TodoAccessService.filter(list, "url"); } } diff --git a/src/main/java/io/v/nutz/zhgh/welfare/controller/WelfareSelfAgentController.java b/src/main/java/io/v/nutz/zhgh/welfare/controller/WelfareSelfAgentController.java index 91041b8..adc5915 100644 --- a/src/main/java/io/v/nutz/zhgh/welfare/controller/WelfareSelfAgentController.java +++ b/src/main/java/io/v/nutz/zhgh/welfare/controller/WelfareSelfAgentController.java @@ -53,6 +53,7 @@ public class WelfareSelfAgentController { .addv("mobileHref", "/mobile/member/edit?taskId=" + v.getId())); }); } - return list; + // 保留原有任务数据范围,再按当前账号的目标页面权限过滤入口。 + return io.v.nutz.sys.services.TodoAccessService.filter(list, "mobileHref"); } } diff --git a/src/main/java/io/v/nutz/zhgh/xhkh/controller/XhkhLssjcxController.java b/src/main/java/io/v/nutz/zhgh/xhkh/controller/XhkhLssjcxController.java index 4a6c512..9c37ae5 100644 --- a/src/main/java/io/v/nutz/zhgh/xhkh/controller/XhkhLssjcxController.java +++ b/src/main/java/io/v/nutz/zhgh/xhkh/controller/XhkhLssjcxController.java @@ -150,7 +150,8 @@ public class XhkhLssjcxController { if (ShiroUtil.hasAnyRoles("sysadmin,SchoolUnionAdmin")) { result.add(new NutMap().setv("title", title).setv("url", "/platform/xhkh/xghsh").setv("iconClass", vi.getIconByPath("/platform/xhkh/xghsh")).setv("label", "校工会审核").setv("number", getXghCount())); } - return result; + // 保留原有任务数据范围,再按当前账号的目标页面权限过滤入口。 + return io.v.nutz.sys.services.TodoAccessService.filter(result, "url"); } diff --git a/src/main/java/io/v/nutz/zhgh/zgfw/controller/condolence/CondolenceAgentController.java b/src/main/java/io/v/nutz/zhgh/zgfw/controller/condolence/CondolenceAgentController.java index fc80994..b1cd13a 100644 --- a/src/main/java/io/v/nutz/zhgh/zgfw/controller/condolence/CondolenceAgentController.java +++ b/src/main/java/io/v/nutz/zhgh/zgfw/controller/condolence/CondolenceAgentController.java @@ -59,7 +59,8 @@ public class CondolenceAgentController { result.add(new NutMap().setv("title", title).setv("url", ECR).setv("iconClass", vi.getIconByPath(ECR)).setv("label", "待工会常务副主席审核").setv("number", getEcrCount())); Sys_user user = (Sys_user) ShiroUtil.getPrincipal(); List menus = Optional.ofNullable(user).orElse(new Sys_user()).getMenus(); - return result.stream().filter(v -> Strings.isNotBlank(v.getString("url")) && menus.stream().anyMatch(x -> Strings.isNotBlank(x.getHref()) && v.getString("url").equals(x.getHref()))).distinct().collect(Collectors.toList()); + // 保留原有任务数据范围,再按当前账号的目标页面权限过滤入口。 + return result.stream().filter(v -> Strings.isNotBlank(v.getString("url")) && menus.stream().anyMatch(x -> Strings.isNotBlank(x.getHref()) && v.getString("url").equals(x.getHref()))).filter(v -> io.v.nutz.sys.services.TodoAccessService.canAccess(v.getString("url"))).distinct().collect(Collectors.toList()); } diff --git a/src/main/java/io/v/nutz/zhgh/zgfw/controller/rxdj/rxdjAgentController.java b/src/main/java/io/v/nutz/zhgh/zgfw/controller/rxdj/rxdjAgentController.java index 4e34c69..d5a3ecf 100644 --- a/src/main/java/io/v/nutz/zhgh/zgfw/controller/rxdj/rxdjAgentController.java +++ b/src/main/java/io/v/nutz/zhgh/zgfw/controller/rxdj/rxdjAgentController.java @@ -64,7 +64,8 @@ public class rxdjAgentController { } } - return result; + // 保留原有任务数据范围,再按当前账号的目标页面权限过滤入口。 + return io.v.nutz.sys.services.TodoAccessService.filter(result, "url"); } public Integer getUnitCount() { diff --git a/src/main/java/io/v/nutz/zhgh/zgfw/controller/singleChild/singleChildAgentController.java b/src/main/java/io/v/nutz/zhgh/zgfw/controller/singleChild/singleChildAgentController.java index dce56da..5581dad 100644 --- a/src/main/java/io/v/nutz/zhgh/zgfw/controller/singleChild/singleChildAgentController.java +++ b/src/main/java/io/v/nutz/zhgh/zgfw/controller/singleChild/singleChildAgentController.java @@ -54,7 +54,8 @@ public class singleChildAgentController { result.add(new NutMap().setv("url", SCHOOL).setv("iconClass", vi.getIconByPath(SCHOOL)).setv("label", "独生子女待工会主席审核").setv("number", getSchoolCount())); } } - return result; + // 保留原有任务数据范围,再按当前账号的目标页面权限过滤入口。 + return io.v.nutz.sys.services.TodoAccessService.filter(result, "url"); } diff --git a/src/main/resources/static/components/activity/site/ReserveInfo.vue b/src/main/resources/static/components/activity/site/ReserveInfo.vue index 50e234b..c2ba0a0 100644 --- a/src/main/resources/static/components/activity/site/ReserveInfo.vue +++ b/src/main/resources/static/components/activity/site/ReserveInfo.vue @@ -33,7 +33,7 @@ - {{ {1:'个人预约',2:'单位预约',3:'协会预约'}[viewData.reserve_type] }} + {{ {1:'个人预约',2:'分工会预约',3:'协会预约'}[viewData.reserve_type] }} {{viewData.club_name || '—'}} {{ viewData.reserve_person }} {{ viewData.site_name }} @@ -41,8 +41,19 @@ {{ viewData.loginname }} {{ viewData.reserve_person_unit }} {{ viewData.reserve_person_phone }} - ({{ viewData.concat_day }}) {{ viewData.start_time }} - {{ viewData.end_time }} - + + +
+
+ {{group.date}} +
+ {{range.start}}–{{range.end}} +
+
+ +
+
+ {{viewData.state_name}} {{viewData.state_name}} {{viewData.state_name}} @@ -146,4 +157,33 @@ module.exports = { .el-descriptions-item__cell { text-align: center !important; } +/* 日期与完整时段整体居中,长时段列表仅在各时间段之间换行。 */ +.reservation-times { + display: inline-block; + max-width: 100%; + text-align: left; + font-variant-numeric: tabular-nums; + vertical-align: middle; +} +.reservation-time-day { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 4px 12px; + padding: 3px 0; + line-height: 22px; +} +.reservation-time-date { + flex: 0 0 88px; + white-space: nowrap; + font-weight: 500; +} +.reservation-time-ranges { + display: flex; + flex-wrap: wrap; + gap: 2px 12px; +} +.reservation-time-range { + white-space: nowrap; +} diff --git a/src/main/resources/views/mobile/activity/site/audit.html b/src/main/resources/views/mobile/activity/site/audit.html index cc23433..da6f132 100644 --- a/src/main/resources/views/mobile/activity/site/audit.html +++ b/src/main/resources/views/mobile/activity/site/audit.html @@ -1,5 +1,15 @@ +
@@ -250,10 +258,14 @@ layout("/mobile/platform.html"){ - 预约时间: - -
{{getTime(o, item)}}
-
+ 预约时间: +
+
+
{{group.date}}
+
{{range.start}}–{{range.end}}
+
+ +
@@ -265,10 +277,10 @@ layout("/mobile/platform.html"){ {{o.statename}} - + 审核意见: - {{JSON.parse(o.auditlist).find(x => x.auditState === false).auditOption}} + {{rejectionOpinion(o)}}
@@ -364,6 +376,16 @@ layout("/mobile/platform.html"){ $(document).off('pjax:beforeReplace.siteList', this._siteListDispose) }, methods: { + // 历史审核记录可能是 JSON 字符串、数组或空值;仅取最近一条拒绝意见,缺失时不阻断整页渲染。 + rejectionOpinion(row) { + let history = row.auditlist || row.auditList || [] + if (typeof history === 'string') { + try { history = JSON.parse(history) } catch (error) { return '—' } + } + if (!Array.isArray(history)) return '—' + const rejection = history.slice().reverse().find(item => item && item.auditState === false) + return rejection && rejection.auditOption || '—' + }, setPopupState(key, value) { this.$set(this, key, value) }, // 列表由 PJAX replace 进入,导航返回明确回首页;有弹层时只关最上层。 goBack() { @@ -427,11 +449,6 @@ layout("/mobile/platform.html"){ backDo() { if(this.formLoading)return;if(!this.option.trim()){vant.Toast('请填写反馈意见');return}this.formLoading=true;$.post('/mobile/activity/site/info/backOption',{id:this.info.id,option:this.option}).then((res)=>{vant.Toast(res.msg);if(res.code===0)this.clearPopupHistory(()=>this.doSearch())}).always(()=>{this.formLoading=false}); }, - getTime(o, day) { - const week = new Date(day).getDay() - const arr = ['日', '一', '二', '三', '四', '五', '六'] - return moment(day).format('MM月DD日') + ' 周' + arr[week] + ' ' + o.start_time + '-' + o.end_time - }, doSearch() { this.pageForm.pageNumber = 1 this.tableData = [] @@ -439,11 +456,27 @@ layout("/mobile/platform.html"){ this.onLoad() }, onLoad() { + // 类型未加载成功时停止请求,避免筛选操作意外查询其他场地类型。 + if (this.pageForm.typeId == null) { this.$set(this,'loading',false); return } this.loading=true;return $.post('/mobile/activity/site/info/myReserve',this.pageForm).then((res)=>{if(res.code===0){this.tableData=this.tableData.concat(res.data.list);this.finished=this.tableData.length>=res.data.totalCount;if(!this.finished)this.pageForm.pageNumber++}else{vant.Toast(res.msg)}}).always(()=>{this.loading=false}); }, }, created() { - siteTypeUtil.getAllType().then((rows)=>{this.typeList=[{text:'全部场地类型'}].concat(rows);this.$set(this.pageForm,'typeId',this.typeList[0].value);this.timeList=[{text:'全部',value:'全部'}];this.$set(this.pageForm,'time','全部');this.$set(this.pageForm,'timeSwitch',true);this.onLoad()}); + // 本页只查询职工之家,使用接口返回的实际类型 ID;类型缺失时不降级成全部类型查询。 + siteTypeUtil.getAllType().then((rows) => { + const types = rows + this.$set(this, 'typeList', types) + if (!types.length) { + this.$set(this, 'finished', true) + vant.Toast('未配置职工之家场地类型') + return + } + this.$set(this.pageForm, 'typeId', types[0].value) + this.$set(this, 'timeList', [{text:'全部',value:'全部'}]) + this.$set(this.pageForm, 'time', '全部') + this.$set(this.pageForm, 'timeSwitch', true) + this.onLoad() + }, () => { vant.Toast('场地类型加载失败,请重新进入') }); } }) } diff --git a/src/main/resources/views/mobile/activity/site/reserve.html b/src/main/resources/views/mobile/activity/site/reserve.html index 63f70ad..68beebb 100644 --- a/src/main/resources/views/mobile/activity/site/reserve.html +++ b/src/main/resources/views/mobile/activity/site/reserve.html @@ -23,26 +23,31 @@ layout("/mobile/platform.html"){ font-size: 14px; } - .time { - width: 45%; + .site-reserve-slots .time { + width: 50%; text-align: center; + white-space: nowrap; } - .num { - width: 30%; - text-align: center; - } - - .state { - width: 80px; - height: 36px; + /* 长禁用原因由内容撑开行高,避免固定高度导致文字覆盖下一场次。 */ + .site-reserve-slots .state { + width: 100%; + min-height: 36px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + padding: 6px 8px; + line-height: 20px; + white-space: normal; + overflow-wrap: anywhere; text-align: center; background-color: #f2f2f2; border-radius: 2px; color: #929292; } - .container { + .site-reserve-slots { padding: 10px 0px; background-color: white; width: 95%; @@ -51,8 +56,16 @@ layout("/mobile/platform.html"){ margin-top: 10px; } - tr { - line-height: 36px; + .site-reserve-slots table { + width: 100%; + table-layout: fixed; + border-collapse: separate; + border-spacing: 0 4px; + } + + .site-reserve-slots td { + padding: 0 6px; + vertical-align: middle; } .van-action-sheet__content { @@ -182,21 +195,14 @@ layout("/mobile/platform.html"){ -
+
-
{{item.start_time + '-' + item.end_time}} - - {{(item.limitNum - item.reserveNum) + '/' + item.limitNum}} - - - {{0 + '/' + item.limitNum}} - -
- {{item.msg}} + + {{item.msg === '预约时段已过期' ? '已过期' : item.msg}}
@@ -231,8 +237,10 @@ layout("/mobile/platform.html"){ {{site.name}} - - + + +
您暂无已通过入会审核的有效协会
@@ -252,6 +260,7 @@ layout("/mobile/platform.html"){ rows="4" autosize label="预约事由" + required type="textarea" maxlength="50" placeholder="请输入预约事由" @@ -301,9 +310,12 @@ layout("/mobile/platform.html"){ return { historyPopupKeys: ['show','calendarVisible','optionsShow'], optionsShow: false, optionKind: 'type', - reserveTypeOptions: [{text:'个人预约',value:1},{text:'单位预约',value:2},{text:'协会预约',value:3}], + // 新申请仅开放分工会和协会,个人类型保留用于历史数据。 + reserveTypeOptions: [{text:'分工会预约',value:2},{text:'协会预约',value:3}], + bookingUnionName:'', unionLoading:false, myClubs: [], clubId: '', formLoading: false, - reserve_type: 1, + // 新预约须主动选择类型,初始不选中任何选项。 + reserve_type: null, person: "${@shiro.getPrincipalProperty('username')}", unit: "${@shiro.getPrincipalProperty('unit').getName()}", phone: "${@shiro.getPrincipalProperty('mobile')}", @@ -312,6 +324,7 @@ layout("/mobile/platform.html"){ site_id: '', site: {}, timeData: [], + slotRequestVersion:0, slotsLoading:false, times: [], weekList: [], joinUser: '', @@ -319,8 +332,16 @@ layout("/mobile/platform.html"){ selectDate: null, } }, + watch: { + // 分工会与协会预约的时段占用规则一致,切换时保留跨日期选择,仅刷新场次状态;提交时仍由后端校验冲突。 + reserve_type() { this.getReserve() } + }, computed: { - reserveTypeName() { return this.reserveTypeOptions.find(option => option.value===this.reserve_type).text }, + reserveTypeName() { + // 未选择时显示提示,避免读取不存在的选项名称。 + const option = this.reserveTypeOptions.find(item => item.value===this.reserve_type) + return option ? option.text : '请选择预约类型' + }, selectedClubName() { const club = this.myClubs.find(item => item.id===this.clubId) return club ? club.name : '请选择所属协会' @@ -367,6 +388,7 @@ layout("/mobile/platform.html"){ // PJAX 替换页面时显式销毁,触发统一历史 mixin 的监听注销。 this._reserveDispose = () => this.$destroy() $(document).one('pjax:beforeReplace.siteReserve', this._reserveDispose) + this.loadBookingUnion() $.post('/platform/activity/site/reserve/myClubs').then((res)=>{ if(res.code===0){this.$set(this,'myClubs',res.data);this.$set(this,'clubId',res.data.length===1 ? res.data[0].id : '')} else{vant.Toast(res.msg)} @@ -374,8 +396,20 @@ layout("/mobile/platform.html"){ }, beforeDestroy() { $(document).off('pjax:beforeReplace.siteReserve', this._reserveDispose) + this.$set(this,'slotRequestVersion',this.slotRequestVersion+1) }, methods: { + // 与PC共用所属分工会接口,页面销毁后的响应不再更新界面。 + loadBookingUnion() { + this.$set(this,'unionLoading',true) + $.post('/platform/activity/site/reserve/myUnion').then(res=>{ + if(this._isDestroyed || this._isBeingDestroyed)return + if(res && res.code===0)this.$set(this,'bookingUnionName',res.data && res.data.name || '') + else vant.Toast(res && res.msg || '所属分工会加载失败') + },()=>{if(!this._isDestroyed && !this._isBeingDestroyed)vant.Toast('所属分工会加载失败,请重新进入')}).always(()=>{ + if(!this._isDestroyed && !this._isBeingDestroyed)this.$set(this,'unionLoading',false) + }) + }, // type 显示预约类型,club 仅在协会预约时可选;不修改所属协会的成员校验规则。 openOptions(kind) { if (this.formLoading || this.optionsShow || kind==='club' && this.reserve_type!==3) return @@ -442,6 +476,19 @@ layout("/mobile/platform.html"){ this.show = true }, reserveDo() { + if(this.slotsLoading || this.formLoading)return + if(!this.times.length){vant.Toast('请选择预约时段');return} + if(Number(this.site.reserveTimeType || 1)===2){ + const days=Array.from(new Set(this.times.map(row=>row.day))) + const gaps=days.some(day=>{ + const selected=this.times.filter(row=>row.day===day).slice().sort((a,b)=>a.start_time.localeCompare(b.start_time)) + return selected.some((row,i)=>i>0 && selected[i-1].end_time!==row.start_time) + }) + if(gaps){vant.Toast('全天候预约请选择连续时段');return} + } + // 类型必须为当前可选项,事由不能为空或仅含空白;校验通过后才发送预约请求。 + if(!this.reserveTypeOptions.some(option => option.value===this.reserve_type)){vant.Toast('请选择预约类型');return} + if(!this.message || !this.message.trim()){vant.Toast('请填写预约事由');return} if(this.reserve_type===3 && !this.clubId){vant.Toast('请选择所属协会');return} this.formLoading=true; $.post('/mobile/activity/site/info/reserveDo',{siteId:this.site_id,times:JSON.stringify(this.times),message:this.message,reserve_type:this.reserve_type,clubId:this.clubId}).then((res)=>{ @@ -450,6 +497,7 @@ layout("/mobile/platform.html"){ }).always(()=>{this.formLoading=false}) }, chooseTime(item) { + if(this.slotsLoading || item.code!==1)return const cloneItem = clone(item) const map = this.times.map(o => o.fullDay) const index = map.indexOf(this.selectDate.format('YYYY-MM-DD') + ' ' + cloneItem.start_time + '-' + cloneItem.end_time) @@ -467,7 +515,19 @@ layout("/mobile/platform.html"){ }, getReserve() { - return $.post('/mobile/activity/site/info/getReserve',{siteId:this.site_id,day:moment(this.selectDate).format('YYYY-MM-DD')}).then((res)=>{if(res.code===0){this.timeData=res.data}else{vant.Toast(res.msg)}}); + if(!this.site_id || !this.selectDate)return + const version=this.slotRequestVersion+1 + this.$set(this,'slotRequestVersion',version) + this.$set(this,'slotsLoading',true) + this.$set(this,'timeData',[]) + // 未选类型时仍按团体预约规则查询占用,仅用于场次查询,不回填表单类型。 + return $.post('/mobile/activity/site/info/getReserve',{siteId:this.site_id,day:moment(this.selectDate).format('YYYY-MM-DD'),reserveType:this.reserve_type===null ? 2 : this.reserve_type}).then(res=>{ + if(version!==this.slotRequestVersion)return + if(res && res.code===0)this.$set(this,'timeData',res.data) + else vant.Toast(res && res.msg || '场次加载失败') + },()=>{if(version===this.slotRequestVersion)vant.Toast('场次加载失败,请重试')}).always(()=>{ + if(version===this.slotRequestVersion)this.$set(this,'slotsLoading',false) + }) }, //获取最新的一个工作日设为当前时间 getLatestWorkDay() { diff --git a/src/main/resources/views/mobile/home.html b/src/main/resources/views/mobile/home.html index ea4262a..4ad9fb5 100644 --- a/src/main/resources/views/mobile/home.html +++ b/src/main/resources/views/mobile/home.html @@ -240,9 +240,10 @@ layout("/mobile/platform.html"){ {{ item.name }}
-
开始时间:{{ moment(item.startDate).format('MM/DD HH:mm') }} + +
开始时间:{{ moment(item.startDate).format(item.id === 'home_site_reserve_entry' ? 'HH:mm:ss' : 'MM/DD HH:mm') }}
-
结束时间:{{ moment(item.endDate).format('MM/DD HH:mm') }} +
结束时间:{{ moment(item.endDate).format(item.id === 'home_site_reserve_entry' ? 'HH:mm:ss' : 'MM/DD HH:mm') }}
@@ -332,13 +333,22 @@ layout("/mobile/platform.html"){
+ 正在加载其他办理事项 +
其他办理事项加载失败,点击重试
-
+ 正在加载办理事项 +
流程待办加载失败,点击重试
+
+
{{ o.processName }}
+
{{ o.taskNodeName }}
+
{{ o.createdByUserName }} · {{ o.createdOn }}
+
+
{{ o.title }}
@@ -356,14 +366,21 @@ layout("/mobile/platform.html"){ description="暂无待办" image="https://img01.yzcdn.cn/vant/custom-empty-image.png" style="margin-top: 44px" - v-if="needItems.length === 0" + v-if="!taskLoading && !taskError && !legacyTaskLoading && !legacyTaskError && visibleNeedItems.length === 0 && localNeedItems.length === 0" >
-
+ 正在加载办理事项 +
流程已办加载失败,点击重试
+
+
{{ o.processName }}
+
{{ o.taskNodeName }} · 已办理
+
{{ o.createdByUserName }} · {{ o.endOn || o.createdOn }}
+
+
{{ o.moduleName }}
@@ -379,7 +396,7 @@ layout("/mobile/platform.html"){ description="暂无已办" image="https://img01.yzcdn.cn/vant/custom-empty-image.png" style="margin-top: 44px" - v-if="completedItems.length === 0" + v-if="!taskLoading && !taskError && !legacyTaskLoading && !legacyTaskError && visibleCompletedItems.length === 0 && localCompletedItems.length === 0" >
@@ -517,7 +534,13 @@ layout("/mobile/platform.html"){ moduleMenus: [], roles: [], active: 0, - needCount: 0, + localNeedItems: [], + localCompletedItems: [], + taskLoading: false, + taskError: false, + taskRequestVersion: 0, + legacyTaskLoading: false, + legacyTaskError: false, needItems: [], activeName: '0', completedItems: [], @@ -545,6 +568,20 @@ layout("/mobile/platform.html"){ activityQrCodeShow: false, } }, + computed: { + // 同一手机入口已接入流程任务时,不再重复显示旧接口的汇总卡片。 + visibleNeedItems() { + const urls = this.localNeedItems.map(o => this.taskPath(o.formMobileUrl)); + return this.needItems.filter(o => !urls.includes(this.taskPath(o.mobileHref))); + }, + visibleCompletedItems() { + const urls = this.localCompletedItems.map(o => this.taskPath(o.formMobileUrlView || o.formMobileUrl)); + return this.completedItems.filter(o => !urls.includes(this.taskPath(o.mobileUrl))); + }, + needCount() { + return this.visibleNeedItems.length + this.localNeedItems.length > 0; + } + }, components: {}, methods: { async getMenus() { @@ -571,7 +608,6 @@ layout("/mobile/platform.html"){ tabbrChange(o) { console.log(o) sessionStorage.setItem("zhgh-mobile-home-active",o) - this.needItems = [] this.initData() }, toUrl(o) { @@ -580,14 +616,11 @@ layout("/mobile/platform.html"){ toHref(o) { pjaxReplace(o.mobileUrl + '?audit=1'); }, - async getNeedItems() { - const {data, code} = await $.get('/platform/needItems/getNeedItems') - if (code === 0) { - const obj = data.filter(v => { - return v.mobileHref != null && v.count > 0 - }) - this.needItems = obj - } + getNeedItems() { + // mobile=true 由后端按实际手机入口过滤,返回数组用于数量及红点计算。 + return $.get('/platform/needItems/getNeedItems', {mobile: true}).then(res => { + if (res.code === 0) this.$set(this, 'needItems', res.data.filter(v => v.mobileHref && v.count > 0)); + }); }, async getCompletes() { const {data, code} = await $.get('/platform/sys/completed/getCompletes') @@ -615,23 +648,68 @@ layout("/mobile/platform.html"){ this.needItems = this.needItems.concat(data.filter(v => v.count > 0)) } }, - async initData() { - await this.getNeedItems(); - await this.getMemberCheckSelfAgenda() - await this.getWelfareCheckSelfAgenda() - await this.getCompletes(); - await this.getMsg() - await this.listActivity(); - this.needCount = this.needItems.length + // 双端共用流程任务来源;只使用手机地址,避免手机进入 PC 表单。 + taskPath(url) { + return (url || '').split('?')[0].replace(/\/$/, ''); }, - async setNeedItems() { - const {data, code} = await $.get('/platform/needItems/getNeedItems') - if (code === 0) { - const obj = data.filter(v => { - return v.mobileHref != null && v.count > 0 - }) - window.localStorage.setItem('needItems', escape(JSON.stringify(obj))) + openLocalTask(row, completed) { + const url = completed ? (row.formMobileUrlView || row.formMobileUrl) : row.formMobileUrl; + if (url) pjaxReplace(url); + }, + loadLocalTasks() { + const version = this.taskRequestVersion + 1; + this.$set(this, 'taskRequestVersion', version); + this.$set(this, 'taskLoading', true); + this.$set(this, 'taskError', false); + let remaining = 2; + // mode=1 待办、2 已办;data 是任务数组。忽略旧请求,防止快速切换覆盖新结果。 + [1, 2].forEach(mode => { + $.post('/platform/sys/localProcess/todoList', {mode: mode, mobile: true}) + .then(res => { + if (version !== this.taskRequestVersion || this._isDestroyed) return; + if (res.code !== 0 || !Array.isArray(res.data)) { + this.$set(this, 'taskError', true); + return; + } + const ids = new Set(); + const rows = res.data.filter(row => { + const url = mode === 1 ? row.formMobileUrl : (row.formMobileUrlView || row.formMobileUrl); + if (!url || ids.has(row.id)) return false; + ids.add(row.id); + return true; + }); + this.$set(this, mode === 1 ? 'localNeedItems' : 'localCompletedItems', rows); + }, () => { + if (version === this.taskRequestVersion && !this._isDestroyed) this.$set(this, 'taskError', true); + }).always(() => { + remaining--; + if (remaining === 0 && version === this.taskRequestVersion && !this._isDestroyed) this.$set(this, 'taskLoading', false); + }); + }); + }, + initData() { + this.loadLocalTasks(); + // 保留原有业务待办;重复点击不并发追加旧接口的汇总记录。 + if (!this.legacyTaskLoading) { + this.$set(this, 'legacyTaskLoading', true); + this.$set(this, 'legacyTaskError', false); + this.getNeedItems().then(() => this.getMemberCheckSelfAgenda()) + .then(() => this.getWelfareCheckSelfAgenda()) + .then(() => this.getCompletes()) + .catch(() => this.$set(this, 'legacyTaskError', true)) + .finally(() => this.$set(this, 'legacyTaskLoading', false)); } + // 首页消息、活动请求不阻塞办理事项展示。 + this.getMsg().catch(() => {}); + return this.listActivity().catch(() => {}); + }, + setNeedItems() { + return $.get('/platform/needItems/getNeedItems', {mobile: true}).then(res => { + if (res.code === 0) { + const items = res.data.filter(v => v.mobileHref && v.count > 0); + window.localStorage.setItem('needItems', escape(JSON.stringify(items))); + } + }); }, //获取站内消息 async getMsg() { @@ -719,18 +797,18 @@ layout("/mobile/platform.html"){ beforeMount() { this.setNeedItems(); }, - async created() { - await this.getMenus(); + created() { this.getAllMonth(); this.createYearList(); - await this.initData(); - const active = sessionStorage.getItem("zhgh-mobile-home-active") - this.active = active ? parseInt(active) : 0 - - if (!this.activityList || !this.activityList.length > 0) { - this.clickIndex = 2 - this.clickMenu = this.moduleMenus.find(o => o.moduleName === '职工权益') - } + const active = sessionStorage.getItem("zhgh-mobile-home-active"); + this.$set(this, 'active', active ? parseInt(active) : 0); + // 菜单加载失败不应阻止待办请求;活动和菜单加载完成后再选择默认栏目。 + Promise.all([this.getMenus().catch(() => {}), this.initData()]).then(() => { + if (!this.activityList || this.activityList.length === 0) { + this.$set(this, 'clickIndex', 2); + this.$set(this, 'clickMenu', this.moduleMenus.find(o => o.moduleName === '职工权益') || {}); + } + }); } }) diff --git a/src/main/resources/views/platform/activity/includeJs/activityUtil.js b/src/main/resources/views/platform/activity/includeJs/activityUtil.js index 037c1bf..8555c88 100644 --- a/src/main/resources/views/platform/activity/includeJs/activityUtil.js +++ b/src/main/resources/views/platform/activity/includeJs/activityUtil.js @@ -1,9 +1,10 @@ class typeUtil { - // 返回类型数组,text/value 为现有选择组件需要的别名;默认保留停用类型用于历史筛选。 + // 场地模块查询只提供职工之家;保留接口实际 ID,text/value 供双端筛选组件使用,不改类型管理数据。 getAllType(enabledOnly = false) { return $.post('/platform/activity/site/type/findAll', {enabledOnly}).then((resp) => { if (resp.code !== 0) return []; - return resp.data.map((row) => Object.assign({}, row, {text:row.meetingTypeName,value:row.id})); + return resp.data.filter((row) => row.meetingTypeName === '职工之家') + .map((row) => Object.assign({}, row, {text:row.meetingTypeName,value:row.id})); }); } } diff --git a/src/main/resources/views/platform/activity/site/SiteManage.html b/src/main/resources/views/platform/activity/site/SiteManage.html index 8c4e6e2..9810bb0 100644 --- a/src/main/resources/views/platform/activity/site/SiteManage.html +++ b/src/main/resources/views/platform/activity/site/SiteManage.html @@ -55,7 +55,7 @@ layout("/layouts/platform.html"){ prop="name"> - @@ -137,20 +137,37 @@ layout("/layouts/platform.html"){ - + + + + + + + + + + + + + + + + + - - + + @@ -160,24 +177,6 @@ layout("/layouts/platform.html"){ - - - - - - - - - - - - - - - @@ -206,6 +205,7 @@ layout("/layouts/platform.html"){ --> + - - 添加 - - - - - - - - - - - - - - - + + + + 全天候预约 + 分段预约 + + + + 点击设置禁用时间 + 已设置 {{(formData.notApplyTimeList || []).length}} 个禁用时间 + + + + +
{{formData.reserveTimeType===2 ? '全天候预约:配置一个开放范围,预约人按时间单位选择连续时段。' : '分段预约:配置一个或多个互不重叠的场次。'}}
+ 添加场次 + + + + +
@@ -265,6 +261,28 @@ layout("/layouts/platform.html"){ + + +
+ + + 应用到全部日期 +
+ + + + + + + 确定 +
diff --git a/src/main/resources/views/platform/activity/site/SiteRecord.html b/src/main/resources/views/platform/activity/site/SiteRecord.html index fb7308c..68d6410 100644 --- a/src/main/resources/views/platform/activity/site/SiteRecord.html +++ b/src/main/resources/views/platform/activity/site/SiteRecord.html @@ -1,6 +1,14 @@ +