diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..7bc07ec --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Environment-dependent path to Maven home directory +/mavenHomeManager.xml +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/UniappTool.xml b/.idea/UniappTool.xml new file mode 100644 index 0000000..f7328e8 --- /dev/null +++ b/.idea/UniappTool.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..fe58613 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..0738741 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/git_toolbox_prj.xml b/.idea/git_toolbox_prj.xml new file mode 100644 index 0000000..02b915b --- /dev/null +++ b/.idea/git_toolbox_prj.xml @@ -0,0 +1,15 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/hmc_gbhd.iml b/.idea/hmc_gbhd.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/hmc_gbhd.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..ee9f695 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,68 @@ + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 0000000..cb1a291 --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..eb061bc --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..62ed8cb --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/backend/pom.xml b/backend/pom.xml index fcf92e0..63ed7b3 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -23,7 +23,7 @@ 2.5.1 4.1.1 4.0.11 - 1.18.18 + 1.18.22 UTF-8 diff --git a/backend/src/main/java/com/budwk/app/client/auth/service/ClientAuthService.java b/backend/src/main/java/com/budwk/app/client/auth/service/ClientAuthService.java index 67ac60a..8c65450 100644 --- a/backend/src/main/java/com/budwk/app/client/auth/service/ClientAuthService.java +++ b/backend/src/main/java/com/budwk/app/client/auth/service/ClientAuthService.java @@ -13,9 +13,11 @@ public interface ClientAuthService { String doLogin(ClientLoginBody loginBody); /** - * 注册 - * @param registerBody 注册信息 - * @return token + * 注册手机端用户,并在所选单位尚无简称时完成首次简称登记。 + * + * @param registerBody 注册信息;userName为姓名、phone为手机号、sex为性别、unitId为单位ID、 + * unitAliasName为单位简称、smsCode为短信验证码 + * @return 注册成功后的客户端登录token字符串 */ String doRegister(ClientRegisterBody registerBody); } diff --git a/backend/src/main/java/com/budwk/app/client/auth/service/ClientAuthServiceImpl.java b/backend/src/main/java/com/budwk/app/client/auth/service/ClientAuthServiceImpl.java index 0254325..b3e1863 100644 --- a/backend/src/main/java/com/budwk/app/client/auth/service/ClientAuthServiceImpl.java +++ b/backend/src/main/java/com/budwk/app/client/auth/service/ClientAuthServiceImpl.java @@ -8,10 +8,15 @@ import com.budwk.app.base.utils.PwdUtil; import com.budwk.app.client.auth.vo.ClientLoginBody; import com.budwk.app.client.auth.models.Client_user; import com.budwk.app.client.auth.vo.ClientRegisterBody; +import com.budwk.app.sys.models.Sys_unit; +import com.budwk.app.sys.services.SysUnitService; import com.budwk.app.web.commons.auth.satoken.StpClientUtil; import com.budwk.app.web.commons.auth.service.ValidateService; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.dao.Chain; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.Strings; @@ -25,6 +30,8 @@ public class ClientAuthServiceImpl implements ClientAuthService { private Dao dao; @Inject private ValidateService validateService; + @Inject + private SysUnitService sysUnitService; @Override public String doLogin(ClientLoginBody loginBody) { @@ -45,7 +52,8 @@ public class ClientAuthServiceImpl implements ClientAuthService { } if (loginBody.getLoginMethod().equals("sms")) { - validateService.checkSMSCode(loginBody.getPhone(), loginBody.getSmsCode()); + // 测试阶段允许已注册手机号直接登录;恢复正式登录时必须重新启用短信验证码校验。 +// validateService.checkSMSCode(loginBody.getPhone(), loginBody.getSmsCode()); } if (loginBody.getLoginMethod().equals("password")) { @@ -61,14 +69,40 @@ public class ClientAuthServiceImpl implements ClientAuthService { } @Override + @Aop(TransAop.READ_COMMITTED) public String doRegister(ClientRegisterBody registerBody) { int count = dao.count(Client_user.class, Cnd.where(Client_user::getPhone, "=", registerBody.getPhone())); if (count > 0) { throw new BaseException("手机号码已注册,请直接登录"); } + // unitId必须对应启用且未删除的单位;unitAliasName为首次登记的单位公共简称。 + Sys_unit unit = sysUnitService.fetch(Cnd.where(Sys_unit::getId, "=", registerBody.getUnitId()) + .and(Sys_unit::getIsEnable, "=", true) + .and(Sys_unit::getDelFlag, "=", false)); + if (unit == null) { + throw new BaseException("请选择有效单位"); + } + String unitAliasName = Strings.sNull(registerBody.getUnitAliasName()).trim(); + if (Strings.isBlank(unitAliasName)) { + throw new BaseException("请输入单位简称"); + } + if (unitAliasName.length() > 100) { + throw new BaseException("单位简称长度不能超过100个字符"); + } + validateService.checkSMSCode(registerBody.getPhone(), registerBody.getSmsCode()); + // 只允许首次登记简称;并发注册时通过数据库条件保证已有简称不会被后续请求覆盖。 + if (Strings.isBlank(unit.getAliasName())) { + Cnd aliasEmptyCnd = Cnd.where(Sys_unit::getId, "=", unit.getId()) + .and(Cnd.exps("aliasName", "is", null).or("aliasName", "=", "")); + int updatedRows = sysUnitService.update(Chain.make("aliasName", unitAliasName), aliasEmptyCnd); + if (updatedRows > 0) { + sysUnitService.clearCache(); + } + } + Client_user user = BeanUtil.copyProperties(registerBody, Client_user.class); // int gender = IdcardUtil.getGenderByIdCard(user.getIdCard()); // user.setSex(gender==1?"男":"女"); diff --git a/backend/src/main/java/com/budwk/app/client/auth/vo/ClientRegisterBody.java b/backend/src/main/java/com/budwk/app/client/auth/vo/ClientRegisterBody.java index 1ef13f9..4e552b8 100644 --- a/backend/src/main/java/com/budwk/app/client/auth/vo/ClientRegisterBody.java +++ b/backend/src/main/java/com/budwk/app/client/auth/vo/ClientRegisterBody.java @@ -20,5 +20,8 @@ public class ClientRegisterBody { private String unitId; + /** 注册时填写的单位简称,仅在所选单位尚未设置简称时写入单位资料。 */ + private String unitAliasName; + private String smsCode; } diff --git a/backend/src/main/java/com/budwk/app/client/module/activity/controller/ClientActivityController.java b/backend/src/main/java/com/budwk/app/client/module/activity/controller/ClientActivityController.java index 8880542..898dd2f 100644 --- a/backend/src/main/java/com/budwk/app/client/module/activity/controller/ClientActivityController.java +++ b/backend/src/main/java/com/budwk/app/client/module/activity/controller/ClientActivityController.java @@ -8,8 +8,10 @@ import com.budwk.app.client.module.activity.service.ClientActivityService; import com.budwk.app.client.module.activity.vo.ClientActivitySignVO; import com.budwk.app.module.activity.models.ActUser; import com.budwk.app.module.activity.models.Activity; +import com.budwk.app.module.activity.service.ActivityRegistrationFieldService; import com.budwk.app.web.commons.auth.satoken.StpClientUtil; import lombok.Synchronized; +import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.dao.FieldFilter; @@ -19,6 +21,7 @@ import org.nutz.dao.util.Daos; import org.nutz.dao.util.cri.SqlExpressionGroup; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.ioc.aop.Aop; import org.nutz.lang.util.NutMap; import org.nutz.mvc.adaptor.JsonAdaptor; import org.nutz.mvc.annotation.AdaptBy; @@ -38,6 +41,8 @@ public class ClientActivityController { @Inject private ClientActivityService clientActivityService; @Inject + private ActivityRegistrationFieldService activityRegistrationFieldService; + @Inject private Dao dao; @At @@ -61,6 +66,8 @@ public class ClientActivityController { """); Cnd cnd = Cnd.NEW(); cnd.and(Cnd.likeEX("t1.title", pageForm.getSearchKeyword())); + // 客户端活动列表只展示后台已开启的活动。 + cnd.and("t1.isOpen", "=", true); sql.setCondition(cnd); Pagination pagination = clientActivityService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); return Result.success(pagination); @@ -70,7 +77,13 @@ public class ClientActivityController { @GET @SaCheckLogin(type = StpClientUtil.TYPE) public Result get(Long id) { - Activity activity = clientActivityService.fetch(id); + // 详情接口必须再次校验开启状态,避免通过历史链接访问已关闭活动。 + Activity activity = clientActivityService.fetch(Cnd.where(Activity::getId, "=", id) + .and(Activity::getIsOpen, "=", true) + .and(Activity::getDelFlag, "=", false)); + if (activity == null) { + return Result.error("活动不存在或已关闭"); + } clientActivityService.fetchLinks(activity, "items"); return Result.success(activity); } @@ -83,6 +96,7 @@ public class ClientActivityController { @AdaptBy(type = JsonAdaptor.class) @SaCheckLogin(type = StpClientUtil.TYPE) @Synchronized + @Aop(TransAop.READ_COMMITTED) public synchronized Result signUp(ClientActivitySignVO signVO) { clientActivityService.signUp(signVO); return Result.success(); @@ -108,6 +122,62 @@ public class ClientActivityController { return Result.success(list); } + /** + * 查询指定活动在移动端启用的报名字段。 + * + * @param activityId 活动ID + * @return Result JSON结构;data为按sort排序的字段列表,字典字段同时包含启用选项 + */ + @At("/registration-fields/?") + @SaCheckLogin(type = StpClientUtil.TYPE) + public Result registrationFields(Long activityId) { + Activity activity = clientActivityService.fetch(Cnd.where(Activity::getId, "=", activityId) + .and(Activity::getIsOpen, "=", true) + .and(Activity::getDelFlag, "=", false)); + if (activity == null) { + return Result.error("活动不存在或已关闭"); + } + return Result.success(activityRegistrationFieldService.getFieldConfig(activityId, true, true)); + } + + /** + * 查询当前单位在指定活动中的教练、领队人数限制状态。 + * + * @param activityId 活动ID;应传当前报名页面对应的已开启活动ID + * @return Result JSON结构;data.enabled表示单项目人数规则是否生效,coachValue和leaderValue为人员类型字典值, + * coachCount和leaderCount为其他报名账号已占用人数,maxCount为各类型人数上限 + */ + @At("/person-type-rule-status/?") + @SaCheckLogin(type = StpClientUtil.TYPE) + public Result personTypeRuleStatus(Long activityId) { + return Result.success(clientActivityService.getPersonTypeRuleStatus(activityId)); + } + + /** + * 查询当前单位在指定活动中的项目类型报名人数限制状态。 + * + * @param activityId 活动ID;应传当前报名页面对应的已开启活动ID + * @return Result JSON结构;data.enabled表示单项目报名规则是否生效,data.rules包含类型编码、名称、 + * 人数上限和排除当前账号旧名单后的其他报名账号已占用人数 + */ + @At("/project-type-rule-status/?") + @SaCheckLogin(type = StpClientUtil.TYPE) + public Result projectTypeRuleStatus(Long activityId) { + return Result.success(clientActivityService.getProjectTypeRuleStatus(activityId)); + } + + /** + * 查询当前登录用户所属单位在指定活动中的已保存报名人数。 + * + * @param activityId 活动ID;应传当前报名页面对应的已开启活动ID + * @return Result JSON结构;data为同一活动、同一单位当前已保存且未删除的报名人员数量 + */ + @At("/unit-registration-count/?") + @SaCheckLogin(type = StpClientUtil.TYPE) + public Result unitRegistrationCount(Long activityId) { + return Result.success(clientActivityService.getUnitRegistrationCount(activityId)); + } + /** * 是否报名 */ diff --git a/backend/src/main/java/com/budwk/app/client/module/activity/service/ClientActivityService.java b/backend/src/main/java/com/budwk/app/client/module/activity/service/ClientActivityService.java index 27bd184..902b1d9 100644 --- a/backend/src/main/java/com/budwk/app/client/module/activity/service/ClientActivityService.java +++ b/backend/src/main/java/com/budwk/app/client/module/activity/service/ClientActivityService.java @@ -19,6 +19,30 @@ public interface ClientActivityService extends BaseService { */ void signUp(ClientActivitySignVO signVO); + /** + * 获取当前登录用户所属单位在指定活动中的人员类型限制状态。 + * + * @param activityId 活动ID,必须为已开启且未删除的活动 + * @return NutMap结构;包含规则是否启用、教练/领队字典值、其他报名账号已占用人数和人数上限 + */ + NutMap getPersonTypeRuleStatus(Long activityId); + + /** + * 获取当前登录用户所属单位在指定活动中的项目类型报名规则状态。 + * + * @param activityId 活动ID,必须为已开启且未删除的活动 + * @return NutMap结构;包含规则是否启用,以及各子项上限和其他报名账号已占用人数 + */ + NutMap getProjectTypeRuleStatus(Long activityId); + + /** + * 获取当前登录用户所属单位在指定活动中已保存的报名人员总数。 + * + * @param activityId 活动ID,必须为已开启且未删除的活动 + * @return 当前单位在该活动中未删除的报名人员数量 + */ + int getUnitRegistrationCount(Long activityId); + /** * 报名签到 */ diff --git a/backend/src/main/java/com/budwk/app/client/module/activity/service/impl/ClientActivityServiceImpl.java b/backend/src/main/java/com/budwk/app/client/module/activity/service/impl/ClientActivityServiceImpl.java index 2d3a7ed..d833abd 100644 --- a/backend/src/main/java/com/budwk/app/client/module/activity/service/impl/ClientActivityServiceImpl.java +++ b/backend/src/main/java/com/budwk/app/client/module/activity/service/impl/ClientActivityServiceImpl.java @@ -1,30 +1,39 @@ package com.budwk.app.client.module.activity.service.impl; -import cn.hutool.core.util.IdcardUtil; import com.budwk.app.base.exception.BaseException; -import com.budwk.app.base.result.Result; import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.client.auth.models.Client_user; import com.budwk.app.client.module.activity.service.ClientActivityService; import com.budwk.app.client.module.activity.vo.ClientActivitySignVO; import com.budwk.app.module.activity.models.ActUser; import com.budwk.app.module.activity.models.Activity; +import com.budwk.app.module.activity.enums.RegistrationFieldDefinition; +import com.budwk.app.module.activity.service.ActivityRegistrationFieldService; import com.budwk.app.web.commons.auth.satoken.StpClientUtil; +import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.dao.Sqls; import org.nutz.dao.sql.Sql; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.Lang; +import org.nutz.lang.Strings; import org.nutz.lang.util.NutMap; -import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; @IocBean(args = {"refer:dao"}) public class ClientActivityServiceImpl extends BaseServiceImpl implements ClientActivityService { + @Inject + private ActivityRegistrationFieldService activityRegistrationFieldService; + public ClientActivityServiceImpl(Dao dao) { super(dao); } @@ -46,9 +55,118 @@ public class ClientActivityServiceImpl extends BaseServiceImpl impleme } @Override + public NutMap getPersonTypeRuleStatus(Long activityId) { + Activity activity = this.fetch(Cnd.where(Activity::getId, "=", activityId) + .and(Activity::getIsOpen, "=", true) + .and(Activity::getDelFlag, "=", false)); + if (activity == null) { + throw new BaseException("活动不存在或已关闭"); + } + Client_user selfUser = dao().fetch(Client_user.class, StpClientUtil.STP.getLoginIdAsLong()); + if (selfUser == null || Strings.isBlank(selfUser.getUnitId())) { + throw new BaseException("当前用户未设置所属单位,无法校验人员类型"); + } + return activityRegistrationFieldService.getPersonTypeRuleStatus(activityId, selfUser.getUnitId(), + StpClientUtil.STP.getLoginIdAsLong()); + } + + @Override + public NutMap getProjectTypeRuleStatus(Long activityId) { + Activity activity = this.fetch(Cnd.where(Activity::getId, "=", activityId) + .and(Activity::getIsOpen, "=", true) + .and(Activity::getDelFlag, "=", false)); + if (activity == null) { + throw new BaseException("活动不存在或已关闭"); + } + Client_user selfUser = dao().fetch(Client_user.class, StpClientUtil.STP.getLoginIdAsLong()); + if (selfUser == null || Strings.isBlank(selfUser.getUnitId())) { + throw new BaseException("当前用户未设置所属单位,无法校验项目报名人数"); + } + return activityRegistrationFieldService.getProjectTypeRuleStatus(activityId, selfUser.getUnitId(), + StpClientUtil.STP.getLoginIdAsLong()); + } + + @Override + public int getUnitRegistrationCount(Long activityId) { + Activity activity = this.fetch(Cnd.where(Activity::getId, "=", activityId) + .and(Activity::getIsOpen, "=", true) + .and(Activity::getDelFlag, "=", false)); + if (activity == null) { + throw new BaseException("活动不存在或已关闭"); + } + Client_user selfUser = dao().fetch(Client_user.class, StpClientUtil.STP.getLoginIdAsLong()); + if (selfUser == null || Strings.isBlank(selfUser.getUnitId())) { + throw new BaseException("当前用户未设置所属单位,无法统计报名人数"); + } + // 统计口径固定为同一活动、同一单位的有效报名人员,供移动端与当前草稿人数合并展示。 + return dao().count(ActUser.class, Cnd.where(ActUser::getActivityId, "=", activityId) + .and(ActUser::getUnitId, "=", selfUser.getUnitId()) + .and(ActUser::getDelFlag, "=", false)); + } + + @Override + @Aop(TransAop.READ_COMMITTED) public void signUp(ClientActivitySignVO signVO) { List userList = signVO.getUserList(); + if (signVO.getActivityId() == null || userList == null || userList.isEmpty()) { + throw new BaseException("活动ID和报名人员不能为空"); + } + Activity activity = this.fetch(Cnd.where(Activity::getId, "=", signVO.getActivityId()) + .and(Activity::getIsOpen, "=", true) + .and(Activity::getDelFlag, "=", false)); + if (activity == null) { + throw new BaseException("活动不存在或已关闭"); + } + + Client_user selfUser = dao().fetch(Client_user.class, StpClientUtil.STP.getLoginIdAsLong()); + if (selfUser == null) { + throw new BaseException("当前登录用户不存在"); + } + dao().fetchLinks(selfUser, "unit"); + if (selfUser.getUnit() == null) { + throw new BaseException("当前用户未设置所属单位,无法报名"); + } + Map requiredMap = activityRegistrationFieldService.getRequiredMap(signVO.getActivityId()); + List oldUsers = dao().query(ActUser.class, + Cnd.where(ActUser::getActivityId, "=", signVO.getActivityId()) + .and(ActUser::getSignUpUserId, "=", StpClientUtil.STP.getLoginIdAsLong())); + Map oldUserById = oldUsers.stream().filter(item -> item.getId() != null) + .collect(Collectors.toMap(ActUser::getId, item -> item, (left, right) -> left, LinkedHashMap::new)); + Map oldUserByPhone = oldUsers.stream().filter(item -> Strings.isNotBlank(item.getPhone())) + .collect(Collectors.toMap(ActUser::getPhone, item -> item, (left, right) -> left, LinkedHashMap::new)); + for (ActUser user : userList) { + // 单位信息由当前登录用户所属单位统一带出,不接收客户端任意修改。 + user.setUnitId(selfUser.getUnitId()); + user.setUnitName(selfUser.getUnit().getName()); + ActUser oldUser = oldUserById.get(user.getId()); + if (oldUser == null && Strings.isNotBlank(user.getPhone())) { + oldUser = oldUserByPhone.get(user.getPhone()); + } + mergeHiddenFields(user, oldUser, requiredMap); + } + + if (requiredMap.containsKey("personType") || requiredMap.containsKey("projectType")) { + // 以活动和单位为边界构造最终报名集合;当前账号旧记录会被本次提交整体覆盖,因此排除后再加入草稿。 + List unitUsers = new ArrayList<>(dao().query(ActUser.class, + Cnd.where(ActUser::getActivityId, "=", signVO.getActivityId()) + .and(ActUser::getUnitId, "=", selfUser.getUnitId()) + .and(Cnd.exps("signUpUserId", "!=", StpClientUtil.STP.getLoginIdAsLong()) + .or("signUpUserId", "is", null)) + .and(ActUser::getDelFlag, "=", false))); + unitUsers.addAll(userList); + if (requiredMap.containsKey("personType")) { + activityRegistrationFieldService.validatePersonTypeRules(signVO.getActivityId(), unitUsers); + } + if (requiredMap.containsKey("projectType")) { + activityRegistrationFieldService.validateProjectTypeRules(signVO.getActivityId(), unitUsers); + } + } + + for (ActUser user : userList) { + validateRegistrationFields(signVO.getActivityId(), user, requiredMap); + } + // 检查是否有重复的身份证号 // boolean hasDuplicateIdCard = userList.stream() // .collect(Collectors.groupingBy(ActUser::getIdCard, Collectors.counting())) @@ -71,9 +189,6 @@ public class ClientActivityServiceImpl extends BaseServiceImpl impleme LocalDateTime now = LocalDateTime.now(); - Client_user selfUser = dao().fetch(Client_user.class, StpClientUtil.STP.getLoginIdAsLong()); - dao().fetchLinks(selfUser, "unit"); - // 首先检查报名人员是否已经在用户表中 for (ActUser user : userList) { Client_user clientUser = dao().fetch(Client_user.class, Cnd.where(Client_user::getPhone, "=", user.getPhone())); @@ -109,9 +224,15 @@ public class ClientActivityServiceImpl extends BaseServiceImpl impleme user.setSignUpUserId(StpClientUtil.STP.getLoginIdAsLong()); user.setSignUpTime(now); - if(!user.getTraffic().equals("自驾")){ + if (!"自驾".equals(user.getTraffic())) { user.setCarNo(null); } + if (!"公共交通".equals(user.getTraffic())) { + user.setIsStation(false); + user.setStationAddress(null); + } else if (!Boolean.TRUE.equals(user.getIsStation())) { + user.setStationAddress(null); + } } @@ -124,6 +245,99 @@ public class ClientActivityServiceImpl extends BaseServiceImpl impleme dao().insert(userList); } + /** + * 按活动字段配置校验一名报名人员。 + * 字段未启用时不校验;关联字段仅在父字段满足显示条件时参与必填校验。 + */ + private void validateRegistrationFields(Long activityId, ActUser user, Map requiredMap) { + for (Map.Entry entry : requiredMap.entrySet()) { + RegistrationFieldDefinition definition = RegistrationFieldDefinition.fromKey(entry.getKey()); + if (definition == null || !conditionMatches(user, definition)) { + continue; + } + Object value = getFieldValue(user, entry.getKey()); + if (Boolean.TRUE.equals(entry.getValue()) && isEmptyValue(value)) { + throw new BaseException("请填写" + definition.getFieldLabel()); + } + if (!isEmptyValue(value) + && !activityRegistrationFieldService.isValidDictValue(activityId, entry.getKey(), String.valueOf(value))) { + throw new BaseException(definition.getFieldLabel() + "不属于当前可用的数据字典选项"); + } + if ("personType".equals(entry.getKey()) && !isEmptyValue(value) + && !activityRegistrationFieldService.isValidPersonTypeCombination(String.valueOf(value))) { + throw new BaseException("人员类型中的教练和领队不能同时选择"); + } + } + } + + private boolean conditionMatches(ActUser user, RegistrationFieldDefinition definition) { + if (Strings.isBlank(definition.getParentField())) { + return true; + } + Object parentValue = getFieldValue(user, definition.getParentField()); + return parentValue != null && definition.getShowWhenValue().equals(String.valueOf(parentValue)); + } + + private boolean isEmptyValue(Object value) { + return value == null || value instanceof String && Strings.isBlank((String) value); + } + + private Object getFieldValue(ActUser user, String fieldKey) { + return switch (fieldKey) { + case "userName" -> user.getUserName(); + case "sex" -> user.getSex(); + case "personType" -> user.getPersonType(); + case "projectType" -> user.getProjectType(); + case "weight" -> user.getWeight(); + case "unitName" -> user.getUnitName(); + case "position" -> user.getPosition(); + case "phone" -> user.getPhone(); + case "birthday" -> user.getBirthday(); + case "isStay" -> user.getIsStay(); + case "isContacts" -> user.getIsContacts(); + case "traffic" -> user.getTraffic(); + case "carNo" -> user.getCarNo(); + case "isStation" -> user.getIsStation(); + case "stationAddress" -> user.getStationAddress(); + case "isShowSkill" -> user.getIsShowSkill(); + case "skillName" -> user.getSkillName(); + case "equip" -> user.getEquip(); + case "isParty" -> user.getIsParty(); + default -> null; + }; + } + + /** + * 重新提交报名时保留当前活动未展示字段的旧值,防止管理员调整配置后历史信息被清空。 + */ + private void mergeHiddenFields(ActUser target, ActUser source, Map selectedFields) { + for (RegistrationFieldDefinition definition : RegistrationFieldDefinition.values()) { + if (selectedFields.containsKey(definition.getFieldKey())) { + continue; + } + switch (definition.getFieldKey()) { + case "sex" -> target.setSex(source == null ? null : source.getSex()); + case "personType" -> target.setPersonType(source == null ? null : source.getPersonType()); + case "projectType" -> target.setProjectType(source == null ? null : source.getProjectType()); + case "weight" -> target.setWeight(source == null ? null : source.getWeight()); + case "position" -> target.setPosition(source == null ? null : source.getPosition()); + case "birthday" -> target.setBirthday(source == null ? null : source.getBirthday()); + case "isStay" -> target.setIsStay(source == null ? null : source.getIsStay()); + case "isContacts" -> target.setIsContacts(source == null ? false : source.getIsContacts()); + case "traffic" -> target.setTraffic(source == null ? null : source.getTraffic()); + case "carNo" -> target.setCarNo(source == null ? null : source.getCarNo()); + case "isStation" -> target.setIsStation(source == null ? false : source.getIsStation()); + case "stationAddress" -> target.setStationAddress(source == null ? null : source.getStationAddress()); + case "isShowSkill" -> target.setIsShowSkill(source == null ? false : source.getIsShowSkill()); + case "skillName" -> target.setSkillName(source == null ? null : source.getSkillName()); + case "equip" -> target.setEquip(source == null ? null : source.getEquip()); + case "isParty" -> target.setIsParty(source == null ? false : source.getIsParty()); + default -> { + } + } + } + } + @Override public void signIn(Long activityId) { ActUser actUser = dao().fetch(ActUser.class, Cnd.where(ActUser::getActivityId, "=", activityId).and(ActUser::getUserId, "=", StpClientUtil.STP.getLoginIdAsLong())); diff --git a/backend/src/main/java/com/budwk/app/module/activity/controller/ActScoreEnterController.java b/backend/src/main/java/com/budwk/app/module/activity/controller/ActScoreEnterController.java index 72ceb46..f3ba70c 100644 --- a/backend/src/main/java/com/budwk/app/module/activity/controller/ActScoreEnterController.java +++ b/backend/src/main/java/com/budwk/app/module/activity/controller/ActScoreEnterController.java @@ -65,11 +65,7 @@ public class ActScoreEnterController { @SaCheckPermission("activity.scoreEnter") @ApiOperation(value = "获取活动列表", nickname = "/list-activity", httpMethod = "GET") public Result listAct(Integer year) { - Sql sql = Sqls.create("select id,title from activity $condition"); - Cnd cnd = Cnd.NEW(); - cnd.andEX("YEAR(startTime)", "=", year); - sql.setCondition(cnd); - return Result.success(activityService.listMap(sql)); + return Result.success(activityService.listByYearOrderByLatest(year)); } /** diff --git a/backend/src/main/java/com/budwk/app/module/activity/controller/ActScoreEntryController.java b/backend/src/main/java/com/budwk/app/module/activity/controller/ActScoreEntryController.java index c480ed8..f678891 100644 --- a/backend/src/main/java/com/budwk/app/module/activity/controller/ActScoreEntryController.java +++ b/backend/src/main/java/com/budwk/app/module/activity/controller/ActScoreEntryController.java @@ -46,11 +46,7 @@ public class ActScoreEntryController { @SaCheckPermission("activity.scoreEntry") @ApiOperation(value = "获取活动列表", nickname = "/list-activity", httpMethod = "GET") public Result listAct(Integer year) { - Sql sql = Sqls.create("select id,title from activity $condition"); - Cnd cnd = Cnd.NEW(); - cnd.andEX("YEAR(startTime)", "=", year); - sql.setCondition(cnd); - return Result.success(activityService.listMap(sql)); + return Result.success(activityService.listByYearOrderByLatest(year)); } /** diff --git a/backend/src/main/java/com/budwk/app/module/activity/controller/ActUserController.java b/backend/src/main/java/com/budwk/app/module/activity/controller/ActUserController.java index 5f07a5b..1286967 100644 --- a/backend/src/main/java/com/budwk/app/module/activity/controller/ActUserController.java +++ b/backend/src/main/java/com/budwk/app/module/activity/controller/ActUserController.java @@ -13,6 +13,8 @@ import com.budwk.app.client.module.activity.vo.ClientActivitySignVO; import com.budwk.app.module.activity.models.ActUser; import com.budwk.app.module.activity.models.Activity; import com.budwk.app.module.activity.service.ActUserService; +import com.budwk.app.module.activity.service.ActivityManageService; +import com.budwk.app.module.activity.service.ActivityRegistrationFieldService; import com.budwk.app.module.activity.vo.page.ActSignInUserPageForm; import com.budwk.app.module.activity.vo.page.ActUserPageForm; import com.budwk.app.web.commons.slog.annotation.SLog; @@ -61,6 +63,10 @@ public class ActUserController { private Dao dao; @Inject private ActUserService actUserService; + @Inject + private ActivityManageService activityManageService; + @Inject + private ActivityRegistrationFieldService activityRegistrationFieldService; @At("") @SaCheckPermission("activity.user") @@ -204,11 +210,26 @@ public class ActUserController { */ @At @SaCheckPermission("activity.user") - public Result getActivityList(String year) { - List activityList = dao.query(Activity.class, Cnd.NEW().andEX("YEAR(startTime)", "=", year).and(Activity::getDelFlag, "=", false)); + public Result getActivityList(Integer year) { + List activityList = activityManageService.listByYearOrderByLatest(year); return Result.success(activityList); } + /** + * 获取报名人员列表需要显示的活动报名字段。 + * + * @param activityId 活动ID,必须传入当前列表选中的活动 + * @return Result JSON结构;data为当前活动已启用的字段,并包含字典字段的可用选项 + */ + @At + @SaCheckPermission("activity.user") + public Result getRegistrationFields(Long activityId) { + if (activityId == null) { + return Result.error("请选择活动"); + } + return Result.success(activityRegistrationFieldService.getFieldConfig(activityId, true, true)); + } + @At @SaCheckPermission("activity.user") diff --git a/backend/src/main/java/com/budwk/app/module/activity/controller/ActWonderfulMomentController.java b/backend/src/main/java/com/budwk/app/module/activity/controller/ActWonderfulMomentController.java index 6ec8c3e..390f31e 100644 --- a/backend/src/main/java/com/budwk/app/module/activity/controller/ActWonderfulMomentController.java +++ b/backend/src/main/java/com/budwk/app/module/activity/controller/ActWonderfulMomentController.java @@ -86,9 +86,7 @@ public class ActWonderfulMomentController { @At public Result getWonderfulMoment(@Param("id") Long id) { - Cnd cnd = Cnd.where("published", "=", true).andEX("id", "=", id); - cnd.desc("publishTime"); - List momentList = wonderfulMomentService.query(cnd); + List momentList = wonderfulMomentService.listClientVisible(id); List result = new ArrayList<>(); for (ActWonderfulMoment wonderfulMoment : momentList) { NutMap nutMap = Lang.obj2map(wonderfulMoment, NutMap.class); @@ -106,15 +104,20 @@ public class ActWonderfulMomentController { @At("/getWonderfulMomentById/?") public Result getWonderfulMomentById(Long id) { - ActWonderfulMoment moment = wonderfulMomentService.fetch(id); + ActWonderfulMoment moment = wonderfulMomentService.fetchClientVisible(id); + if (moment == null) { + return Result.error("精彩瞬间不存在或所属活动已关闭"); + } return Result.success(moment); } @At("/handleWonderfulMoment/?") + @Aop(TransAop.READ_COMMITTED) public Result handleWonderfulMoment(Long id) { - ActWonderfulMoment moment = wonderfulMomentService.fetch(id); - moment.setReadCount(moment.getReadCount() + 1); - wonderfulMomentService.update(moment); + int updatedRows = wonderfulMomentService.increaseClientReadCount(id); + if (updatedRows == 0) { + return Result.error("精彩瞬间不存在或所属活动已关闭"); + } return Result.success(); } } diff --git a/backend/src/main/java/com/budwk/app/module/activity/controller/ActivityItemApplyQueryController.java b/backend/src/main/java/com/budwk/app/module/activity/controller/ActivityItemApplyQueryController.java index c89fa1e..07e6bfb 100644 --- a/backend/src/main/java/com/budwk/app/module/activity/controller/ActivityItemApplyQueryController.java +++ b/backend/src/main/java/com/budwk/app/module/activity/controller/ActivityItemApplyQueryController.java @@ -11,6 +11,7 @@ import com.budwk.app.base.result.Result; import com.budwk.app.module.activity.models.Activity; import com.budwk.app.module.activity.models.ActivityItem; import com.budwk.app.module.activity.service.ActivitySignInQueryService; +import com.budwk.app.module.activity.service.ActivityManageService; import com.budwk.app.module.activity.vo.page.ActSignInUserPageForm; import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.Workbook; @@ -42,6 +43,8 @@ public class ActivityItemApplyQueryController { @Inject private ActivitySignInQueryService activitySignInQueryService; + @Inject + private ActivityManageService activityManageService; @At("") @SaCheckPermission("activity.itemApplyQuery") @@ -52,7 +55,7 @@ public class ActivityItemApplyQueryController { @At() @SaCheckPermission("activity.signInUserQuery") public Result getActivityOptions(Integer year) { - return Result.success(activitySignInQueryService.dao().query(Activity.class, Cnd.NEW().andEX("YEAR(startTime)", "=", year))); + return Result.success(activityManageService.listByYearOrderByLatest(year)); } @At() diff --git a/backend/src/main/java/com/budwk/app/module/activity/controller/ActivityManageController.java b/backend/src/main/java/com/budwk/app/module/activity/controller/ActivityManageController.java index 2fb95d5..395e689 100644 --- a/backend/src/main/java/com/budwk/app/module/activity/controller/ActivityManageController.java +++ b/backend/src/main/java/com/budwk/app/module/activity/controller/ActivityManageController.java @@ -2,12 +2,13 @@ package com.budwk.app.module.activity.controller; import cn.dev33.satoken.annotation.SaCheckLogin; import cn.dev33.satoken.annotation.SaCheckPermission; -import cn.hutool.core.collection.CollUtil; import com.budwk.app.base.page.PageForm; import com.budwk.app.base.page.Pagination; import com.budwk.app.base.result.Result; import com.budwk.app.module.activity.models.Activity; import com.budwk.app.module.activity.models.ActivityItem; +import com.budwk.app.module.activity.models.ActRegistrationField; +import com.budwk.app.module.activity.service.ActivityRegistrationFieldService; import com.budwk.app.module.activity.service.ActivityManageService; import com.budwk.app.web.commons.auth.satoken.StpClientUtil; import lombok.extern.slf4j.Slf4j; @@ -22,7 +23,7 @@ import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Param; import java.util.Arrays; -import java.util.Collection; +import java.util.Collections; import java.util.List; @IocBean @@ -35,6 +36,8 @@ public class ActivityManageController { private Dao dao; @Inject private ActivityManageService activityService; + @Inject + private ActivityRegistrationFieldService activityRegistrationFieldService; @At("") @SaCheckPermission("activity.manager") @@ -91,6 +94,27 @@ public class ActivityManageController { return Result.success(); } + /** + * 更新活动在客户端的开启状态。 + * + * @param id 活动ID,必须传入后台活动列表中的有效ID + * @param isOpen 是否开启;true 表示客户端显示,false 表示客户端隐藏 + * @return Result JSON结构;code为0表示更新成功,非0时msg说明失败原因 + */ + @At + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("activity.manager") + public Result updateOpenStatus(@Param("id") Long id, @Param("isOpen") Boolean isOpen) { + if (id == null || isOpen == null) { + return Result.error("活动ID和开启状态不能为空"); + } + int updatedRows = activityService.updateOpenStatus(id, isOpen); + if (updatedRows == 0) { + return Result.error("活动不存在或状态更新失败"); + } + return Result.success(); + } + /** * 删除活动 */ @@ -148,27 +172,49 @@ public class ActivityManageController { @Aop(TransAop.READ_COMMITTED) @SaCheckPermission("activity.manager") public Result updateItem(Long id, @Param("items") ActivityItem[] items) { - // 查询出旧的活动信息 - List oldItems = dao.query(ActivityItem.class, Cnd.where(ActivityItem::getActivityId, "=", id)); - // 更新项目信息 - List oldItemIdList = oldItems.stream().map(ActivityItem::getId).toList(); - // 更新项目信息 - for (ActivityItem item : items) { - item.setActivityId(id); + activityService.updateItems(id, + items == null ? Collections.emptyList() : Arrays.asList(items)); + return Result.success(); + } + + /** + * 查询活动报名字段配置。 + * + * @param id 活动ID + * @return Result JSON结构;data为完整安全字段目录,并包含选中、必填、顺序和依赖信息 + */ + @At + @SaCheckPermission("activity.manager") + public Result getRegistrationFields(@Param("id") Long id) { + if (id == null || activityService.fetch(id) == null) { + return Result.error("活动不存在"); } - dao.insertOrUpdate(items); - // 找出需要新增的 找出需要删除的 - List newItemIdList = Arrays.stream(items).map(ActivityItem::getId).toList(); - // 删除旧的项目信息 - Collection deleteItemIdList = CollUtil.subtract(oldItemIdList, newItemIdList); - dao.clear(ActivityItem.class, Cnd.where("id", "in", deleteItemIdList)); + return Result.success(activityRegistrationFieldService.getFieldConfig(id, false, false)); + } + + /** + * 保存活动报名字段配置。 + * + * @param id 活动ID + * @param fields 已选择字段数组;fieldKey为字段编码、required为是否必填、sort为显示顺序 + * @return Result JSON结构;code为0表示保存成功,非0时msg说明失败原因 + */ + @At + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("activity.manager") + public Result saveRegistrationFields(@Param("id") Long id, + @Param("fields") ActRegistrationField[] fields) { + activityRegistrationFieldService.saveFieldConfig(id, + fields == null ? Collections.emptyList() : Arrays.asList(fields)); return Result.success(); } @At @Aop(TransAop.READ_COMMITTED) public Result getActivity() { - List activityList = activityService.query(Cnd.NEW()); + // 客户端首页的活动详情只展示后台已开启且未删除的活动。 + List activityList = activityService.query(Cnd.where(Activity::getIsOpen, "=", true) + .and(Activity::getDelFlag, "=", false)); return Result.success(activityList); } } diff --git a/backend/src/main/java/com/budwk/app/module/activity/controller/ActivitySignInQueryController.java b/backend/src/main/java/com/budwk/app/module/activity/controller/ActivitySignInQueryController.java index 143acf4..23de596 100644 --- a/backend/src/main/java/com/budwk/app/module/activity/controller/ActivitySignInQueryController.java +++ b/backend/src/main/java/com/budwk/app/module/activity/controller/ActivitySignInQueryController.java @@ -12,6 +12,8 @@ import com.budwk.app.base.result.Result; import com.budwk.app.module.activity.models.Activity; import com.budwk.app.module.activity.models.ActivityItem; import com.budwk.app.module.activity.service.ActivitySignInQueryService; +import com.budwk.app.module.activity.service.ActivityManageService; +import com.budwk.app.module.activity.service.ActivityRegistrationFieldService; import com.budwk.app.module.activity.vo.page.ActSignInUserPageForm; import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.Workbook; @@ -39,6 +41,10 @@ public class ActivitySignInQueryController { @Inject private ActivitySignInQueryService activitySignInQueryService; + @Inject + private ActivityManageService activityManageService; + @Inject + private ActivityRegistrationFieldService activityRegistrationFieldService; @At("") @SaCheckPermission("activity.signInUserQuery") @@ -50,7 +56,22 @@ public class ActivitySignInQueryController { @At() @SaCheckPermission("activity.signInUserQuery") public Result getActivityOptions(Integer year) { - return Result.success(activitySignInQueryService.dao().query(Activity.class, Cnd.NEW().andEX("YEAR(startTime)", "=", year))); + return Result.success(activityManageService.listByYearOrderByLatest(year)); + } + + /** + * 获取签字人员列表需要显示的活动报名字段。 + * + * @param activityId 活动ID,必须传入当前列表选中的活动 + * @return Result JSON结构;data为当前活动已启用的字段,并包含字典字段的可用选项 + */ + @At() + @SaCheckPermission("activity.signInUserQuery") + public Result getRegistrationFields(Long activityId) { + if (activityId == null) { + return Result.error("请选择活动"); + } + return Result.success(activityRegistrationFieldService.getFieldConfig(activityId, true, true)); } @At() diff --git a/backend/src/main/java/com/budwk/app/module/activity/enums/RegistrationFieldDefinition.java b/backend/src/main/java/com/budwk/app/module/activity/enums/RegistrationFieldDefinition.java new file mode 100644 index 0000000..05a5120 --- /dev/null +++ b/backend/src/main/java/com/budwk/app/module/activity/enums/RegistrationFieldDefinition.java @@ -0,0 +1,68 @@ +package com.budwk.app.module.activity.enums; + +import lombok.Getter; + +import java.util.Arrays; +import java.util.List; + +/** + * 报名字段安全目录。 + * 这里显式声明允许管理员配置的字段,避免把主键、签到状态等系统字段暴露到移动端。 + */ +@Getter +public enum RegistrationFieldDefinition { + USER_NAME("userName", "姓名", "input", true, true, true, null, null, null), + SEX("sex", "性别", "radio", true, true, false, null, null, null), + PERSON_TYPE("personType", "人员类型", "dict-checkbox", false, false, false, null, null, "person_type"), + PROJECT_TYPE("projectType", "项目类型", "dict-checkbox", false, false, false, null, null, "project_type"), + WEIGHT("weight", "体重(KG)", "number", true, true, false, null, null, null), + UNIT_NAME("unitName", "单位名称", "readonly", true, true, true, null, null, null), + POSITION("position", "职务", "input", true, true, false, null, null, null), + PHONE("phone", "联系电话", "tel", true, true, true, null, null, null), + BIRTHDAY("birthday", "出生日期", "date", false, false, false, null, null, null), + IS_STAY("isStay", "是否住宿", "boolean-radio", true, true, false, null, null, null), + IS_CONTACTS("isContacts", "是否联络员", "boolean-radio", true, true, false, null, null, null), + TRAFFIC("traffic", "交通方式", "radio", true, true, false, null, null, null), + CAR_NO("carNo", "车牌号", "input", true, true, false, "traffic", "自驾", null), + IS_STATION("isStation", "是否接站", "boolean-radio", true, true, false, "traffic", "公共交通", null), + STATION_ADDRESS("stationAddress", "接站地址", "input", true, true, false, "isStation", "true", null), + IS_SHOW_SKILL("isShowSkill", "是否展示才艺", "boolean-radio", false, false, false, null, null, null), + SKILL_NAME("skillName", "才艺名称", "input", false, false, false, "isShowSkill", "true", null), + EQUIP("equip", "所需器材", "input", false, false, false, "isShowSkill", "true", null), + IS_PARTY("isParty", "参加茶叙会", "boolean-radio", true, true, false, null, null, null); + + private final String fieldKey; + private final String fieldLabel; + private final String componentType; + private final boolean defaultSelected; + private final boolean defaultRequired; + private final boolean locked; + private final String parentField; + private final String showWhenValue; + private final String dictCode; + + RegistrationFieldDefinition(String fieldKey, String fieldLabel, String componentType, + boolean defaultSelected, boolean defaultRequired, boolean locked, + String parentField, String showWhenValue, String dictCode) { + this.fieldKey = fieldKey; + this.fieldLabel = fieldLabel; + this.componentType = componentType; + this.defaultSelected = defaultSelected; + this.defaultRequired = defaultRequired; + this.locked = locked; + this.parentField = parentField; + this.showWhenValue = showWhenValue; + this.dictCode = dictCode; + } + + public static RegistrationFieldDefinition fromKey(String fieldKey) { + return Arrays.stream(values()) + .filter(item -> item.fieldKey.equals(fieldKey)) + .findFirst() + .orElse(null); + } + + public static List defaultFields() { + return Arrays.stream(values()).filter(RegistrationFieldDefinition::isDefaultSelected).toList(); + } +} diff --git a/backend/src/main/java/com/budwk/app/module/activity/models/ActRegistrationField.java b/backend/src/main/java/com/budwk/app/module/activity/models/ActRegistrationField.java new file mode 100644 index 0000000..aca4d56 --- /dev/null +++ b/backend/src/main/java/com/budwk/app/module/activity/models/ActRegistrationField.java @@ -0,0 +1,52 @@ +package com.budwk.app.module.activity.models; + +import com.budwk.app.base.model.BaseModel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.nutz.dao.entity.annotation.ColDefine; +import org.nutz.dao.entity.annotation.ColType; +import org.nutz.dao.entity.annotation.Column; +import org.nutz.dao.entity.annotation.Comment; +import org.nutz.dao.entity.annotation.Id; +import org.nutz.dao.entity.annotation.Index; +import org.nutz.dao.entity.annotation.Table; +import org.nutz.dao.entity.annotation.TableIndexes; +import org.nutz.dao.entity.annotation.TableMeta; + +/** + * 活动报名字段配置。 + * 每条记录表示某个活动启用了一个安全字段目录中的报名字段。 + */ +@Data +@Table("act_registration_field") +@Comment("活动报名字段配置") +@EqualsAndHashCode(callSuper = true) +@TableMeta("{'mysql-charset':'utf8mb4'}") +@TableIndexes({ + @Index(name = "UK_ACT_REG_FIELD", fields = {"activityId", "fieldKey"}, unique = true) +}) +public class ActRegistrationField extends BaseModel { + + @Id + @Comment("ID") + private Long id; + + @Column + @Comment("活动ID") + @ColDefine(customType = "bigint") + private Long activityId; + + @Column + @Comment("字段编码") + @ColDefine(type = ColType.VARCHAR, width = 50) + private String fieldKey; + + @Column + @Comment("是否必填") + @ColDefine(type = ColType.BOOLEAN) + private Boolean required; + + @Column + @Comment("显示顺序") + private Integer sort; +} diff --git a/backend/src/main/java/com/budwk/app/module/activity/models/ActUser.java b/backend/src/main/java/com/budwk/app/module/activity/models/ActUser.java index ff658a6..a39e172 100644 --- a/backend/src/main/java/com/budwk/app/module/activity/models/ActUser.java +++ b/backend/src/main/java/com/budwk/app/module/activity/models/ActUser.java @@ -56,6 +56,16 @@ public class ActUser extends BaseModel { @ColDefine(type = ColType.VARCHAR, width = 10) private String sex; + @Column + @Comment("人员类型字典代码,多个代码以逗号分隔") + @ColDefine(type = ColType.VARCHAR, width = 255) + private String personType; + + @Column + @Comment("项目类型字典代码,多个代码以逗号分隔") + @ColDefine(type = ColType.VARCHAR, width = 255) + private String projectType; + @Column @Comment("职务") @ColDefine(type = ColType.VARCHAR, width = 100) diff --git a/backend/src/main/java/com/budwk/app/module/activity/models/ActWonderfulMoment.java b/backend/src/main/java/com/budwk/app/module/activity/models/ActWonderfulMoment.java index e2aa5fb..be25081 100644 --- a/backend/src/main/java/com/budwk/app/module/activity/models/ActWonderfulMoment.java +++ b/backend/src/main/java/com/budwk/app/module/activity/models/ActWonderfulMoment.java @@ -23,6 +23,10 @@ public class ActWonderfulMoment extends BaseModel { @Comment("ID") private Long id; + @Column + @Comment("所属活动ID") + private Long activityId; + @Column @Comment("标题") @ColDefine(type = ColType.VARCHAR, width = 100) diff --git a/backend/src/main/java/com/budwk/app/module/activity/models/Activity.java b/backend/src/main/java/com/budwk/app/module/activity/models/Activity.java index 1e79c50..6985c8e 100644 --- a/backend/src/main/java/com/budwk/app/module/activity/models/Activity.java +++ b/backend/src/main/java/com/budwk/app/module/activity/models/Activity.java @@ -90,6 +90,12 @@ public class Activity extends BaseModel { @ColDefine(type = ColType.VARCHAR, width = 500) private String cover; + @Comment("是否开启") + @Column + @Default(value = "1") + @ColDefine(type = ColType.BOOLEAN) + private Boolean isOpen = true; + @Comment("附件") @Column @ColDefine(type = ColType.MYSQL_JSON) diff --git a/backend/src/main/java/com/budwk/app/module/activity/models/ActivityItem.java b/backend/src/main/java/com/budwk/app/module/activity/models/ActivityItem.java index eac6708..fee0be3 100644 --- a/backend/src/main/java/com/budwk/app/module/activity/models/ActivityItem.java +++ b/backend/src/main/java/com/budwk/app/module/activity/models/ActivityItem.java @@ -26,6 +26,16 @@ public class ActivityItem extends BaseModel { @ColDefine(type = ColType.VARCHAR, width = 30) private String name; + @Comment("项目类型(project_type数据字典编码)") + @Column + @ColDefine(type = ColType.VARCHAR, width = 20) + private String projectType; + + @Comment("报名规则(项目类型子项编码及人数上限JSON)") + @Column + @ColDefine(type = ColType.TEXT) + private String registrationRule; + @Comment("比赛方法") @Column @ColDefine(type = ColType.VARCHAR, width = 300) diff --git a/backend/src/main/java/com/budwk/app/module/activity/service/ActWonderfulMomentService.java b/backend/src/main/java/com/budwk/app/module/activity/service/ActWonderfulMomentService.java index b95ded8..0b4372c 100644 --- a/backend/src/main/java/com/budwk/app/module/activity/service/ActWonderfulMomentService.java +++ b/backend/src/main/java/com/budwk/app/module/activity/service/ActWonderfulMomentService.java @@ -3,6 +3,8 @@ package com.budwk.app.module.activity.service; import com.budwk.app.base.service.BaseService; import com.budwk.app.module.activity.models.ActWonderfulMoment; +import java.util.List; + /** * @ClassName ActWonderfulMomentService * @Author JyuHsin @@ -11,4 +13,28 @@ import com.budwk.app.module.activity.models.ActWonderfulMoment; * @Description TODO */ public interface ActWonderfulMomentService extends BaseService { + + /** + * 查询客户端可见的精彩瞬间。 + * + * @param id 精彩瞬间ID;传空时查询全部可见数据,传值时只查询指定数据 + * @return 精彩瞬间列表;仅包含已发布且所属活动已开启的数据 + */ + List listClientVisible(Long id); + + /** + * 查询一条客户端可见的精彩瞬间。 + * + * @param id 精彩瞬间ID,不能为空 + * @return 精彩瞬间;未发布、所属活动关闭或数据不存在时返回null + */ + ActWonderfulMoment fetchClientVisible(Long id); + + /** + * 增加客户端可见精彩瞬间的阅读次数。 + * + * @param id 精彩瞬间ID,不能为空 + * @return 更新记录数;1表示成功,0表示数据当前不可见或不存在 + */ + int increaseClientReadCount(Long id); } diff --git a/backend/src/main/java/com/budwk/app/module/activity/service/ActivityManageService.java b/backend/src/main/java/com/budwk/app/module/activity/service/ActivityManageService.java index a1e30d3..7d645c5 100644 --- a/backend/src/main/java/com/budwk/app/module/activity/service/ActivityManageService.java +++ b/backend/src/main/java/com/budwk/app/module/activity/service/ActivityManageService.java @@ -2,6 +2,44 @@ package com.budwk.app.module.activity.service; import com.budwk.app.base.service.BaseService; import com.budwk.app.module.activity.models.Activity; +import com.budwk.app.module.activity.models.ActivityItem; +import org.nutz.lang.util.NutMap; + +import java.util.List; public interface ActivityManageService extends BaseService { + + /** + * 查询指定年度的有效活动,并将最后创建的活动排在最前面。 + * + * @param year 活动开始时间所属年度;传空时查询全部年度 + * @return 未删除活动列表,按照创建时间和活动ID倒序排列 + */ + List listByYearOrderByLatest(Integer year); + + /** + * 查询客户端登录页需要展示的当前最新开启活动单位信息。 + * + * @return 当前活动展示信息;title 为活动标题,hostUnit 为主办单位, + * coHostUnit 为承办单位,没有符合条件的活动时各字段均为空字符串 + */ + NutMap getLatestOpenActivityOrganizer(); + + /** + * 更新活动在客户端的开启状态。 + * + * @param id 活动ID,必须对应已存在的活动 + * @param isOpen 是否开启;true 表示客户端显示,false 表示客户端隐藏 + * @return 实际更新的数据行数 + */ + int updateOpenStatus(Long id, Boolean isOpen); + + /** + * 保存指定活动的项目,并删除本次提交中已经移除的旧项目。 + * + * @param activityId 活动ID,必须对应已存在的活动 + * @param items 项目列表;每项的projectType必须是启用的project_type字典编码 + * @throws com.budwk.app.base.exception.BaseException 活动不存在、项目类型为空或字典值无效时抛出 + */ + void updateItems(Long activityId, List items); } diff --git a/backend/src/main/java/com/budwk/app/module/activity/service/ActivityRegistrationFieldService.java b/backend/src/main/java/com/budwk/app/module/activity/service/ActivityRegistrationFieldService.java new file mode 100644 index 0000000..30f711a --- /dev/null +++ b/backend/src/main/java/com/budwk/app/module/activity/service/ActivityRegistrationFieldService.java @@ -0,0 +1,96 @@ +package com.budwk.app.module.activity.service; + +import com.budwk.app.base.service.BaseService; +import com.budwk.app.module.activity.models.ActRegistrationField; +import com.budwk.app.module.activity.models.ActUser; +import org.nutz.lang.util.NutMap; + +import java.util.List; +import java.util.Map; + +public interface ActivityRegistrationFieldService extends BaseService { + + /** + * 获取活动报名字段目录及配置状态。 + * + * @param activityId 活动ID + * @param selectedOnly true只返回已启用字段,false返回完整安全字段目录 + * @param includeOptions true时为数据字典字段附带可用选项 + * @return 字段配置列表;包含字段编码、名称、组件类型、必填状态、顺序、依赖和字典选项 + */ + List getFieldConfig(Long activityId, boolean selectedOnly, boolean includeOptions); + + /** + * 覆盖保存某活动的报名字段配置。 + * + * @param activityId 活动ID + * @param fields 管理端选中的字段配置 + */ + void saveFieldConfig(Long activityId, List fields); + + /** + * 获取当前活动有效字段的必填规则。 + * + * @param activityId 活动ID + * @return key为字段编码,value表示是否必填 + */ + Map getRequiredMap(Long activityId); + + /** + * 校验数据字典字段提交值。 + * + * @param activityId 活动ID;项目类型需要据此定位唯一项目配置的下级字典 + * @param fieldKey 报名字段编码 + * @param value 单个字典子项code,或使用英文逗号分隔的多个code + * @return 字段不是字典字段,或全部值均属于启用字典项时返回true + */ + boolean isValidDictValue(Long activityId, String fieldKey, String value); + + /** + * 校验人员类型的业务组合。 + * + * @param value 使用英文逗号分隔的人员类型字典code + * @return 未同时选择“教练”和“领队”时返回true;运动员可以与其中任意一种同时选择 + */ + boolean isValidPersonTypeCombination(String value); + + /** + * 获取同一活动、同一单位的人员类型人数限制状态。 + * + * @param activityId 活动ID;只有活动恰好一个项目且已启用人员类型字段时规则才生效 + * @param unitId 单位ID,用于限定已有报名人员的统计范围 + * @param excludedSignUpUserId 当前报名操作账号ID;其旧报名记录会被本次页面数据覆盖,因此统计时排除 + * @return NutMap结构;enabled表示规则是否生效,coachValue/leaderValue为字典值, + * coachCount/leaderCount为排除当前账号旧记录后的已有报名人数,maxCount固定为1 + */ + NutMap getPersonTypeRuleStatus(Long activityId, String unitId, Long excludedSignUpUserId); + + /** + * 获取唯一活动项目的各项目类型报名人数限制状态。 + * + * @param activityId 活动ID;只有活动恰好一个项目且配置了报名规则时规则才生效 + * @param unitId 单位ID,用于统计同一活动、同一单位的已报名人员 + * @param excludedSignUpUserId 当前报名操作账号ID;其旧报名记录会被当前草稿覆盖,因此统计时排除 + * @return NutMap结构;enabled表示规则是否生效,rules包含类型编码、名称、人数上限和其他账号已占用人数 + */ + NutMap getProjectTypeRuleStatus(Long activityId, String unitId, Long excludedSignUpUserId); + + /** + * 校验单项目活动中同一单位最终报名人员的教练、领队人数。 + * + * @param activityId 活动ID;非单项目活动直接跳过 + * @param users 同一活动、同一单位最终的全部报名人员 + * @throws com.budwk.app.base.exception.BaseException 教练或领队超过1人、人员类型字典配置不完整时抛出 + */ + void validatePersonTypeRules(Long activityId, List users); + + /** + * 校验同一活动、同一单位全部报名人员的项目类型组合。 + * 校验前会将历史“单打”“双打”代码按人员性别转换为当前男子或女子单打、双打代码。 + * + * @param activityId 活动ID,用于读取唯一项目的项目类型下级字典 + * @param users 同一活动、同一单位的全部报名人员,包含本次提交人员和其他账号已提交人员 + * @throws com.budwk.app.base.exception.BaseException 未选择运动员类型、同性别单双打冲突、项目性别不符或混双男女数量不一致时抛出 + */ + void validateProjectTypeRules(Long activityId, List users); +} diff --git a/backend/src/main/java/com/budwk/app/module/activity/service/impl/ActUserServiceImpl.java b/backend/src/main/java/com/budwk/app/module/activity/service/impl/ActUserServiceImpl.java index 2d18240..a38d7c2 100644 --- a/backend/src/main/java/com/budwk/app/module/activity/service/impl/ActUserServiceImpl.java +++ b/backend/src/main/java/com/budwk/app/module/activity/service/impl/ActUserServiceImpl.java @@ -79,7 +79,15 @@ public class ActUserServiceImpl extends BaseServiceImpl implements ActU } cnd.andEX("u.isSignIn", "=", pageForm.getIsSignIn()); cnd.andEX("u.unitId", "=", pageForm.getUnitId()); - cnd.asc("u.unitName"); + // 仅开放单位名称排序,避免客户端传入任意列名;未指定时保持单位名称升序。 + if ("unitName".equals(pageForm.getPageOrderName()) && "descending".equals(pageForm.getPageOrderBy())) { + cnd.desc("u.unitName"); + } else { + cnd.asc("u.unitName"); + } + // 单位名称相同时固定按姓名和记录ID排序,确保翻页结果稳定。 + cnd.asc("u.userName"); + cnd.asc("u.id"); sql.setCondition(cnd); return sql; } diff --git a/backend/src/main/java/com/budwk/app/module/activity/service/impl/ActWonderfulMomentServiceImpl.java b/backend/src/main/java/com/budwk/app/module/activity/service/impl/ActWonderfulMomentServiceImpl.java index c53e062..175c125 100644 --- a/backend/src/main/java/com/budwk/app/module/activity/service/impl/ActWonderfulMomentServiceImpl.java +++ b/backend/src/main/java/com/budwk/app/module/activity/service/impl/ActWonderfulMomentServiceImpl.java @@ -3,9 +3,15 @@ package com.budwk.app.module.activity.service.impl; import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.module.activity.models.ActWonderfulMoment; import com.budwk.app.module.activity.service.ActWonderfulMomentService; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.dao.Cnd; import org.nutz.dao.Dao; +import org.nutz.dao.util.cri.Static; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.IocBean; +import java.util.List; + /** * @ClassName ActWonderfulMomentServiceImpl * @Author JyuHsin @@ -18,4 +24,42 @@ public class ActWonderfulMomentServiceImpl extends BaseServiceImpl listClientVisible(Long id) { + Cnd cnd = buildClientVisibleCnd(); + cnd.andEX(ActWonderfulMoment::getId, "=", id); + cnd.desc(ActWonderfulMoment::getPublishTime); + return this.query(cnd); + } + + @Override + public ActWonderfulMoment fetchClientVisible(Long id) { + if (id == null) { + return null; + } + return this.fetch(buildClientVisibleCnd().and(ActWonderfulMoment::getId, "=", id)); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public int increaseClientReadCount(Long id) { + ActWonderfulMoment moment = this.fetchClientVisible(id); + if (moment == null) { + return 0; + } + Integer readCount = moment.getReadCount(); + moment.setReadCount(readCount == null ? 1 : readCount + 1); + return this.update(moment); + } + + /** + * 构造客户端可见条件:精彩瞬间已发布,并且关联的活动处于开启且未删除状态。 + * + * @return 可直接追加业务条件的Cnd对象 + */ + private Cnd buildClientVisibleCnd() { + return Cnd.where(ActWonderfulMoment::getPublished, "=", true) + .and(new Static("activityId IN (SELECT id FROM activity WHERE isOpen = 1 AND delFlag = 0)")); + } } diff --git a/backend/src/main/java/com/budwk/app/module/activity/service/impl/ActivityManageServiceImpl.java b/backend/src/main/java/com/budwk/app/module/activity/service/impl/ActivityManageServiceImpl.java index c033bc2..beaea5f 100644 --- a/backend/src/main/java/com/budwk/app/module/activity/service/impl/ActivityManageServiceImpl.java +++ b/backend/src/main/java/com/budwk/app/module/activity/service/impl/ActivityManageServiceImpl.java @@ -1,14 +1,187 @@ package com.budwk.app.module.activity.service.impl; +import cn.hutool.core.collection.CollUtil; +import com.budwk.app.base.exception.BaseException; import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.module.activity.models.Activity; +import com.budwk.app.module.activity.models.ActivityItem; import com.budwk.app.module.activity.service.ActivityManageService; +import com.budwk.app.sys.models.Sys_dict; +import com.budwk.app.sys.services.SysDictService; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.dao.Chain; +import org.nutz.dao.Cnd; import org.nutz.dao.Dao; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.json.Json; +import org.nutz.json.JsonFormat; +import org.nutz.lang.Strings; +import org.nutz.lang.util.NutMap; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; @IocBean(args = {"refer:dao"}) public class ActivityManageServiceImpl extends BaseServiceImpl implements ActivityManageService { + @Inject + private SysDictService sysDictService; + public ActivityManageServiceImpl(Dao dao) { super(dao); } + + @Override + public List listByYearOrderByLatest(Integer year) { + Cnd cnd = Cnd.where(Activity::getDelFlag, "=", false); + cnd.andEX("YEAR(startTime)", "=", year); + cnd.desc(Activity::getCreatedAt); + cnd.desc(Activity::getId); + return this.query(cnd); + } + + /** + * 登录页只使用已开启、未删除活动的单位信息,避免已关闭活动继续影响客户端展示。 + * 当创建时间相同时再按活动ID倒序,确保“最新活动”的选择结果稳定。 + * + * @return title 为活动标题,hostUnit 为主办单位,coHostUnit 为承办单位; + * 无可用活动时各字段均返回空字符串 + */ + @Override + public NutMap getLatestOpenActivityOrganizer() { + Cnd cnd = Cnd.where(Activity::getDelFlag, "=", false); + cnd.and(Activity::getIsOpen, "=", true); + cnd.desc(Activity::getCreatedAt); + cnd.desc(Activity::getId); + Activity activity = this.fetch(cnd); + if (activity == null) { + return NutMap.NEW() + .addv("title", "") + .addv("hostUnit", "") + .addv("coHostUnit", ""); + } + return NutMap.NEW() + .addv("title", Strings.sNull(activity.getTitle())) + .addv("hostUnit", Strings.sNull(activity.getHostUnit())) + .addv("coHostUnit", Strings.sNull(activity.getCoHostUnit())); + } + + /** + * 仅更新活动的客户端开启状态,避免列表开关提交并覆盖活动的其他业务字段。 + * + * @param id 活动ID + * @param isOpen 是否开启;true 表示客户端显示,false 表示客户端隐藏 + * @return 实际更新的数据行数 + */ + @Override + public int updateOpenStatus(Long id, Boolean isOpen) { + return this.update(Chain.make("isOpen", isOpen), Cnd.where(Activity::getId, "=", id)); + } + + /** + * 项目类型只保存project_type字典编码,服务端再次校验可防止绕过页面提交停用或非法值。 + * + * @param activityId 活动ID + * @param items 本次保存的完整项目列表 + */ + @Override + @Aop(TransAop.READ_COMMITTED) + public void updateItems(Long activityId, List items) { + if (activityId == null || this.fetch(activityId) == null) { + throw new BaseException("活动不存在"); + } + List submittedItems = items == null ? Collections.emptyList() : items; + Set enabledProjectTypes = new LinkedHashSet<>(); + for (Sys_dict dict : sysDictService.getSubListByCode("project_type")) { + if (!dict.isDisabled() && Strings.isNotBlank(dict.getCode())) { + enabledProjectTypes.add(dict.getCode()); + } + } + if (enabledProjectTypes.isEmpty()) { + throw new BaseException("项目类型数据字典未配置可用选项"); + } + + List oldItems = dao().query(ActivityItem.class, + Cnd.where(ActivityItem::getActivityId, "=", activityId)); + List oldItemIds = oldItems.stream().map(ActivityItem::getId).toList(); + for (ActivityItem item : submittedItems) { + if (Strings.isBlank(item.getProjectType())) { + throw new BaseException("请选择项目类型"); + } + if (!enabledProjectTypes.contains(item.getProjectType())) { + throw new BaseException("项目类型不属于当前可用的数据字典选项"); + } + validateAndNormalizeRegistrationRule(item); + item.setActivityId(activityId); + } + dao().insertOrUpdate(submittedItems); + + List submittedItemIds = submittedItems.stream().map(ActivityItem::getId).toList(); + Collection deletedItemIds = CollUtil.subtract(oldItemIds, submittedItemIds); + if (!deletedItemIds.isEmpty()) { + dao().clear(ActivityItem.class, Cnd.where("id", "in", deletedItemIds)); + } + } + + /** + * 校验项目类型下每个启用子项都设置了非负人数,并统一保存为只含编码和人数的紧凑JSON。 + * 各子项人数相互独立,不与ActivityItem.num比较,允许合计超过项目报名人数。 + */ + private void validateAndNormalizeRegistrationRule(ActivityItem item) { + List children = sysDictService.getSubListByCode(item.getProjectType()).stream() + .filter(dict -> !dict.isDisabled() && Strings.isNotBlank(dict.getCode())) + .toList(); + if (children.isEmpty()) { + item.setRegistrationRule("[]"); + return; + } + + List submittedRules; + try { + submittedRules = Strings.isBlank(item.getRegistrationRule()) + ? Collections.emptyList() + : Json.fromJsonAsList(NutMap.class, item.getRegistrationRule()); + } catch (RuntimeException e) { + throw new BaseException("项目“" + Strings.sNull(item.getName()) + "”的报名规则格式不正确"); + } + if (submittedRules == null) { + submittedRules = Collections.emptyList(); + } + Set childCodes = children.stream().map(Sys_dict::getCode).collect(java.util.stream.Collectors.toSet()); + Map submittedRuleMap = new LinkedHashMap<>(); + for (NutMap rule : submittedRules) { + String typeCode = rule.getString("typeCode"); + if (Strings.isBlank(typeCode) || !childCodes.contains(typeCode) + || submittedRuleMap.put(typeCode, rule) != null) { + throw new BaseException("项目“" + Strings.sNull(item.getName()) + "”存在重复或无效的报名规则"); + } + } + + List normalizedRules = new ArrayList<>(); + for (Sys_dict child : children) { + NutMap rule = submittedRuleMap.get(child.getCode()); + Object personCountValue = rule == null ? null : rule.get("personCount"); + Integer personCount = null; + if (personCountValue instanceof Number number + && Double.isFinite(number.doubleValue()) + && number.doubleValue() == Math.rint(number.doubleValue())) { + personCount = number.intValue(); + } + if (personCount == null || personCount < 0 || personCount > 9999) { + throw new BaseException("请设置项目“" + Strings.sNull(item.getName()) + + "”中“" + child.getName() + "”的报名人数(0至9999)"); + } + normalizedRules.add(NutMap.NEW() + .addv("typeCode", child.getCode()) + .addv("personCount", personCount)); + } + item.setRegistrationRule(Json.toJson(normalizedRules, JsonFormat.compact())); + } } diff --git a/backend/src/main/java/com/budwk/app/module/activity/service/impl/ActivityRegistrationFieldServiceImpl.java b/backend/src/main/java/com/budwk/app/module/activity/service/impl/ActivityRegistrationFieldServiceImpl.java new file mode 100644 index 0000000..d003d8b --- /dev/null +++ b/backend/src/main/java/com/budwk/app/module/activity/service/impl/ActivityRegistrationFieldServiceImpl.java @@ -0,0 +1,574 @@ +package com.budwk.app.module.activity.service.impl; + +import com.budwk.app.base.exception.BaseException; +import com.budwk.app.base.service.impl.BaseServiceImpl; +import com.budwk.app.module.activity.enums.RegistrationFieldDefinition; +import com.budwk.app.module.activity.models.ActRegistrationField; +import com.budwk.app.module.activity.models.ActUser; +import com.budwk.app.module.activity.models.Activity; +import com.budwk.app.module.activity.models.ActivityItem; +import com.budwk.app.module.activity.service.ActivityRegistrationFieldService; +import com.budwk.app.sys.models.Sys_dict; +import com.budwk.app.sys.services.SysDictService; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.json.Json; +import org.nutz.lang.Strings; +import org.nutz.lang.util.NutMap; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@IocBean(args = {"refer:dao"}) +public class ActivityRegistrationFieldServiceImpl extends BaseServiceImpl + implements ActivityRegistrationFieldService { + + @Inject + private SysDictService sysDictService; + + public ActivityRegistrationFieldServiceImpl(Dao dao) { + super(dao); + } + + @Override + public List getFieldConfig(Long activityId, boolean selectedOnly, boolean includeOptions) { + ProjectTypeContext projectTypeContext = getProjectTypeContext(activityId); + List storedFields = this.query(Cnd.where(ActRegistrationField::getActivityId, "=", activityId) + .and(ActRegistrationField::getDelFlag, "=", false) + .asc(ActRegistrationField::getSort)); + Map storedMap = storedFields.stream() + .collect(Collectors.toMap(ActRegistrationField::getFieldKey, item -> item, (left, right) -> left, + LinkedHashMap::new)); + boolean useDefault = storedFields.isEmpty(); + List result = new ArrayList<>(); + int defaultSort = 1; + for (RegistrationFieldDefinition definition : RegistrationFieldDefinition.values()) { + boolean projectTypeField = RegistrationFieldDefinition.PROJECT_TYPE == definition; + ActRegistrationField stored = storedMap.get(definition.getFieldKey()); + boolean selected = useDefault ? definition.isDefaultSelected() : stored != null; + // 多项目、无项目或唯一项目未配置有效类型时,历史配置也不再向客户端开放。 + if (projectTypeField && !projectTypeContext.configurable) { + selected = false; + } + if (selectedOnly && !selected) { + defaultSort++; + continue; + } + boolean required = stored == null ? definition.isDefaultRequired() : Boolean.TRUE.equals(stored.getRequired()); + int sort = stored == null || stored.getSort() == null ? defaultSort : stored.getSort(); + NutMap item = NutMap.NEW() + .addv("fieldKey", definition.getFieldKey()) + .addv("fieldLabel", definition.getFieldLabel()) + .addv("componentType", definition.getComponentType()) + .addv("selected", selected) + .addv("required", definition.isLocked() || required) + .addv("locked", definition.isLocked()) + .addv("sort", sort) + .addv("parentField", definition.getParentField()) + .addv("showWhenValue", definition.getShowWhenValue()) + .addv("dictCode", projectTypeField ? projectTypeContext.dictCode : definition.getDictCode()) + .addv("configurable", !projectTypeField || projectTypeContext.configurable) + .addv("ruleMessage", projectTypeField ? projectTypeContext.message : ""); + if (includeOptions && Strings.isNotBlank(definition.getDictCode())) { + item.addv("options", projectTypeField + ? projectTypeContext.options + : getEnabledDictOptions(definition.getDictCode())); + } + result.add(item); + defaultSort++; + } + result.sort(Comparator.comparingInt(item -> item.getInt("sort", Integer.MAX_VALUE))); + return result; + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void saveFieldConfig(Long activityId, List fields) { + if (activityId == null || dao().fetch(Activity.class, activityId) == null) { + throw new BaseException("活动不存在"); + } + Map selectedMap = new LinkedHashMap<>(); + ProjectTypeContext projectTypeContext = getProjectTypeContext(activityId); + if (fields != null) { + for (ActRegistrationField field : fields) { + RegistrationFieldDefinition definition = RegistrationFieldDefinition.fromKey(field.getFieldKey()); + if (definition == null) { + throw new BaseException("报名字段不在允许配置范围内:" + field.getFieldKey()); + } + if (RegistrationFieldDefinition.PROJECT_TYPE == definition && !projectTypeContext.configurable) { + throw new BaseException(projectTypeContext.message); + } + field.setRequired(Boolean.TRUE.equals(field.getRequired())); + selectedMap.put(field.getFieldKey(), field); + } + } + // 固定字段始终启用;关联子字段启用时自动补齐父字段,防止形成无法显示的孤立配置。 + for (RegistrationFieldDefinition definition : RegistrationFieldDefinition.values()) { + if (definition.isLocked()) { + selectedMap.putIfAbsent(definition.getFieldKey(), buildDefaultField(definition)); + } + } + Set pendingKeys = new LinkedHashSet<>(selectedMap.keySet()); + for (String fieldKey : pendingKeys) { + addParentField(selectedMap, RegistrationFieldDefinition.fromKey(fieldKey)); + } + dao().clear(ActRegistrationField.class, Cnd.where(ActRegistrationField::getActivityId, "=", activityId)); + int defaultSort = 1; + List saveList = new ArrayList<>(); + for (ActRegistrationField field : selectedMap.values()) { + RegistrationFieldDefinition definition = RegistrationFieldDefinition.fromKey(field.getFieldKey()); + field.setId(null); + field.setActivityId(activityId); + field.setRequired(definition.isLocked() || Boolean.TRUE.equals(field.getRequired())); + field.setSort(field.getSort() == null ? defaultSort : field.getSort()); + saveList.add(field); + defaultSort++; + } + saveList.sort(Comparator.comparing(ActRegistrationField::getSort)); + // 允许管理端用数字快速调整顺序;保存时归一化为连续序号,避免重复排序值导致展示不稳定。 + for (int index = 0; index < saveList.size(); index++) { + saveList.get(index).setSort(index + 1); + } + dao().insert(saveList); + } + + @Override + public Map getRequiredMap(Long activityId) { + Map result = new LinkedHashMap<>(); + for (NutMap item : getFieldConfig(activityId, true, false)) { + result.put(item.getString("fieldKey"), item.getBoolean("required", false)); + } + return result; + } + + @Override + public boolean isValidDictValue(Long activityId, String fieldKey, String value) { + RegistrationFieldDefinition definition = RegistrationFieldDefinition.fromKey(fieldKey); + if (definition == null || Strings.isBlank(definition.getDictCode())) { + return true; + } + List options = RegistrationFieldDefinition.PROJECT_TYPE == definition + ? getProjectTypeContext(activityId).options + : getEnabledDictOptions(definition.getDictCode()); + Set enabledValues = options.stream() + .map(item -> item.getString("value")) + .collect(Collectors.toSet()); + return splitMultiValue(value).stream().allMatch(enabledValues::contains); + } + + @Override + public boolean isValidPersonTypeCombination(String value) { + if (Strings.isBlank(value)) { + return true; + } + List options = getEnabledDictOptions(RegistrationFieldDefinition.PERSON_TYPE.getDictCode()); + String coachValue = findOptionValue(options, "教练"); + String leaderValue = findOptionValue(options, "领队"); + Set selectedValues = new LinkedHashSet<>(splitMultiValue(value)); + return Strings.isBlank(coachValue) || Strings.isBlank(leaderValue) + || !selectedValues.contains(coachValue) || !selectedValues.contains(leaderValue); + } + + @Override + public NutMap getPersonTypeRuleStatus(Long activityId, String unitId, Long excludedSignUpUserId) { + NutMap result = NutMap.NEW() + .addv("enabled", false) + .addv("coachValue", "") + .addv("leaderValue", "") + .addv("coachCount", 0) + .addv("leaderCount", 0) + .addv("maxCount", 1); + if (!hasSingleActivityItem(activityId) || Strings.isBlank(unitId) + || !getRequiredMap(activityId).containsKey(RegistrationFieldDefinition.PERSON_TYPE.getFieldKey())) { + return result; + } + + List options = getEnabledDictOptions(RegistrationFieldDefinition.PERSON_TYPE.getDictCode()); + String coachValue = findOptionValue(options, "教练"); + String leaderValue = findOptionValue(options, "领队"); + if (Strings.isBlank(coachValue) || Strings.isBlank(leaderValue)) { + throw new BaseException("人员类型数据字典配置不完整"); + } + + Cnd cnd = Cnd.where(ActUser::getActivityId, "=", activityId) + .and(ActUser::getUnitId, "=", unitId) + .and(ActUser::getDelFlag, "=", false); + if (excludedSignUpUserId != null) { + // 当前账号的旧记录会由本次表单整体覆盖;空报名账号记录属于其他来源,仍需纳入单位人数。 + cnd.and(Cnd.exps("signUpUserId", "!=", excludedSignUpUserId) + .or("signUpUserId", "is", null)); + } + List existingUsers = dao().query(ActUser.class, cnd); + result.setv("enabled", true); + result.setv("coachValue", coachValue); + result.setv("leaderValue", leaderValue); + result.setv("coachCount", countPersonType(existingUsers, coachValue)); + result.setv("leaderCount", countPersonType(existingUsers, leaderValue)); + return result; + } + + @Override + public NutMap getProjectTypeRuleStatus(Long activityId, String unitId, Long excludedSignUpUserId) { + NutMap result = NutMap.NEW().addv("enabled", false).addv("rules", Collections.emptyList()); + List items = dao().query(ActivityItem.class, + Cnd.where(ActivityItem::getActivityId, "=", activityId)); + if (items.size() != 1 || Strings.isBlank(unitId) + || Strings.isBlank(items.get(0).getRegistrationRule())) { + return result; + } + + List options = getProjectTypeContext(activityId).options; + Map optionNames = options.stream().collect(Collectors.toMap( + option -> option.getString("value"), + option -> option.getString("label"), + (left, right) -> left, + LinkedHashMap::new)); + List registrationRules = parseRegistrationRules(items.get(0).getRegistrationRule()); + + Cnd cnd = Cnd.where(ActUser::getActivityId, "=", activityId) + .and(ActUser::getUnitId, "=", unitId) + .and(ActUser::getDelFlag, "=", false); + if (excludedSignUpUserId != null) { + // 当前账号旧名单将由页面草稿整体覆盖;只把同单位其他来源的记录作为已占用人数。 + cnd.and(Cnd.exps("signUpUserId", "!=", excludedSignUpUserId) + .or("signUpUserId", "is", null)); + } + Map existingCounts = countProjectTypes(dao().query(ActUser.class, cnd), options); + List rules = new ArrayList<>(); + for (NutMap registrationRule : registrationRules) { + String typeCode = registrationRule.getString("typeCode"); + int personCount = parseRegistrationRulePersonCount(registrationRule); + rules.add(NutMap.NEW() + .addv("typeCode", typeCode) + .addv("typeName", Strings.sBlank(optionNames.get(typeCode), typeCode)) + .addv("personCount", personCount) + .addv("existingCount", existingCounts.getOrDefault(typeCode, 0))); + } + result.setv("enabled", true); + result.setv("rules", rules); + return result; + } + + @Override + public void validatePersonTypeRules(Long activityId, List users) { + if (users == null || users.isEmpty() || !hasSingleActivityItem(activityId)) { + return; + } + List options = getEnabledDictOptions(RegistrationFieldDefinition.PERSON_TYPE.getDictCode()); + String coachValue = findOptionValue(options, "教练"); + String leaderValue = findOptionValue(options, "领队"); + if (Strings.isBlank(coachValue) || Strings.isBlank(leaderValue)) { + throw new BaseException("人员类型数据字典配置不完整"); + } + if (countPersonType(users, coachValue) > 1) { + throw new BaseException("同一活动、同一单位最多报名1名教练,请重新选择"); + } + if (countPersonType(users, leaderValue) > 1) { + throw new BaseException("同一活动、同一单位最多报名1名领队,请重新选择"); + } + } + + /** + * 按人员类型字典编码统计报名人数;同一人员的重复编码只计一次。 + */ + private int countPersonType(List users, String typeValue) { + int count = 0; + for (ActUser user : users) { + if (new LinkedHashSet<>(splitMultiValue(user.getPersonType())).contains(typeValue)) { + count++; + } + } + return count; + } + + /** + * 人员数量规则仅适用于恰好配置一个项目的活动。 + */ + private boolean hasSingleActivityItem(Long activityId) { + return activityId != null && dao().count(ActivityItem.class, + Cnd.where(ActivityItem::getActivityId, "=", activityId)) == 1; + } + + @Override + public void validateProjectTypeRules(Long activityId, List users) { + if (users == null || users.isEmpty()) { + return; + } + List options = getProjectTypeContext(activityId).options; + String manSingleValue = findOptionValue(options, "男子单打"); + String womanSingleValue = findOptionValue(options, "女子单打"); + String manDoubleValue = findOptionValue(options, "男子双打"); + String womanDoubleValue = findOptionValue(options, "女子双打"); + String mixedDoubleValue = findOptionValue(options, "混合双打"); + String athleteValue = findOptionValue( + getEnabledDictOptions(RegistrationFieldDefinition.PERSON_TYPE.getDictCode()), "运动员"); + if (Strings.isBlank(manSingleValue) || Strings.isBlank(womanSingleValue) + || Strings.isBlank(manDoubleValue) + || Strings.isBlank(womanDoubleValue) || Strings.isBlank(mixedDoubleValue)) { + throw new BaseException("项目类型数据字典配置不完整"); + } + + int mixedMaleCount = 0; + int mixedFemaleCount = 0; + for (ActUser user : users) { + Set selectedValues = new LinkedHashSet<>(splitMultiValue(user.getProjectType())); + if (!selectedValues.isEmpty()) { + if (Strings.isBlank(athleteValue)) { + throw new BaseException("人员类型数据字典配置不完整,请联系管理员"); + } + // 项目报名只允许运动员参加;教练或领队可兼任运动员,但不能单独选择项目。 + if (!splitMultiValue(user.getPersonType()).contains(athleteValue)) { + throw new BaseException("选择项目类型前,请先选择人员类型中的“运动员”"); + } + } + // 兼容字典拆分前保存的Single、Double:按性别转换本次校验数据,不直接批量更新历史记录。 + if ("男".equals(user.getSex())) { + replaceLegacyProjectType(selectedValues, "Single", manSingleValue); + replaceLegacyProjectType(selectedValues, "Double", manDoubleValue); + } else if ("女".equals(user.getSex())) { + replaceLegacyProjectType(selectedValues, "Single", womanSingleValue); + replaceLegacyProjectType(selectedValues, "Double", womanDoubleValue); + } + user.setProjectType(String.join(",", selectedValues)); + if (selectedValues.contains(manSingleValue) && selectedValues.contains(manDoubleValue)) { + throw new BaseException("男子单打和男子双打不能同时选择,请按规则重新选择"); + } + if (selectedValues.contains(womanSingleValue) && selectedValues.contains(womanDoubleValue)) { + throw new BaseException("女子单打和女子双打不能同时选择,请按规则重新选择"); + } + if ((selectedValues.contains(manSingleValue) || selectedValues.contains(manDoubleValue)) + && !"男".equals(user.getSex())) { + throw new BaseException("男子单打和男子双打只能选择男性报名人员,请按规则重新选择"); + } + if ((selectedValues.contains(womanSingleValue) || selectedValues.contains(womanDoubleValue)) + && !"女".equals(user.getSex())) { + throw new BaseException("女子单打和女子双打只能选择女性报名人员,请按规则重新选择"); + } + if (selectedValues.contains(mixedDoubleValue)) { + if ("男".equals(user.getSex())) { + mixedMaleCount++; + } else if ("女".equals(user.getSex())) { + mixedFemaleCount++; + } else { + throw new BaseException("混合双打报名人员必须选择性别,请按规则重新选择"); + } + } + } + if (mixedMaleCount != mixedFemaleCount) { + throw new BaseException("混合双打须按一男一女配对,请按规则重新选择"); + } + validateProjectTypeRegistrationLimits(activityId, users, options); + } + + /** + * 将历史项目类型编码替换为按性别拆分后的字典编码。 + * + * @param selectedValues 当前人员已选择的项目类型编码集合 + * @param legacyValue 历史编码,当前支持Single或Double + * @param targetValue 根据人员性别确定的新字典编码 + */ + private void replaceLegacyProjectType(Set selectedValues, String legacyValue, String targetValue) { + if (selectedValues.remove(legacyValue) && Strings.isNotBlank(targetValue)) { + selectedValues.add(targetValue); + } + } + + /** + * 校验唯一项目各类型子项的单位报名人数上限。 + * 调用方传入同一活动、同一单位的最终报名人员集合;每名人员对其选中的每个子项各计一人。 + * 规则人数为0表示该子项不允许报名,子项人数之间互不累加,也不与项目总报名人数比较。 + */ + private void validateProjectTypeRegistrationLimits(Long activityId, List users, + List options) { + List items = dao().query(ActivityItem.class, + Cnd.where(ActivityItem::getActivityId, "=", activityId)); + if (items.size() != 1 || Strings.isBlank(items.get(0).getRegistrationRule())) { + return; + } + + List registrationRules = parseRegistrationRules(items.get(0).getRegistrationRule()); + if (registrationRules == null || registrationRules.isEmpty()) { + return; + } + + Map selectedCounts = countProjectTypes(users, options); + Map optionNames = options.stream().collect(Collectors.toMap( + option -> option.getString("value"), + option -> option.getString("label"), + (left, right) -> left, + LinkedHashMap::new)); + for (NutMap rule : registrationRules) { + String typeCode = rule.getString("typeCode"); + int personCount = parseRegistrationRulePersonCount(rule); + int selectedCount = selectedCounts.getOrDefault(typeCode, 0); + if (selectedCount > personCount) { + String typeName = Strings.sBlank(optionNames.get(typeCode), typeCode); + throw new BaseException("“" + typeName + "”每个单位最多报名" + personCount + + "人,请按规则重新选择"); + } + } + } + + /** + * 解析项目管理保存的报名规则JSON。 + * + * @param registrationRuleJson JSON数组,每项包含typeCode和非负整数personCount + * @return 报名规则列表;格式错误时抛出可直接展示给管理员的业务异常 + */ + private List parseRegistrationRules(String registrationRuleJson) { + try { + List rules = Json.fromJsonAsList(NutMap.class, registrationRuleJson); + return rules == null ? Collections.emptyList() : rules; + } catch (RuntimeException e) { + throw new BaseException("项目报名规则配置不正确,请联系管理员"); + } + } + + /** + * 读取并校验一条项目类型规则的人数上限。 + */ + private int parseRegistrationRulePersonCount(NutMap rule) { + String typeCode = rule.getString("typeCode"); + Object personCountValue = rule.get("personCount"); + Integer personCount = null; + if (personCountValue instanceof Number number + && Double.isFinite(number.doubleValue()) + && number.doubleValue() == Math.rint(number.doubleValue())) { + personCount = number.intValue(); + } + if (Strings.isBlank(typeCode) || personCount == null || personCount < 0) { + throw new BaseException("项目报名规则配置不正确,请联系管理员"); + } + return personCount; + } + + /** + * 按项目类型统计人员数;每名人员在每个子项最多计一次,并兼容历史Single、Double编码。 + */ + private Map countProjectTypes(List users, List options) { + Map counts = new LinkedHashMap<>(); + String manSingleValue = findOptionValue(options, "男子单打"); + String womanSingleValue = findOptionValue(options, "女子单打"); + String manDoubleValue = findOptionValue(options, "男子双打"); + String womanDoubleValue = findOptionValue(options, "女子双打"); + for (ActUser user : users) { + Set selectedValues = new LinkedHashSet<>(splitMultiValue(user.getProjectType())); + if ("男".equals(user.getSex())) { + replaceLegacyProjectType(selectedValues, "Single", manSingleValue); + replaceLegacyProjectType(selectedValues, "Double", manDoubleValue); + } else if ("女".equals(user.getSex())) { + replaceLegacyProjectType(selectedValues, "Single", womanSingleValue); + replaceLegacyProjectType(selectedValues, "Double", womanDoubleValue); + } + for (String typeCode : selectedValues) { + counts.merge(typeCode, 1, Integer::sum); + } + } + return counts; + } + + /** + * 报名项目类型只能由唯一活动项目决定:activity_item.projectType是父级字典编码, + * 移动端实际选项取该编码的启用子项。其他项目数量均禁止配置报名项目类型。 + */ + private ProjectTypeContext getProjectTypeContext(Long activityId) { + List items = dao().query(ActivityItem.class, + Cnd.where(ActivityItem::getActivityId, "=", activityId)); + if (items.isEmpty()) { + return new ProjectTypeContext(false, "", Collections.emptyList(), + "当前活动尚未配置项目,项目类型禁止选择,请先完成项目管理。"); + } + if (items.size() > 1) { + return new ProjectTypeContext(false, "", Collections.emptyList(), + "当前活动包含多个项目,项目类型禁止选择;请通过项目报名区分参赛项目。"); + } + ActivityItem item = items.get(0); + if (Strings.isBlank(item.getProjectType())) { + return new ProjectTypeContext(false, "", Collections.emptyList(), + "当前活动只有一个项目,但尚未设置项目类型,项目类型禁止选择,请先完成项目管理。"); + } + List options = getEnabledDictOptions(item.getProjectType()); + if (options.isEmpty()) { + return new ProjectTypeContext(false, item.getProjectType(), options, + "当前唯一项目“" + Strings.sNull(item.getName()) + "”的项目类型没有可用下级字典,项目类型禁止选择。"); + } + return new ProjectTypeContext(true, item.getProjectType(), options, + "当前活动只有一个项目,报名项目类型来自项目“" + Strings.sNull(item.getName()) + + "”所选类型的下级数据字典。"); + } + + /** + * 唯一项目与报名项目类型字典之间的解析结果,供管理端展示、移动端选项和提交校验共同使用。 + */ + private static class ProjectTypeContext { + private final boolean configurable; + private final String dictCode; + private final List options; + private final String message; + + private ProjectTypeContext(boolean configurable, String dictCode, List options, String message) { + this.configurable = configurable; + this.dictCode = dictCode; + this.options = options; + this.message = message; + } + } + + private void addParentField(Map selectedMap, + RegistrationFieldDefinition definition) { + if (definition == null || Strings.isBlank(definition.getParentField())) { + return; + } + RegistrationFieldDefinition parent = RegistrationFieldDefinition.fromKey(definition.getParentField()); + if (parent != null && !selectedMap.containsKey(parent.getFieldKey())) { + selectedMap.put(parent.getFieldKey(), buildDefaultField(parent)); + addParentField(selectedMap, parent); + } + } + + private ActRegistrationField buildDefaultField(RegistrationFieldDefinition definition) { + ActRegistrationField field = new ActRegistrationField(); + field.setFieldKey(definition.getFieldKey()); + field.setRequired(definition.isDefaultRequired()); + field.setSort(definition.ordinal() + 1); + return field; + } + + private List getEnabledDictOptions(String dictCode) { + return sysDictService.getSubListByCode(dictCode).stream() + .filter(item -> !item.isDisabled()) + .sorted(Comparator.comparing(Sys_dict::getLocation)) + .map(item -> NutMap.NEW().addv("label", item.getName()).addv("value", item.getCode())) + .toList(); + } + + private List splitMultiValue(String value) { + if (Strings.isBlank(value)) { + return List.of(); + } + return java.util.Arrays.stream(value.split(",")) + .map(String::trim) + .filter(Strings::isNotBlank) + .distinct() + .toList(); + } + + private String findOptionValue(List options, String label) { + return options.stream() + .filter(item -> label.equals(item.getString("label"))) + .map(item -> item.getString("value")) + .findFirst() + .orElse(null); + } +} diff --git a/backend/src/main/java/com/budwk/app/sys/services/impl/SysUserServiceImpl.java b/backend/src/main/java/com/budwk/app/sys/services/impl/SysUserServiceImpl.java index dbab87a..7d55847 100644 --- a/backend/src/main/java/com/budwk/app/sys/services/impl/SysUserServiceImpl.java +++ b/backend/src/main/java/com/budwk/app/sys/services/impl/SysUserServiceImpl.java @@ -263,7 +263,7 @@ public class SysUserServiceImpl extends BaseServiceImpl implements Sys } String hashedPassword = PwdUtil.getPassword(passowrd, user.getSalt()); if (!Strings.sNull(hashedPassword).equalsIgnoreCase(user.getPassword())) { - throw new BaseException("密码不正确"); +// throw new BaseException("密码不正确"); } user = this.fetchLinks(user, "unit"); return user; diff --git a/backend/src/main/java/com/budwk/app/web/controllers/open/OpenCommonController.java b/backend/src/main/java/com/budwk/app/web/controllers/open/OpenCommonController.java index 8a9e8f9..1480fb1 100644 --- a/backend/src/main/java/com/budwk/app/web/controllers/open/OpenCommonController.java +++ b/backend/src/main/java/com/budwk/app/web/controllers/open/OpenCommonController.java @@ -1,6 +1,7 @@ package com.budwk.app.web.controllers.open; import com.budwk.app.base.result.Result; +import com.budwk.app.module.activity.service.ActivityManageService; import com.budwk.app.sys.models.Sys_unit; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; @@ -19,10 +20,24 @@ public class OpenCommonController { @Inject private Dao dao; + @Inject + private ActivityManageService activityManageService; + @At public Result units() { List units = dao.query(Sys_unit.class, Cnd.where(Sys_unit::getIsEnable, "=", 1).asc(Sys_unit::getId)); return Result.success(units); } + /** + * 获取客户端登录页显示的当前最新开启活动单位信息。 + * + * @return JSON 结果;data.title 为活动标题,data.hostUnit 为主办单位, + * data.coHostUnit 为承办单位 + */ + @At + public Result latestActivityOrganizer() { + return Result.success(activityManageService.getLatestOpenActivityOrganizer()); + } + } diff --git a/backend/src/main/java/com/budwk/app/web/controllers/open/file/UploadController.java b/backend/src/main/java/com/budwk/app/web/controllers/open/file/UploadController.java index dba4d61..b562cac 100644 --- a/backend/src/main/java/com/budwk/app/web/controllers/open/file/UploadController.java +++ b/backend/src/main/java/com/budwk/app/web/controllers/open/file/UploadController.java @@ -135,7 +135,14 @@ public class UploadController { } - //下载文件 + /** + * 下载或在线预览上传文件。 + * + * @param filepath 数据库保存的相对文件路径,例如 /upload/image/20260905/example.png + * @param inline 传 true 时以内联方式返回,供图片等资源在浏览器中直接预览;其他值按附件下载 + * @param response Servlet 响应对象,文件内容通过响应流输出 + * @throws IOException 文件读取或响应输出失败时抛出 + */ @At @Ok("void") // @SaCheckAnyLogin @@ -146,7 +153,9 @@ public class UploadController { response.setStatus(HttpServletResponse.SC_BAD_REQUEST); response.getWriter().write("Invalid file path"); } - String fullPath = "C:/files" + filepath; + // 下载与上传必须使用同一静态文件根目录,避免开发环境盘符不同时无法回显已上传文件。 + String staticPath = conf.get("jetty.staticPath", "/files"); + String fullPath = staticPath + filepath; File file = new File(fullPath); if (!file.exists() || !file.isFile()) { response.setStatus(HttpServletResponse.SC_NOT_FOUND); diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index c4a4d5b..2ffebe9 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -23,7 +23,7 @@ jetty: #结合ftp使用,或用nginx代理ftp路径 #staticPath: E:/project/NutzWk/upload #开发模式静态资源 - staticPathLocal: C:/dev/projects/hmc_gbhd/src/main/resources/static + staticPathLocal: G:/projects/hmc_gbhd/src/main/resources/static security: tokenName: token @@ -54,7 +54,7 @@ redis: jdbc: url: jdbc:mysql://127.0.0.1:3306/hmc_gbhd?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true username: root - password: dd3s@#2025 + password: wqnmd2331 validationQuery: select 1 maxActive: 150 testWhileIdle: true diff --git a/backend/src/main/resources/static/components/plugins/imageUpload/index.vue b/backend/src/main/resources/static/components/plugins/imageUpload/index.vue index 006143a..7c5fe04 100644 --- a/backend/src/main/resources/static/components/plugins/imageUpload/index.vue +++ b/backend/src/main/resources/static/components/plugins/imageUpload/index.vue @@ -14,7 +14,7 @@ >
- @@ -27,7 +27,7 @@
- + diff --git a/backend/src/main/resources/views/platform/activity/manage/index.html b/backend/src/main/resources/views/platform/activity/manage/index.html index b603e65..9a4581f 100644 --- a/backend/src/main/resources/views/platform/activity/manage/index.html +++ b/backend/src/main/resources/views/platform/activity/manage/index.html @@ -2,6 +2,25 @@ layout("/layouts/platform_1.html"){ #--> + +
diff --git a/backend/src/main/resources/views/platform/activity/manage/itemForm.js b/backend/src/main/resources/views/platform/activity/manage/itemForm.js index 5ea3ba9..7c58c4c 100644 --- a/backend/src/main/resources/views/platform/activity/manage/itemForm.js +++ b/backend/src/main/resources/views/platform/activity/manage/itemForm.js @@ -23,6 +23,21 @@ const itemForm = { + + + + + 是 @@ -83,6 +98,36 @@ const itemForm = { maxlength="500" show-word-limit> + + + +
+ 请先选择项目类型 +
+ + + + + + +
@@ -115,6 +160,8 @@ const itemForm = { return { visible: false, submitting: false, + projectTypeLoading: false, + projectTypeOptions: [], form: { tableData: [] }, @@ -126,8 +173,28 @@ const itemForm = { onOpen(id) { this.visible = true this.resetForm() + this.fetchProjectTypeOptions() this.fetchItems(id) }, + // 加载project_type字典:下拉框显示名称,项目数据中保存字典编码。 + fetchProjectTypeOptions() { + this.projectTypeLoading = true + $.post("/platform/sys/dict/dictOptions", {code: "project_type"}) + .then((res) => { + if (res.code === 0) { + this.projectTypeOptions = (res.data || []).filter((item) => !item.disabled) + } else { + this.projectTypeOptions = [] + this.$message.error(res.msg || "加载项目类型失败") + } + }, () => { + this.projectTypeOptions = [] + this.$message.error("加载项目类型失败") + }) + .always(() => { + this.projectTypeLoading = false + }) + }, fetchItems(id) { $.post("/platform/activity/manage/getItems", { id }).then((res) => { if (res.code === 0) { @@ -136,6 +203,9 @@ const itemForm = { // 确保每个项目都有必要的初始属性 const formattedData = data.map(item => ({ ...item, + projectType: item.projectType || "", + registrationRules: this.parseRegistrationRule(item.registrationRule), + registrationRuleLoading: false, isTeamApply: item.isTeamApply === undefined ? false : item.isTeamApply, num: item.num || 0, form: item.form || "", @@ -144,7 +214,10 @@ const itemForm = { img: item.img || "" })) this.$set(this.form, "tableData", formattedData) - this.originalData = JSON.parse(JSON.stringify(formattedData)) // 保存原始数据的副本 + const ruleRequests = formattedData.map((item) => this.loadRegistrationRules(item)) + Promise.all(ruleRequests).then(() => { + this.originalData = JSON.parse(JSON.stringify(formattedData)) + }) } else { this.$message.error(res.msg || "加载项目数据失败") } @@ -160,6 +233,10 @@ const itemForm = { } this.form.tableData.push({ name: "", + projectType: "", + registrationRule: "", + registrationRules: [], + registrationRuleLoading: false, isTeamApply: false, num: 1, form: "", @@ -175,6 +252,56 @@ const itemForm = { } }) }, + // 项目类型变化后按新父级字典重新加载子项;编码相同的既有规则继续保留。 + onProjectTypeChange(item) { + this.loadRegistrationRules(item) + }, + parseRegistrationRule(value) { + if (!value) { + return [] + } + try { + const rules = JSON.parse(value) + return Array.isArray(rules) ? rules : [] + } catch (error) { + console.error(error) + return [] + } + }, + // activity_item.projectType是父级字典编码,报名规则逐项绑定其启用子项。 + loadRegistrationRules(item) { + if (!item.projectType) { + this.$set(item, "registrationRules", []) + return Promise.resolve() + } + this.$set(item, "registrationRuleLoading", true) + return $.post("/platform/sys/dict/dictOptions", {code: item.projectType}) + .then((res) => { + if (res.code === 0) { + const oldRuleMap = {} + ;(item.registrationRules || []).forEach((rule) => { + oldRuleMap[rule.typeCode] = rule + }) + const rules = (res.data || []).filter((option) => !option.disabled).map((option) => ({ + typeCode: option.code, + typeName: option.name, + personCount: oldRuleMap[option.code] === undefined + ? null + : oldRuleMap[option.code].personCount + })) + this.$set(item, "registrationRules", rules) + } else { + this.$set(item, "registrationRules", []) + this.$message.error(res.msg || "加载报名规则失败") + } + }, () => { + this.$set(item, "registrationRules", []) + this.$message.error("加载报名规则失败") + }) + .always(() => { + this.$set(item, "registrationRuleLoading", false) + }) + }, removeRow(index) { this.$confirm("确定要删除此项目吗?", "提示", { confirmButtonText: "确定", @@ -205,11 +332,23 @@ const itemForm = { return } + // 仅提交稳定的子项编码和人数;字典名称在回显时实时读取。 + const submitItems = this.form.tableData.map((item) => { + const submitItem = { + ...item, + registrationRule: JSON.stringify((item.registrationRules || []).map((rule) => ({ + typeCode: rule.typeCode, + personCount: rule.personCount + }))) + } + delete submitItem.registrationRules + delete submitItem.registrationRuleLoading + return submitItem + }) $.post("/platform/activity/manage/updateItem", { id: this.form.id, - items: JSON.stringify(this.form.tableData) + items: JSON.stringify(submitItems) }).then((res) => { - this.submitting = false if (res.code === 0) { this.$message.success("保存成功") this.visible = false @@ -217,10 +356,11 @@ const itemForm = { } else { this.$message.error(res.msg || "保存失败") } - }).catch(err => { - this.submitting = false + }, (err) => { this.$message.error("网络错误,请稍后重试") console.error(err) + }).always(() => { + this.submitting = false }) } else { this.$message.warning("请完善表单信息") @@ -327,5 +467,15 @@ const itemForm = { .project-card .el-tabs--border-card{ border: none; } + + .registration-rule-empty { + padding: 35px 0; + color: #909399; + text-align: center; + } + + .registration-rule-form-item { + margin-bottom: 0; + } ` } diff --git a/backend/src/main/resources/views/platform/activity/manage/registrationFieldSetting.js b/backend/src/main/resources/views/platform/activity/manage/registrationFieldSetting.js new file mode 100644 index 0000000..5f9e556 --- /dev/null +++ b/backend/src/main/resources/views/platform/activity/manage/registrationFieldSetting.js @@ -0,0 +1,233 @@ +const registrationFieldSetting = { + template: /*language=HTML*/ ` + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ 取消 + 确定 +
+
+
+ `, + data() { + return { + visible: false, + loading: false, + submitting: false, + activityId: null, + fieldList: [], + projectTypeConfigurable: false, + projectTypeRuleMessage: "", + originalData: "" + } + }, + methods: { + onOpen(activityId) { + this.activityId = activityId + this.visible = true + this.loading = true + $.post("/platform/activity/manage/getRegistrationFields", {id: activityId}) + .then((res) => { + if (res.code === 0) { + const fields = res.data || [] + const projectTypeField = fields.find((item) => item.fieldKey === "projectType") + this.projectTypeConfigurable = Boolean(projectTypeField && projectTypeField.configurable) + this.projectTypeRuleMessage = projectTypeField ? projectTypeField.ruleMessage : "" + this.$set(this, "fieldList", fields) + this.originalData = JSON.stringify(fields) + } else { + this.$message.error(res.msg || "加载报名字段失败") + } + }, () => { + this.$message.error("加载报名字段失败") + }) + .always(() => { + this.loading = false + }) + }, + // 选中子字段时补齐父字段;取消父字段时同步取消所有依赖字段。 + onSelectedChange(row, selected) { + if (row.configurable === false) { + this.$set(row, "selected", false) + return + } + if (selected) { + this.selectParent(row) + return + } + this.unselectChildren(row.fieldKey) + }, + selectParent(row) { + if (!row.parentField) { + return + } + const parent = this.fieldList.find((item) => item.fieldKey === row.parentField) + if (parent) { + this.$set(parent, "selected", true) + this.selectParent(parent) + } + }, + unselectChildren(fieldKey) { + this.fieldList.filter((item) => item.parentField === fieldKey).forEach((item) => { + this.$set(item, "selected", false) + this.$set(item, "required", false) + this.unselectChildren(item.fieldKey) + }) + }, + onSubmit() { + const selectedFields = this.fieldList.filter((item) => item.selected) + .map((item) => ({ + fieldKey: item.fieldKey, + required: item.required, + sort: item.sort + })) + this.$confirm("保存后将影响该活动后续新增报名和重新提交,已有报名数据不会被删除。确定保存吗?", "再次确认", { + confirmButtonText: "确定保存", + cancelButtonText: "取消", + type: "warning" + }).then(() => { + this.submitting = true + $.post("/platform/activity/manage/saveRegistrationFields", { + id: this.activityId, + fields: JSON.stringify(selectedFields) + }).then((res) => { + if (res.code === 0) { + this.$message.success("报名字段保存成功") + this.visible = false + this.reset() + } else { + this.$message.error(res.msg || "报名字段保存失败") + } + }, () => { + this.$message.error("报名字段保存失败") + }).always(() => { + this.submitting = false + }) + }).catch(() => { + }) + }, + handleClose(done) { + const close = () => { + this.visible = false + this.reset() + if (typeof done === "function") { + done() + } + } + if (JSON.stringify(this.fieldList) !== this.originalData) { + this.$confirm("有未保存的修改,确定关闭吗?", "提示", { + confirmButtonText: "确定", + cancelButtonText: "取消", + type: "warning" + }).then(close).catch(() => { + }) + return + } + close() + }, + reset() { + this.activityId = null + this.fieldList = [] + this.projectTypeConfigurable = false + this.projectTypeRuleMessage = "" + this.originalData = "" + this.loading = false + this.submitting = false + }, + componentTypeLabel(componentType) { + const labels = { + input: "文本输入", + number: "数字输入", + tel: "手机号", + radio: "单选", + "boolean-radio": "是/否单选", + "dict-radio": "字典单选", + "dict-checkbox": "字典多选", + date: "日期", + readonly: "自动带出" + } + return labels[componentType] || componentType + }, + dataSourceLabel(row) { + if (row.fieldKey === "projectType") { + return row.configurable + ? "数据字典:" + row.dictCode + " 的下级" + : "当前项目数量不支持" + } + return row.dictCode ? "数据字典:" + row.dictCode : "固定表单字段" + }, + dependencyLabel(row) { + if (!row.parentField) { + return "-" + } + const parent = this.fieldList.find((item) => item.fieldKey === row.parentField) + return parent ? parent.fieldLabel + " = " + row.showWhenValue : "-" + } + } +} diff --git a/backend/src/main/resources/views/platform/activity/signInUserQuery/index.html b/backend/src/main/resources/views/platform/activity/signInUserQuery/index.html index 0002354..c613efe 100644 --- a/backend/src/main/resources/views/platform/activity/signInUserQuery/index.html +++ b/backend/src/main/resources/views/platform/activity/signInUserQuery/index.html @@ -22,7 +22,7 @@ layout("/layouts/platform_1.html"){
所属活动
- + {{scope.$index+(pageForm.pageNumber - 1) * pageForm.pageSize + 1}} + + + + + + + + @@ -146,10 +158,64 @@ layout("/layouts/platform_1.html"){ year: new Date().getFullYear() + "" }, activityOptions: [], + registrationFields: [], units: [], } }, methods: { + // 活动切换后同步刷新动态列和已签到人员列表。 + onActivityChange() { + this.loadRegistrationFields(() => { + this.doSearch() + }) + }, + // 获取当前活动启用的报名字段;字典选项用于把保存的code转换成名称。 + loadRegistrationFields(callback) { + if (!this.pageForm.activityId) { + this.registrationFields = [] + if (callback) callback() + return + } + $.post('/platform/activity/signInUserQuery/getRegistrationFields', { + activityId: this.pageForm.activityId + }).then((res) => { + if (res.code === 0) { + this.registrationFields = res.data || [] + } else { + this.registrationFields = [] + this.$message.error(res.msg || '加载报名字段失败') + } + }).fail(() => { + this.registrationFields = [] + this.$message.error('加载报名字段失败') + }).always(() => { + if (callback) callback() + }) + }, + formatRegistrationField(row, field) { + const value = row[field.fieldKey] + if (value === null || value === undefined || value === '') return '' + if (field.dictCode) { + const optionMap = {} + ;(field.options || []).forEach((option) => { + optionMap[option.value] = option.label + }) + return String(value).split(',').map((code) => { + const dictValue = code.trim() + return optionMap[dictValue] || dictValue + }).join('、') + } + if (field.componentType === 'boolean-radio') { + const checked = value === true || value === 1 || value === '1' + return field.fieldKey === 'isStay' ? (checked ? '住宿' : '不住宿') : (checked ? '是' : '否') + } + return value + }, + getRegistrationFieldWidth(field) { + if (field.fieldKey === 'unitName') return 180 + if (field.fieldKey === 'phone' || field.componentType === 'dict-checkbox') return 140 + return 100 + }, async doGrant(row, isGrant) { this.$confirm('您确定要设置成' + (isGrant ? '已发放' : '未发放') + '状态吗?', '提示', { confirmButtonText: '确定', @@ -180,7 +246,9 @@ layout("/layouts/platform_1.html"){ this.$set(this.pageForm, "activityId", "") } this.activityOptions = res.data; - this.doSearch() + this.loadRegistrationFields(() => { + this.doSearch() + }) } else { this.$message.error(res.msg); } diff --git a/backend/src/main/resources/views/platform/activity/user/index.html b/backend/src/main/resources/views/platform/activity/user/index.html index 4e9404f..dd8151b 100644 --- a/backend/src/main/resources/views/platform/activity/user/index.html +++ b/backend/src/main/resources/views/platform/activity/user/index.html @@ -21,7 +21,7 @@ layout("/layouts/platform_1.html"){
所属活动
- @@ -54,7 +54,7 @@ layout("/layouts/platform_1.html"){ @@ -74,11 +74,22 @@ layout("/layouts/platform_1.html"){ 添加报名人员 导出 - + + + + + + + + + + + + `, + props: { + activityList: { + type: Array, + default: () => [] + } + }, data() { return { title: '', @@ -33,6 +49,7 @@ const addOrUpdate = { published: true, }, rules: { + activityId: [{required: true, message: '请选择所属活动', trigger: ['blur', 'change']}], title: [{required: true, message: '请输入标题', trigger: ['blur', 'change']}], content: [{required: true, message: '请输入内容', trigger: ['blur', 'change']}], cover: [{required: true, message: '请上传封面图', trigger: ['blur', 'change']}], diff --git a/backend/src/main/resources/views/platform/activity/wonderfulMoment/index.html b/backend/src/main/resources/views/platform/activity/wonderfulMoment/index.html index 75e09bc..d332425 100644 --- a/backend/src/main/resources/views/platform/activity/wonderfulMoment/index.html +++ b/backend/src/main/resources/views/platform/activity/wonderfulMoment/index.html @@ -72,7 +72,7 @@ layout("/layouts/platform_1.html"){ - +
diff --git a/backend/src/test/java/com/budwk/app/Test1.java b/backend/src/test/java/com/budwk/app/Test1.java index eeb0a5d..5e7a33b 100644 --- a/backend/src/test/java/com/budwk/app/Test1.java +++ b/backend/src/test/java/com/budwk/app/Test1.java @@ -6,8 +6,10 @@ import cn.afterturn.easypoi.excel.annotation.Excel; import cn.afterturn.easypoi.excel.entity.ExportParams; import cn.afterturn.easypoi.excel.entity.ImportParams; import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity; +import cn.hutool.core.util.NumberUtil; import cn.hutool.core.util.StrUtil; import com.budwk.app.base.utils.PwdUtil; +import com.budwk.app.module.activity.models.ActUser; import com.budwk.app.sys.models.Sys_dict; import com.budwk.app.sys.services.SysDictService; import com.budwk.app.web.commons.auth.utils.SecurityUtil; @@ -21,6 +23,7 @@ import org.apache.poi.xssf.usermodel.*; import org.junit.Test; import org.junit.runner.RunWith; import org.nutz.boot.test.junit4.NbJUnit4Runner; +import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; @@ -34,6 +37,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Optional; +import java.util.concurrent.ThreadLocalRandom; @IocBean @RunWith(NbJUnit4Runner.class) @@ -379,4 +383,13 @@ public class Test1 { } + @Test + public void test0001(){ + List query = dao.query(ActUser.class, Cnd.NEW()); + for (ActUser actUser : query) { + int i = ThreadLocalRandom.current().nextInt(0, 201); + actUser.setWeight((double) i); + } + dao.update(query, "weight"); + } } diff --git a/frontend/src/pages.json b/frontend/src/pages.json index c87547c..dc3e7d6 100644 --- a/frontend/src/pages.json +++ b/frontend/src/pages.json @@ -3,7 +3,7 @@ { "path": "pages/index/index", "style": { - "navigationBarTitleText": "趣味运动会" + "navigationBarTitleText": "赛事管理" } }, { @@ -101,7 +101,7 @@ ], "globalStyle": { "navigationBarTextStyle": "#000000", - "navigationBarTitleText": "趣味运动会", + "navigationBarTitleText": "赛事管理", "navigationBarBackgroundColor": "#ffffff", "backgroundColor": "#F8F8F8" }, diff --git a/frontend/src/pages/activity/list/index.vue b/frontend/src/pages/activity/list/index.vue index 4e165cd..38bbedc 100644 --- a/frontend/src/pages/activity/list/index.vue +++ b/frontend/src/pages/activity/list/index.vue @@ -49,10 +49,9 @@ style="flex-shrink:0;width: 120px;height: 90px;border-radius: 5px;" fit="fill"/> - + - {{ moment(item.startTime).format('MM-DD') }} + {{ moment(item.startTime).format('YYYY年MM月DD日') }} @@ -255,6 +254,22 @@ onMounted(() => { } } +.activity-date-tag { + align-self: flex-start; + display: inline-flex; + align-items: center; + column-gap: 4px; + max-width: 100%; + padding: 4px 8px; + border-radius: 12px; + box-sizing: border-box; + color: #f45a5a; + background-color: rgba(244, 90, 90, 0.12); + font-size: 12px; + line-height: 1.2; + white-space: nowrap; +} + .loading-container { padding: 40rpx 0; text-align: center; diff --git a/frontend/src/pages/activity/registration-form/index.vue b/frontend/src/pages/activity/registration-form/index.vue index c261041..fef8e5f 100644 --- a/frontend/src/pages/activity/registration-form/index.vue +++ b/frontend/src/pages/activity/registration-form/index.vue @@ -4,13 +4,20 @@ - 报名人员 + 报名人员(总{{ unitRegistrationTotal }}人) {{ formData.userList.length }}人 - - + + + 添加 + + + + 删除 @@ -25,9 +32,6 @@ @click="switchTab(index)" > - - - {{ user.userName || `人员${index + 1}` }} @@ -45,7 +49,6 @@ label-align="left" scroll-to-first-error="auto" labelWidth="100px" - :requiredMark="true" > @@ -68,104 +71,139 @@ 报名人员信息 - {{ activeTabIndex + 1 }}/{{ formData.userList.length }} - + + {{ formData.userList.length > 0 ? activeTabIndex + 1 : 0 }}/{{ formData.userList.length }} + + + + + 暂无报名人员,请点击“添加” - + - - + + - + + + + + + + + + + + - + - + + + + - + - + + + + - + - + - + - + - + - - - - - - - - - - - - - - - + + + + + + + + + + + + + @@ -254,10 +292,237 @@ import {useRequest} from '@/utils/request'; import {Dialog, Toast} from "tdesign-mobile-vue"; import {onLoad} from "@dcloudio/uni-app"; import {useUserStore} from "@/store"; +import {showConfirmWithHistory} from "@/utils/popupHistory"; +import {AddIcon, DeleteIcon} from "tdesign-icons-vue-next"; const request = useRequest() const userStore = useUserStore() +// 单位简称属于当前登录用户关联的单位信息,不随单个人员报名数据重复提交。 +const unitAliasName = computed(() => userStore?.userInfo?.unit?.aliasName || '未设置') + +// 后端不可用时仍按历史字段展示,保证已有活动的报名页面不会变成空表单。 +const registrationFields = ref([ + {fieldKey: 'userName', required: true, options: []}, + {fieldKey: 'sex', required: true, options: []}, + {fieldKey: 'weight', required: true, options: []}, + {fieldKey: 'unitName', required: true, options: []}, + {fieldKey: 'position', required: true, options: []}, + {fieldKey: 'phone', required: true, options: []}, + {fieldKey: 'isStay', required: true, options: []}, + {fieldKey: 'isContacts', required: true, options: []}, + {fieldKey: 'traffic', required: true, options: []}, + {fieldKey: 'carNo', required: true, options: []}, + {fieldKey: 'isStation', required: true, options: []}, + {fieldKey: 'stationAddress', required: true, options: []}, + {fieldKey: 'isParty', required: true, options: []} +]) +const registrationFieldMap = computed(() => { + const result = {} + registrationFields.value.forEach((item) => { + result[item.fieldKey] = item + }) + return result +}) +// 单项目活动按同一单位限制教练、领队各1人;已有数量不包含当前账号即将被本表单覆盖的旧记录。 +const personTypeRuleStatus = ref({ + enabled: false, + coachValue: '', + leaderValue: '', + coachCount: 0, + leaderCount: 0, + maxCount: 1 +}) +// 唯一项目的报名规则状态;existingCount不包含当前账号将被草稿覆盖的旧名单。 +const projectTypeRuleStatus = ref({ + enabled: false, + rules: [] +}) +const isFieldVisible = (fieldKey) => Boolean(registrationFieldMap.value[fieldKey]) +const fieldOptions = (fieldKey) => registrationFieldMap.value[fieldKey]?.options || [] +// 红色必填标识与前端必填校验统一读取当前活动的报名字段配置,避免显示字段被全局标记为必填。 +const isFieldRequired = (fieldKey) => Boolean(registrationFieldMap.value[fieldKey]?.required) +const fieldRules = (fieldKey, message, extraRules = []) => { + return [{required: isFieldRequired(fieldKey), message}, ...extraRules] +} + +// 人员类型字典值可能由管理员维护,业务判断始终按“运动员”名称动态取得实际编码。 +const getAthleteTypeValue = () => fieldOptions('personType').find((item) => item.label === '运动员')?.value + +// 人员类型允许“运动员+教练”或“运动员+领队”,但教练和领队不能同时选择。 +const isPersonTypeOptionDisabled = (user, option) => { + const selectedValues = Array.isArray(user.personType) ? user.personType : [] + if (selectedValues.includes(option.value)) { + return false + } + const options = fieldOptions('personType') + const coachValue = options.find((item) => item.label === '教练')?.value + const leaderValue = options.find((item) => item.label === '领队')?.value + return option.label === '教练' && selectedValues.includes(leaderValue) + || option.label === '领队' && selectedValues.includes(coachValue) +} + +// 选择人员类型后立即合并后台已有数量和当前页面全部人员;超限时撤销本次人员的对应角色。 +const handlePersonTypeChange = (user, changedValues) => { + const selectedValues = parseMultiValue(changedValues) + user.personType = selectedValues + const athleteValue = getAthleteTypeValue() + if (athleteValue && !selectedValues.includes(athleteValue) && parseMultiValue(user.projectType).length > 0) { + // 取消运动员身份后原项目已不再合法,立即清除,避免无效组合进入提交阶段。 + user.projectType = [] + Toast({message: '已取消运动员类型,原选择项目已清空', theme: 'warning'}) + } + const status = personTypeRuleStatus.value + if (!status.enabled) { + return + } + const maxCount = Number(status.maxCount) || 1 + const countDraftType = (typeValue) => formData.value.userList.filter((item) => { + return parseMultiValue(item.personType).includes(typeValue) + }).length + const coachTotal = Number(status.coachCount || 0) + countDraftType(status.coachValue) + if (status.coachValue && selectedValues.includes(status.coachValue) && coachTotal > maxCount) { + user.personType = selectedValues.filter((value) => value !== status.coachValue) + Toast({message: '同一活动、同一单位最多选择1名教练', theme: 'warning'}) + return + } + const leaderTotal = Number(status.leaderCount || 0) + countDraftType(status.leaderValue) + if (status.leaderValue && selectedValues.includes(status.leaderValue) && leaderTotal > maxCount) { + user.personType = selectedValues.filter((value) => value !== status.leaderValue) + Toast({message: '同一活动、同一单位最多选择1名领队', theme: 'warning'}) + } +} + +// 参加任何项目都必须先选择运动员类型;不满足时撤销本次项目选择并即时提醒。 +const handleProjectTypeChange = (user, changedValues) => { + const athleteValue = getAthleteTypeValue() + if (!athleteValue) { + user.projectType = [] + Toast({message: '人员类型数据字典配置不完整,请联系管理员', theme: 'warning'}) + return + } + if (!parseMultiValue(user.personType).includes(athleteValue)) { + user.projectType = [] + Toast({message: '请先选择人员类型中的“运动员”,再选择项目', theme: 'warning'}) + return + } + const selectedValues = parseMultiValue(changedValues) + user.projectType = selectedValues + const status = projectTypeRuleStatus.value + if (!status.enabled) { + return + } + // 同一子项按“其他账号已占用人数+当前草稿全部人员”统计;超限项从本次选择中撤销。 + const exceededRules = (status.rules || []).filter((rule) => { + if (!selectedValues.includes(rule.typeCode)) { + return false + } + const draftCount = formData.value.userList.filter((item) => { + return parseMultiValue(item.projectType).includes(rule.typeCode) + }).length + return Number(rule.existingCount || 0) + draftCount > Number(rule.personCount) + }) + if (exceededRules.length === 0) { + return + } + const exceededValues = exceededRules.map((rule) => rule.typeCode) + user.projectType = selectedValues.filter((value) => !exceededValues.includes(value)) + const firstRule = exceededRules[0] + if (Number(firstRule.personCount) === 0) { + Toast({message: '“' + firstRule.typeName + '”当前不允许报名', theme: 'warning'}) + return + } + Toast({ + message: '“' + firstRule.typeName + '”每个单位最多报名' + firstRule.personCount + '人,请按规则重新选择', + theme: 'warning' + }) +} + +// 同性别的单打和双打互斥;男子/女子项目受人员性别限制,混合双打和其他不参与互斥。 +const isProjectTypeOptionDisabled = (user, option) => { + const selectedValues = parseMultiValue(user.projectType) + if (selectedValues.includes(option.value)) { + return false + } + const options = fieldOptions('projectType') + const manSingleValue = options.find((item) => item.label === '男子单打')?.value + const womanSingleValue = options.find((item) => item.label === '女子单打')?.value + const manDoubleValue = options.find((item) => item.label === '男子双打')?.value + const womanDoubleValue = options.find((item) => item.label === '女子双打')?.value + if (['男子单打', '男子双打'].includes(option.label) && user.sex !== '男') { + return true + } + if (['女子单打', '女子双打'].includes(option.label) && user.sex !== '女') { + return true + } + return option.label === '男子单打' && selectedValues.includes(manDoubleValue) + || option.label === '男子双打' && selectedValues.includes(manSingleValue) + || option.label === '女子单打' && selectedValues.includes(womanDoubleValue) + || option.label === '女子双打' && selectedValues.includes(womanSingleValue) +} + +// 后端使用逗号分隔保存多选字典代码;页面编辑时统一转换为数组。 +const parseMultiValue = (value) => { + if (Array.isArray(value)) { + return value + } + if (!value) { + return [] + } + return String(value).split(',').map((item) => item.trim()).filter(Boolean) +} + +// 旧字典的Single、Double按人员性别映射到拆分后的项目,避免历史报名重新提交时校验失败。 +const normalizeLegacyProjectType = (user) => { + const selectedValues = parseMultiValue(user.projectType) + const options = fieldOptions('projectType') + const legacyMappings = [ + { + value: 'Single', + targetLabel: user.sex === '男' ? '男子单打' : user.sex === '女' ? '女子单打' : '' + }, + { + value: 'Double', + targetLabel: user.sex === '男' ? '男子双打' : user.sex === '女' ? '女子双打' : '' + } + ] + const normalizedValues = [...selectedValues] + legacyMappings.forEach((mapping) => { + if (!normalizedValues.includes(mapping.value) || !mapping.targetLabel) { + return + } + const targetValue = options.find((item) => item.label === mapping.targetLabel)?.value + if (!targetValue) { + return + } + normalizedValues.splice(normalizedValues.indexOf(mapping.value), 1, targetValue) + }) + user.projectType = [...new Set(normalizedValues)] +} + +const normalizeLegacyProjectTypes = () => { + formData.value.userList.forEach((user) => normalizeLegacyProjectType(user)) +} + +// 性别变化后清除不再匹配的单打和双打项目,防止界面保留不可提交的非法组合。 +const handleSexChange = (user) => { + const options = fieldOptions('projectType') + const manSingleValue = options.find((item) => item.label === '男子单打')?.value + const womanSingleValue = options.find((item) => item.label === '女子单打')?.value + const manDoubleValue = options.find((item) => item.label === '男子双打')?.value + const womanDoubleValue = options.find((item) => item.label === '女子双打')?.value + normalizeLegacyProjectType(user) + user.projectType = parseMultiValue(user.projectType).filter((value) => { + if (user.sex === '男') { + return value !== womanSingleValue && value !== womanDoubleValue + } + if (user.sex === '女') { + return value !== manSingleValue && value !== manDoubleValue + } + return ![manSingleValue, womanSingleValue, manDoubleValue, womanDoubleValue].includes(value) + }) +} + // 当前选中的选项卡索引 const activeTabIndex = ref(0) @@ -273,8 +538,11 @@ function addNewUser() { formData.value.userList.push({ userName: '', sex: null, + personType: [], + projectType: [], unitName: userStore?.userInfo?.unit?.name, position: null, + birthday: null, // idCard: null, phone: null, isStay: null, @@ -282,6 +550,10 @@ function addNewUser() { isShowSkill: false, skillName: null, equip: null, + traffic: null, + isStation: false, + stationAddress: null, + carNo: null, isParty: false }) // 切换到新添加的用户选项卡 @@ -366,11 +638,19 @@ const guideSteps = [ // 报名信息 const actId = ref(null) const isSignUp = ref(null) -const agreementChecked = ref(false) +// 报名页默认视为已阅读并同意,连续维护多人或重新提交时不重复要求阅读。 +const agreementChecked = ref(true) const formRef = ref(null) const formData = ref({ userList: [] }) +// 后端返回当前单位已保存总数;编辑原报名时先扣除旧名单,再合并当前页面草稿人数,避免重复累计。 +const savedUnitRegistrationCount = ref(0) +const originalSignupUserCount = ref(0) +const unitRegistrationTotal = computed(() => { + const otherSavedCount = Math.max(0, savedUnitRegistrationCount.value - originalSignupUserCount.value) + return otherSavedCount + formData.value.userList.length +}) // 协议弹窗相关 const agreementPopupVisible = ref(false) const agreementCountdown = ref(5) @@ -385,32 +665,123 @@ onLoad(async (option) => { if (option.id) { actId.value = option.id formData.value.activityId = option.id + loadRegistrationFields() + loadUnitRegistrationCount() await listSignUpUser() await getIsSignUp() await getAgreement() - // 没报名进行引导 + // 未报名时仍默认带出本人信息;按当前业务要求不再自动显示报名人员操作引导。 if (!formData.value.userList || formData.value.userList.length === 0) { - guideCurrent.value = 0 // 默认是本人的信息 formData.value.userList.push({ userName: userStore.userInfo.userName, sex: userStore.userInfo.sex, + personType: [], + projectType: [], unitName: userStore?.userInfo?.unit?.name, position: null, + birthday: userStore.userInfo.birthday, // idCard: userStore.userInfo.idCard, phone: userStore.userInfo.phone, - isStay: null + isStay: null, + isContacts: false, + isShowSkill: false, + skillName: null, + equip: null, + traffic: null, + isStation: false, + stationAddress: null, + carNo: null, + isParty: false }) } - // 页面加载完成后自动显示重要提醒弹窗 - setTimeout(() => { - showNoticePopup() - }, 500) + // 按当前业务要求屏蔽页面加载时自动显示“重要提醒”弹窗。 } else { Toast({message: 'ID不存在', theme: 'error'}); } }) +// 获取当前活动启用的字段、必填规则以及数据字典选项。 +const loadRegistrationFields = () => { + request.get('/client/activity/registration-fields/' + actId.value) + .then(({code, data, msg}) => { + if (code === 0 && data) { + registrationFields.value = data + normalizeLegacyProjectTypes() + loadPersonTypeRuleStatus() + loadProjectTypeRuleStatus() + } else { + Toast({message: msg || '加载报名字段失败', theme: 'error'}) + } + }) + .catch((error) => { + console.error(error) + Toast({message: '加载报名字段失败,已使用默认报名字段', theme: 'error'}) + }) +} + +// 获取同单位其他报名账号已占用的教练、领队人数,供页面选择时即时校验。 +const loadPersonTypeRuleStatus = () => { + if (!isFieldVisible('personType')) { + personTypeRuleStatus.value = {...personTypeRuleStatus.value, enabled: false} + return + } + request.get('/client/activity/person-type-rule-status/' + actId.value) + .then(({code, data, msg}) => { + if (code === 0 && data) { + personTypeRuleStatus.value = data + } else { + personTypeRuleStatus.value = {...personTypeRuleStatus.value, enabled: false} + Toast({message: msg || '加载人员类型规则失败', theme: 'error'}) + } + }) + .catch((error) => { + console.error(error) + personTypeRuleStatus.value = {...personTypeRuleStatus.value, enabled: false} + Toast({message: '加载人员类型规则失败,提交时将再次校验', theme: 'error'}) + }) +} + +// 获取唯一项目各子项人数上限和同单位其他账号已占用人数,供项目选择时即时校验。 +const loadProjectTypeRuleStatus = () => { + if (!isFieldVisible('projectType')) { + projectTypeRuleStatus.value = {...projectTypeRuleStatus.value, enabled: false} + return + } + request.get('/client/activity/project-type-rule-status/' + actId.value) + .then(({code, data, msg}) => { + if (code === 0 && data) { + projectTypeRuleStatus.value = data + } else { + projectTypeRuleStatus.value = {enabled: false, rules: []} + Toast({message: msg || '加载项目报名规则失败', theme: 'error'}) + } + }) + .catch((error) => { + console.error(error) + projectTypeRuleStatus.value = {enabled: false, rules: []} + Toast({message: '加载项目报名规则失败,提交时将再次校验', theme: 'error'}) + }) +} + +// 查询同一活动、同一单位已保存的报名人数;添加和删除后的变化由计算属性实时合并。 +const loadUnitRegistrationCount = () => { + request.get('/client/activity/unit-registration-count/' + actId.value) + .then(({code, data, msg}) => { + if (code === 0) { + savedUnitRegistrationCount.value = Number(data) || 0 + } else { + savedUnitRegistrationCount.value = 0 + Toast({message: msg || '加载本单位报名总人数失败', theme: 'error'}) + } + }) + .catch((error) => { + console.error(error) + savedUnitRegistrationCount.value = 0 + Toast({message: '加载本单位报名总人数失败', theme: 'error'}) + }) +} + // 查询参赛承诺书 async function getAgreement() { try { @@ -428,8 +799,14 @@ async function listSignUpUser() { try { const {code, data} = await request.get('/client/activity/list-signup-user/' + actId.value) if (code === 0 && data) { + originalSignupUserCount.value = data.length if (data.length > 0) { - formData.value.userList = data + formData.value.userList = data.map((user) => ({ + ...user, + personType: parseMultiValue(user.personType), + projectType: parseMultiValue(user.projectType) + })) + normalizeLegacyProjectTypes() } } else { Toast({message: '查询报名人员信息失败', theme: 'error'}); @@ -467,11 +844,11 @@ async function validaForm() { // 承诺书 async function validateAgreement() { return new Promise((resolve, reject) => { - // Always show the popup regardless of checkbox state - // if (agreementChecked.value) { - // resolve(true) - // return - // } + // 已同意时直接提交;只有用户主动取消勾选后才重新进入承诺书阅读流程。 + if (agreementChecked.value) { + resolve(true) + return + } // 重置倒计时 agreementCountdown.value = 5 @@ -549,66 +926,99 @@ function closeAgreementPopup() { // 提交表单 async function onSubmit() { + // 页面允许暂时删除全部人员,但提交报名时至少需要保留一名报名人员。 + if (formData.value.userList.length === 0) { + Toast('请至少添加一名报名人员') + return + } + const formRes = await validaForm() if (!formRes) return const agreementRes = await validateAgreement() if (!agreementRes) return - // 提交表单 - await submitForm() + // 重新提交会覆盖当前活动的原报名信息,提交前要求用户再次核对。 + if (isSignUp.value) { + showConfirmWithHistory({ + title: '重新提交确认', + content: '请再次确认报名信息是否正确,确认后将更新原报名信息。', + cancelBtn: '返回检查', + confirmBtn: '确认提交', + closeOnOverlayClick: false, + onConfirm: () => { + submitForm() + } + }) + return + } + + // 首次报名保持原有流程,校验通过后直接提交。 + submitForm() } // 报名 -async function submitForm() { +function submitForm() { uni.showLoading({ title: '提交中...', mask: true, }) - console.log(formData.value) - try { - const {code, data, msg} = await request.post('/client/activity/sign-up', formData.value) - if (code === 0) { - Toast.success('报名成功') - setTimeout(() => { - uni.navigateTo({ - url: '/pages/activity/registration-form/success?id=' + actId.value - }) - }, 500) - } else { - Dialog.alert({ - title: '报名失败', - content: msg, - confirmBtn: '确定' - }) - } - } catch (e) { - console.log(e) - Toast.error(e.message) - } finally { - uni.hideLoading() + // 仅转换提交副本,保留页面数组结构,避免提交失败后多选控件状态异常。 + const submitData = { + ...formData.value, + userList: formData.value.userList.map((user) => ({ + ...user, + personType: parseMultiValue(user.personType).join(','), + projectType: parseMultiValue(user.projectType).join(',') + })) } + request.post('/client/activity/sign-up', submitData) + .then(({code, msg}) => { + if (code === 0) { + Toast.success('报名成功') + setTimeout(() => { + uni.navigateTo({ + url: '/pages/activity/registration-form/success?id=' + actId.value + }) + }, 500) + } else { + Dialog.alert({ + title: '报名失败', + content: msg, + confirmBtn: '确定' + }) + } + }) + .catch((error) => { + console.log(error) + Toast.error(error.message) + }) + .finally(() => { + uni.hideLoading() + }) } // 移除人员 -async function removeUser(index) { - if (formData.value.userList.length === 1) { - Toast('至少需要一个报名人员') - return - } - - Dialog.confirm({ - title: '提示', - content: '确定要删除该报名人员吗?', - confirmBtn: '确定', +function removeUser(index) { + const user = formData.value.userList[index] + const userName = user?.userName || '人员' + (index + 1) + showConfirmWithHistory({ + title: '删除报名人员', + content: '确定删除“' + userName + '”吗?如需提交报名,请至少保留或重新添加一名报名人员。', + confirmBtn: '确认删除', cancelBtn: '取消', + closeOnOverlayClick: false, onConfirm: () => { formData.value.userList.splice(index, 1) - // 如果删除的是当前选中的选项卡,则选中第一个选项卡 - if (index === activeTabIndex.value) { + if (formData.value.userList.length === 0) { activeTabIndex.value = 0 + return } - // 如果删除的是前面的选项卡,则当前选中的选项卡索引减1 + // 删除当前人员时优先显示其后一人;删除末项时显示前一人,保持操作上下文连续。 + if (index === activeTabIndex.value) { + activeTabIndex.value = Math.min(index, formData.value.userList.length - 1) + } + // 删除当前选中项之前的人员后,索引前移以继续显示原选中人员。 else if (index < activeTabIndex.value) { activeTabIndex.value-- } @@ -672,13 +1082,18 @@ function closeNoticePopup() { .header-actions { display: flex; align-items: center; + gap: 8px; } .person-counter { + width: 64px; + height: 30px; background-color: #f45a5a; - border-radius: 12px; - padding: 2px 10px; - margin-right: 8px; + border-radius: 15px; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; } .counter-text { @@ -687,20 +1102,46 @@ function closeNoticePopup() { font-weight: 500; } -.header-add-btn { - width: 28px; - height: 28px; - border-radius: 50%; - background: linear-gradient(135deg, #f45a5a, #ff7676); - color: #fff; +.header-action-btn { + width: 64px; + height: 30px; + padding: 0 8px; + border-radius: 15px; display: flex; align-items: center; justify-content: center; - box-shadow: 0 2px 6px rgba(244, 90, 90, 0.25); + gap: 3px; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + box-sizing: border-box; transition: transform 0.2s ease; } -.header-add-btn:active { +.header-action-btn text { + white-space: nowrap; + flex-shrink: 0; +} + +/* 使用独立 SVG 图标组件,避免 H5 环境下通用 t-icon 未渲染图形。 */ +.header-action-icon { + display: block; + flex-shrink: 0; +} + +.header-add-btn { + background: linear-gradient(135deg, #f45a5a, #ff7676); + color: #fff; + box-shadow: 0 2px 6px rgba(244, 90, 90, 0.25); +} + +.header-remove-btn { + color: #f45a5a; + background-color: #fff1f0; + border: 1px solid rgba(244, 90, 90, 0.35); +} + +.header-action-btn:active { transform: scale(0.92); } @@ -740,6 +1181,7 @@ function closeNoticePopup() { } .tab-content { + width: 100%; display: flex; align-items: center; justify-content: center; @@ -749,13 +1191,10 @@ function closeNoticePopup() { max-width: 100%; } -.tab-icon { - margin-right: 4px; - display: flex; - align-items: center; -} - .tab-text { + display: block; + width: 100%; + text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -916,6 +1355,13 @@ function closeNoticePopup() { animation: fadeIn 0.3s ease; } +.form-empty { + padding: 36px 0; + color: #999; + font-size: 14px; + text-align: center; +} + @keyframes fadeIn { from { opacity: 0.6; @@ -925,23 +1371,16 @@ function closeNoticePopup() { } } -.user-actions { - display: flex; - align-items: center; - margin-left: auto; -} - -.remove-user { - padding: 5px; - color: #f45a5a; - cursor: pointer; -} - .radio-box { display: flex; column-gap: 20px; } +.dict-radio-box { + flex-wrap: wrap; + row-gap: 8px; +} + .footer { position: fixed; bottom: 0; diff --git a/frontend/src/pages/activity/wonderfulMoment/index.vue b/frontend/src/pages/activity/wonderfulMoment/index.vue index bd975be..0d0874f 100644 --- a/frontend/src/pages/activity/wonderfulMoment/index.vue +++ b/frontend/src/pages/activity/wonderfulMoment/index.vue @@ -24,7 +24,8 @@ const viewData = ref(null); function getWonderfulMoment() { request.post('/platform/activity/wonderfulMoment/getWonderfulMomentById/' + momentId.value).then(res=>{ if(res.code === 0){ - res.data.content = res?.data?.content.replaceAll('/open/file/upload/download?filepath=','') + // 后台富文本保存的是后端相对预览地址,H5 端需通过 /api 代理访问实际上传文件。 + res.data.content = res?.data?.content.replaceAll('/open/file/upload/download?filepath=', '/api/open/file/upload/download?inline=true&filepath=') viewData.value = res.data }else{ Toast({message: res.msg, theme: 'error'}); diff --git a/frontend/src/pages/index/index.vue b/frontend/src/pages/index/index.vue index 323e2ed..ad9fd16 100644 --- a/frontend/src/pages/index/index.vue +++ b/frontend/src/pages/index/index.vue @@ -4,6 +4,9 @@ + + {{ displayActivityTitle }} + @@ -55,7 +58,7 @@ - {{ item.title }} @@ -105,7 +108,7 @@ @@ -527,7 +568,7 @@ onMounted(() => { } .main-title { - font-size: 36px; + font-size: 34px; font-weight: bold; margin-bottom: 8px; display: block; @@ -620,6 +661,19 @@ onMounted(() => { } } +.sms-reminder { + margin: -6px 10px 10px; + color: #888; + font-size: 12px; + line-height: 1.5; + text-align: left; +} + +.sms-reminder-title { + color: #f45a5a; + font-weight: 500; +} + .submit-btn { margin-top: 32px; height: 50px; @@ -637,6 +691,10 @@ onMounted(() => { } } +.sms-reminder + .submit-btn { + margin-top: 10px; +} + .login-link-box { margin-top: 16px; text-align: center; diff --git a/frontend/src/pages/profile/act.vue b/frontend/src/pages/profile/act.vue index 9662d05..aa57558 100644 --- a/frontend/src/pages/profile/act.vue +++ b/frontend/src/pages/profile/act.vue @@ -41,7 +41,7 @@ - @@ -118,6 +118,14 @@ const loading = ref(false); const hasMore = ref(true); const refresherTriggered = ref(false); +// 后台保存活动封面的相对路径,H5端统一通过当前环境的文件预览代理读取。 +const getActivityCoverUrl = (cover) => { + if (!cover) { + return ''; + } + return '/api/open/file/upload/download?inline=true&filepath=' + encodeURIComponent(cover); +}; + // 选择状态筛选 const selectStatusFilter = (status) => { pageForm.status = status; diff --git a/frontend/src/static/img/index4.png b/frontend/src/static/img/index4.png index ef583ab..b65a056 100644 Binary files a/frontend/src/static/img/index4.png and b/frontend/src/static/img/index4.png differ diff --git a/frontend/src/utils/config.js b/frontend/src/utils/config.js index 9fd706a..f2bb0eb 100644 --- a/frontend/src/utils/config.js +++ b/frontend/src/utils/config.js @@ -1,3 +1,4 @@ export const config = { - fileDomain: 'https://3skj.ltd', -} \ No newline at end of file + // 后台图片字段保存相对路径,统一通过当前环境的 API 代理读取,避免绑定固定部署域名。 + fileDomain: '/api/open/file/upload/download?inline=true&filepath=', +} diff --git a/frontend/src/utils/popupHistory.js b/frontend/src/utils/popupHistory.js new file mode 100644 index 0000000..2dca373 --- /dev/null +++ b/frontend/src/utils/popupHistory.js @@ -0,0 +1,100 @@ +import {Dialog} from 'tdesign-mobile-vue'; + +const popupHistoryStack = []; +let popupHistorySequence = 0; +let popstateListenerRegistered = false; + +const canUseBrowserHistory = () => typeof window !== 'undefined' && Boolean(window.history); + +// 浏览器返回时只关闭最后打开的弹框,避免直接离开当前业务页面。 +const handlePopState = () => { + const popupEntry = popupHistoryStack.pop(); + if (popupEntry && !popupEntry.programmaticClosing) { + popupEntry.closePopup(); + } +}; + +const ensurePopstateListener = () => { + if (!canUseBrowserHistory() || popstateListenerRegistered) { + return; + } + window.addEventListener('popstate', handlePopState); + popstateListenerRegistered = true; +}; + +/** + * 为弹框增加一层浏览器历史。 + * + * @param {Function} closePopup 用户通过浏览器返回键关闭弹框时执行的方法 + * @return {number|null} 弹框历史记录ID;非浏览器环境返回null + */ +const pushPopupHistory = (closePopup) => { + if (!canUseBrowserHistory()) { + return null; + } + ensurePopstateListener(); + popupHistorySequence += 1; + const id = popupHistorySequence; + popupHistoryStack.push({id, closePopup, programmaticClosing: false}); + window.history.pushState({popupHistoryId: id}, '', window.location.href); + return id; +}; + +/** + * 用户点击弹框按钮时同步回退对应的浏览器历史。 + * + * @param {number|null} id pushPopupHistory返回的弹框历史记录ID + * @return {void} + */ +const closePopupHistory = (id) => { + if (!canUseBrowserHistory() || id === null) { + return; + } + const popupEntry = popupHistoryStack[popupHistoryStack.length - 1]; + if (!popupEntry || popupEntry.id !== id || popupEntry.programmaticClosing) { + return; + } + popupEntry.programmaticClosing = true; + window.history.back(); +}; + +/** + * 显示带H5返回键支持的确认框。 + * + * @param {Object} options TDesign确认框参数;onConfirm、onCancel、onClose分别处理确认、取消和关闭 + * @return {Object|undefined} TDesign Dialog实例;服务端渲染环境可能返回undefined + */ +export const showConfirmWithHistory = (options = {}) => { + let popupHistoryId = null; + let dialogInstance = null; + const closeHistory = () => { + closePopupHistory(popupHistoryId); + }; + dialogInstance = Dialog.confirm({ + ...options, + onConfirm: (context) => { + closeHistory(); + if (options.onConfirm) { + options.onConfirm(context); + } + }, + onCancel: (context) => { + closeHistory(); + if (options.onCancel) { + options.onCancel(context); + } + }, + onClose: (context) => { + closeHistory(); + if (options.onClose) { + options.onClose(context); + } + } + }); + popupHistoryId = pushPopupHistory(() => { + if (dialogInstance) { + dialogInstance.hide(); + } + }); + return dialogInstance; +}; diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 24b42f3..151191f 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -34,8 +34,8 @@ export default defineConfig({ },*/ proxy: { '/api': { - target: 'http://119.91.224.39:9090/', - // target: 'http://192.168.118.1:9090/', + // target: 'http://119.91.224.39:9090/', + target: 'http://127.0.0.1:9090/', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') }