This commit is contained in:
2026-09-07 10:04:35 +08:00
parent c1589e4792
commit 7c34e41f7e
63 changed files with 3392 additions and 294 deletions
+10
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="cn.fjdmy.uniapp.UniappProjectDataService">
<option name="generalBasePath" value="$PROJECT_DIR$" />
<option name="manifestPath" value="$PROJECT_DIR$/manifest.json" />
<option name="pagesPath" value="$PROJECT_DIR$/pages.json" />
<option name="scanNum" value="1" />
<option name="type" value="store" />
</component>
</project>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<annotationProcessing>
<profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<module name="mini" />
</profile>
</annotationProcessing>
</component>
<component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="mini" options="-parameters" />
</option>
</component>
</project>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/backend/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/backend/src/main/resources" charset="UTF-8" />
</component>
</project>
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GitToolBoxProjectSettings">
<option name="commitMessageIssueKeyValidationOverride">
<BoolValueOverride>
<option name="enabled" value="true" />
</BoolValueOverride>
</option>
<option name="commitMessageValidationEnabledOverride">
<BoolValueOverride>
<option name="enabled" value="true" />
</BoolValueOverride>
</option>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+68
View File
@@ -0,0 +1,68 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="AliAccessStaticViaInstance" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliArrayNamingShouldHaveBracket" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliControlFlowStatementWithoutBraces" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliDeprecation" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliEqualsAvoidNull" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliLongLiteralsEndingWithLowercaseL" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliMissingOverrideAnnotation" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliWrapperTypeEquality" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAbstractClassShouldStartWithAbstractNaming" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAbstractMethodOrInterfaceMethodMustUseJavadoc" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidApacheBeanUtilsCopy" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidCallStaticSimpleDateFormat" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidCommentBehindStatement" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidComplexCondition" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidConcurrentCompetitionRandom" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidDoubleOrFloatEqualCompare" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidManuallyCreateThread" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidMissUseOfMathRandom" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidNegationOperator" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidNewDateGetTime" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidPatternCompileInMethod" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidReturnInFinally" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidStartWithDollarAndUnderLineNaming" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidUseTimer" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaBigDecimalAvoidDoubleConstructor" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaBooleanPropertyShouldNotStartWithIs" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaClassCastExceptionWithSubListToArrayList" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaClassCastExceptionWithToArray" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaClassMustHaveAuthor" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaClassNamingShouldBeCamel" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaCollectionInitShouldAssignCapacity" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaCommentsMustBeJavadocFormat" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaConcurrentExceptionWithModifyOriginSubList" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaConstantFieldShouldBeUpperCase" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaCountDownShouldInFinally" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaDontModifyInForeachCircle" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaEnumConstantsMustHaveComment" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaExceptionClassShouldEndWithException" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaIbatisMethodQueryForList" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaLockShouldWithTryFinally" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaLowerCamelCaseVariableNaming" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaMethodReturnWrapperType" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaMethodTooLong" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaPackageNaming" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaPojoMustOverrideToString" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaPojoMustUsePrimitiveField" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaPojoNoDefaultValue" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaRemoveCommentedCode" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaServiceOrDaoClassShouldEndWithImpl" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaSneakyThrowsWithoutExceptionType" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaStringConcat" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaSwitchExpression" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaSwitchStatement" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaTestClassShouldEndWithTestNaming" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaThreadLocalShouldRemove" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaThreadPoolCreation" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaThreadShouldSetName" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaTransactionMustHaveRollback" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaUndefineMagicConstant" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaUnsupportedExceptionWithModifyAsList" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaUseQuietReferenceNotation" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaUseRightCaseForDateFormat" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="MapOrSetKeyShouldOverrideHashCodeEquals" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="nutz-snapshots" />
<option name="name" value="nutz-snapshots" />
<option name="url" value="https://maven.aliyun.com/repository/public" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Central Repository" />
<option name="url" value="https://maven.aliyun.com/repository/public" />
</remote-repository>
<remote-repository>
<option name="id" value="nutz" />
<option name="name" value="nutz" />
<option name="url" value="https://maven.aliyun.com/repository/public" />
</remote-repository>
</component>
</project>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/backend/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/hmc_gbhd.iml" filepath="$PROJECT_DIR$/.idea/hmc_gbhd.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+1 -1
View File
@@ -23,7 +23,7 @@
<pinyin4j.veision>2.5.1</pinyin4j.veision>
<poi.version>4.1.1</poi.version>
<tencentcloud-sdk-java.version>4.0.11</tencentcloud-sdk-java.version>
<lombok.version>1.18.18</lombok.version>
<lombok.version>1.18.22</lombok.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
@@ -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);
}
@@ -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?"男":"女");
@@ -20,5 +20,8 @@ public class ClientRegisterBody {
private String unitId;
/** 注册时填写的单位简称,仅在所选单位尚未设置简称时写入单位资料。 */
private String unitAliasName;
private String smsCode;
}
@@ -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));
}
/**
* 是否报名
*/
@@ -19,6 +19,30 @@ public interface ClientActivityService extends BaseService<Activity> {
*/
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);
/**
* 报名签到
*/
@@ -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<Activity> implements ClientActivityService {
@Inject
private ActivityRegistrationFieldService activityRegistrationFieldService;
public ClientActivityServiceImpl(Dao dao) {
super(dao);
}
@@ -46,9 +55,118 @@ public class ClientActivityServiceImpl extends BaseServiceImpl<Activity> 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<ActUser> 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<String, Boolean> requiredMap = activityRegistrationFieldService.getRequiredMap(signVO.getActivityId());
List<ActUser> oldUsers = dao().query(ActUser.class,
Cnd.where(ActUser::getActivityId, "=", signVO.getActivityId())
.and(ActUser::getSignUpUserId, "=", StpClientUtil.STP.getLoginIdAsLong()));
Map<Long, ActUser> oldUserById = oldUsers.stream().filter(item -> item.getId() != null)
.collect(Collectors.toMap(ActUser::getId, item -> item, (left, right) -> left, LinkedHashMap::new));
Map<String, ActUser> 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<ActUser> 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<Activity> 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<Activity> 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<Activity> impleme
dao().insert(userList);
}
/**
* 按活动字段配置校验一名报名人员。
* 字段未启用时不校验;关联字段仅在父字段满足显示条件时参与必填校验。
*/
private void validateRegistrationFields(Long activityId, ActUser user, Map<String, Boolean> requiredMap) {
for (Map.Entry<String, Boolean> 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<String, Boolean> 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()));
@@ -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));
}
/**
@@ -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));
}
/**
@@ -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<Activity> activityList = dao.query(Activity.class, Cnd.NEW().andEX("YEAR(startTime)", "=", year).and(Activity::getDelFlag, "=", false));
public Result getActivityList(Integer year) {
List<Activity> 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")
@@ -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<ActWonderfulMoment> momentList = wonderfulMomentService.query(cnd);
List<ActWonderfulMoment> momentList = wonderfulMomentService.listClientVisible(id);
List<NutMap> 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();
}
}
@@ -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()
@@ -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<ActivityItem> oldItems = dao.query(ActivityItem.class, Cnd.where(ActivityItem::getActivityId, "=", id));
// 更新项目信息
List<Long> 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<Long> newItemIdList = Arrays.stream(items).map(ActivityItem::getId).toList();
// 删除旧的项目信息
Collection<Long> 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<Activity> activityList = activityService.query(Cnd.NEW());
// 客户端首页的活动详情只展示后台已开启且未删除的活动。
List<Activity> activityList = activityService.query(Cnd.where(Activity::getIsOpen, "=", true)
.and(Activity::getDelFlag, "=", false));
return Result.success(activityList);
}
}
@@ -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()
@@ -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<RegistrationFieldDefinition> defaultFields() {
return Arrays.stream(values()).filter(RegistrationFieldDefinition::isDefaultSelected).toList();
}
}
@@ -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;
}
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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<ActWonderfulMoment> {
/**
* 查询客户端可见的精彩瞬间。
*
* @param id 精彩瞬间ID;传空时查询全部可见数据,传值时只查询指定数据
* @return 精彩瞬间列表;仅包含已发布且所属活动已开启的数据
*/
List<ActWonderfulMoment> listClientVisible(Long id);
/**
* 查询一条客户端可见的精彩瞬间。
*
* @param id 精彩瞬间ID,不能为空
* @return 精彩瞬间;未发布、所属活动关闭或数据不存在时返回null
*/
ActWonderfulMoment fetchClientVisible(Long id);
/**
* 增加客户端可见精彩瞬间的阅读次数。
*
* @param id 精彩瞬间ID,不能为空
* @return 更新记录数;1表示成功,0表示数据当前不可见或不存在
*/
int increaseClientReadCount(Long id);
}
@@ -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<Activity> {
/**
* 查询指定年度的有效活动,并将最后创建的活动排在最前面。
*
* @param year 活动开始时间所属年度;传空时查询全部年度
* @return 未删除活动列表,按照创建时间和活动ID倒序排列
*/
List<Activity> 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<ActivityItem> items);
}
@@ -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<ActRegistrationField> {
/**
* 获取活动报名字段目录及配置状态。
*
* @param activityId 活动ID
* @param selectedOnly true只返回已启用字段,false返回完整安全字段目录
* @param includeOptions true时为数据字典字段附带可用选项
* @return 字段配置列表;包含字段编码、名称、组件类型、必填状态、顺序、依赖和字典选项
*/
List<NutMap> getFieldConfig(Long activityId, boolean selectedOnly, boolean includeOptions);
/**
* 覆盖保存某活动的报名字段配置。
*
* @param activityId 活动ID
* @param fields 管理端选中的字段配置
*/
void saveFieldConfig(Long activityId, List<ActRegistrationField> fields);
/**
* 获取当前活动有效字段的必填规则。
*
* @param activityId 活动ID
* @return key为字段编码,value表示是否必填
*/
Map<String, Boolean> 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<ActUser> users);
/**
* 校验同一活动、同一单位全部报名人员的项目类型组合。
* 校验前会将历史“单打”“双打”代码按人员性别转换为当前男子或女子单打、双打代码。
*
* @param activityId 活动ID,用于读取唯一项目的项目类型下级字典
* @param users 同一活动、同一单位的全部报名人员,包含本次提交人员和其他账号已提交人员
* @throws com.budwk.app.base.exception.BaseException 未选择运动员类型、同性别单双打冲突、项目性别不符或混双男女数量不一致时抛出
*/
void validateProjectTypeRules(Long activityId, List<ActUser> users);
}
@@ -79,7 +79,15 @@ public class ActUserServiceImpl extends BaseServiceImpl<ActUser> 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;
}
@@ -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<ActWonderfulM
public ActWonderfulMomentServiceImpl(Dao dao) {
super(dao);
}
@Override
public List<ActWonderfulMoment> 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)"));
}
}
@@ -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<Activity> implements ActivityManageService {
@Inject
private SysDictService sysDictService;
public ActivityManageServiceImpl(Dao dao) {
super(dao);
}
@Override
public List<Activity> 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<ActivityItem> items) {
if (activityId == null || this.fetch(activityId) == null) {
throw new BaseException("活动不存在");
}
List<ActivityItem> submittedItems = items == null ? Collections.emptyList() : items;
Set<String> 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<ActivityItem> oldItems = dao().query(ActivityItem.class,
Cnd.where(ActivityItem::getActivityId, "=", activityId));
List<Long> 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<Long> submittedItemIds = submittedItems.stream().map(ActivityItem::getId).toList();
Collection<Long> 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<Sys_dict> children = sysDictService.getSubListByCode(item.getProjectType()).stream()
.filter(dict -> !dict.isDisabled() && Strings.isNotBlank(dict.getCode()))
.toList();
if (children.isEmpty()) {
item.setRegistrationRule("[]");
return;
}
List<NutMap> 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<String> childCodes = children.stream().map(Sys_dict::getCode).collect(java.util.stream.Collectors.toSet());
Map<String, NutMap> 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<NutMap> 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()));
}
}
@@ -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<ActRegistrationField>
implements ActivityRegistrationFieldService {
@Inject
private SysDictService sysDictService;
public ActivityRegistrationFieldServiceImpl(Dao dao) {
super(dao);
}
@Override
public List<NutMap> getFieldConfig(Long activityId, boolean selectedOnly, boolean includeOptions) {
ProjectTypeContext projectTypeContext = getProjectTypeContext(activityId);
List<ActRegistrationField> storedFields = this.query(Cnd.where(ActRegistrationField::getActivityId, "=", activityId)
.and(ActRegistrationField::getDelFlag, "=", false)
.asc(ActRegistrationField::getSort));
Map<String, ActRegistrationField> storedMap = storedFields.stream()
.collect(Collectors.toMap(ActRegistrationField::getFieldKey, item -> item, (left, right) -> left,
LinkedHashMap::new));
boolean useDefault = storedFields.isEmpty();
List<NutMap> 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<ActRegistrationField> fields) {
if (activityId == null || dao().fetch(Activity.class, activityId) == null) {
throw new BaseException("活动不存在");
}
Map<String, ActRegistrationField> 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<String> 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<ActRegistrationField> 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<String, Boolean> getRequiredMap(Long activityId) {
Map<String, Boolean> 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<NutMap> options = RegistrationFieldDefinition.PROJECT_TYPE == definition
? getProjectTypeContext(activityId).options
: getEnabledDictOptions(definition.getDictCode());
Set<String> 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<NutMap> options = getEnabledDictOptions(RegistrationFieldDefinition.PERSON_TYPE.getDictCode());
String coachValue = findOptionValue(options, "教练");
String leaderValue = findOptionValue(options, "领队");
Set<String> 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<NutMap> 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<ActUser> 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<ActivityItem> 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<NutMap> options = getProjectTypeContext(activityId).options;
Map<String, String> optionNames = options.stream().collect(Collectors.toMap(
option -> option.getString("value"),
option -> option.getString("label"),
(left, right) -> left,
LinkedHashMap::new));
List<NutMap> 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<String, Integer> existingCounts = countProjectTypes(dao().query(ActUser.class, cnd), options);
List<NutMap> 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<ActUser> users) {
if (users == null || users.isEmpty() || !hasSingleActivityItem(activityId)) {
return;
}
List<NutMap> 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<ActUser> 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<ActUser> users) {
if (users == null || users.isEmpty()) {
return;
}
List<NutMap> 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<String> 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<String> selectedValues, String legacyValue, String targetValue) {
if (selectedValues.remove(legacyValue) && Strings.isNotBlank(targetValue)) {
selectedValues.add(targetValue);
}
}
/**
* 校验唯一项目各类型子项的单位报名人数上限。
* 调用方传入同一活动、同一单位的最终报名人员集合;每名人员对其选中的每个子项各计一人。
* 规则人数为0表示该子项不允许报名,子项人数之间互不累加,也不与项目总报名人数比较。
*/
private void validateProjectTypeRegistrationLimits(Long activityId, List<ActUser> users,
List<NutMap> options) {
List<ActivityItem> items = dao().query(ActivityItem.class,
Cnd.where(ActivityItem::getActivityId, "=", activityId));
if (items.size() != 1 || Strings.isBlank(items.get(0).getRegistrationRule())) {
return;
}
List<NutMap> registrationRules = parseRegistrationRules(items.get(0).getRegistrationRule());
if (registrationRules == null || registrationRules.isEmpty()) {
return;
}
Map<String, Integer> selectedCounts = countProjectTypes(users, options);
Map<String, String> 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<NutMap> parseRegistrationRules(String registrationRuleJson) {
try {
List<NutMap> 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<String, Integer> countProjectTypes(List<ActUser> users, List<NutMap> options) {
Map<String, Integer> counts = new LinkedHashMap<>();
String manSingleValue = findOptionValue(options, "男子单打");
String womanSingleValue = findOptionValue(options, "女子单打");
String manDoubleValue = findOptionValue(options, "男子双打");
String womanDoubleValue = findOptionValue(options, "女子双打");
for (ActUser user : users) {
Set<String> 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<ActivityItem> 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<NutMap> 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<NutMap> options;
private final String message;
private ProjectTypeContext(boolean configurable, String dictCode, List<NutMap> options, String message) {
this.configurable = configurable;
this.dictCode = dictCode;
this.options = options;
this.message = message;
}
}
private void addParentField(Map<String, ActRegistrationField> 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<NutMap> 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<String> 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<NutMap> options, String label) {
return options.stream()
.filter(item -> label.equals(item.getString("label")))
.map(item -> item.getString("value"))
.findFirst()
.orElse(null);
}
}
@@ -263,7 +263,7 @@ public class SysUserServiceImpl extends BaseServiceImpl<Sys_user> 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;
@@ -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<Sys_unit> 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());
}
}
@@ -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);
+2 -2
View File
@@ -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
@@ -14,7 +14,7 @@
>
<i slot="default" class="el-icon-plus"></i>
<div slot="file" slot-scope="{file}">
<img class="el-upload-list__item-thumbnail" :src="'/open/file/upload/download?filepath=' + file.url" alt=""
<img class="el-upload-list__item-thumbnail" :src="'/open/file/upload/download?inline=true&filepath=' + file.url" alt=""
style="var(--box-width, '100px') !important;height: var(--box-height, '100px') !important">
<span class="el-upload-list__item-actions">
<span class="el-upload-list__item-preview" @click="handlePictureCardPreview(file)">
@@ -27,7 +27,7 @@
</div>
</el-upload>
<el-dialog :visible.sync="dialogVisible" append-to-body>
<img width="100%" :src="'/open/file/upload/download?filepath=' + dialogImageUrl" alt="">
<img width="100%" :src="'/open/file/upload/download?inline=true&filepath=' + dialogImageUrl" alt="">
</el-dialog>
</div>
</template>
@@ -2,6 +2,25 @@
layout("/layouts/platform_1.html"){
#-->
<style>
.registration-field-setting {
height: calc(100vh - 80px);
padding: 0 20px 70px;
overflow-y: auto;
box-sizing: border-box;
}
.registration-field-actions {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 14px 20px;
text-align: right;
background: #fff;
border-top: 1px solid #ebeef5;
}
</style>
<div id="app">
<el-card shadow="never">
<div class="search">
@@ -35,11 +54,22 @@ layout("/layouts/platform_1.html"){
<el-table-column label="报名结束时间" prop="signEndTime"></el-table-column>
<el-table-column label="活动开始时间" prop="startTime"></el-table-column>
<el-table-column label="活动结束时间" prop="endTime"></el-table-column>
<el-table-column label="操作" fixed="right" width="250">
<el-table-column label="是否开启" prop="isOpen" header-align="center" align="center" width="100">
<template #default="scope">
<el-switch
v-model="scope.row.isOpen"
active-color="#13ce66"
inactive-color="#ff4949"
@change="(val) => {onOpenStatusChange(val, scope.row)}"
></el-switch>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="350">
<template #default="scope">
<el-button type="text" @click="$refs.actQrCodeRef.onOpen(scope.row.id)">二维码</el-button>
<el-button type="text" @click="$refs.addOrUpdateRef.onOpen(scope.row.id)">修改</el-button>
<el-button type="text" @click="$refs.itemFormRef.onOpen(scope.row.id)">项目管理</el-button>
<el-button type="text" @click="$refs.registrationFieldSettingRef.onOpen(scope.row.id)">设置报名信息</el-button>
<el-button type="text" class="text-danger" @click="onTableRowDelete(scope.row.id)">删除</el-button>
</template>
</el-table-column>
@@ -48,12 +78,14 @@ layout("/layouts/platform_1.html"){
</el-card>
<add-or-update ref="addOrUpdateRef" @refresh="doSearch"></add-or-update>
<item-form ref="itemFormRef"></item-form>
<registration-field-setting ref="registrationFieldSettingRef"></registration-field-setting>
<act-qr-code ref="actQrCodeRef"></act-qr-code>
</div>
<script>
<!--#include('addOrUpdate.js'){}#-->
<!--#include('itemForm.js'){}#-->
<!--#include('registrationFieldSetting.js'){}#-->
<!--#include('actQrCode.js'){}#-->
new Vue({
el: "#app",
@@ -61,6 +93,7 @@ layout("/layouts/platform_1.html"){
components: {
"add-or-update": addOrUpdate,
"item-form": itemForm,
"registration-field-setting": registrationFieldSetting,
"act-qr-code": actQrCode
},
data() {
@@ -71,6 +104,28 @@ layout("/layouts/platform_1.html"){
},
created() {
this.doSearch()
},
methods: {
// isOpen=true 时活动在独立 Vue 客户端显示,false 时仅从客户端活动列表隐藏。
onOpenStatusChange(val, row) {
this.tableLoading = true
$.post('/platform/activity/manage/updateOpenStatus', {
id: row.id,
isOpen: val
}).then((res) => {
if (res.code === 0) {
this.$message.success('状态修改成功')
} else {
this.$set(row, 'isOpen', !val)
this.$message.error(res.msg)
}
}, () => {
this.$set(row, 'isOpen', !val)
this.$message.error('状态修改失败')
}).always(() => {
this.tableLoading = false
})
}
}
})
</script>
@@ -23,6 +23,21 @@ const itemForm = {
<el-input v-model="item.name" placeholder="请输入项目名称" maxlength="50"
show-word-limit></el-input>
</el-form-item>
<el-form-item
:prop="'tableData.' + index + '.projectType'"
:rules="[
{ required: true, message: '请选择项目类型', trigger: 'change' }
]"
label="项目类型"
label-width="150px"
>
<el-select v-model="item.projectType" placeholder="请选择项目类型" clearable filterable
@change="onProjectTypeChange(item)"
:loading="projectTypeLoading" style="width: 100%">
<el-option v-for="option in projectTypeOptions" :key="option.code"
:label="option.name" :value="option.code"></el-option>
</el-select>
</el-form-item>
<el-form-item label="是否团队合作" label-width="150px">
<el-radio-group v-model="item.isTeamApply" size="medium" @change="item.num=0">
<el-radio-button :label="true">是</el-radio-button>
@@ -83,6 +98,36 @@ const itemForm = {
maxlength="500" show-word-limit></el-input>
</el-form-item>
</el-tab-pane>
<el-tab-pane label="报名规则">
<el-alert
title="各子项报名人数独立计算,人数合计可以超过本项目报名人数;人数设置为0表示该子项不允许报名。"
type="info"
:closable="false"
show-icon>
</el-alert>
<div v-if="!item.projectType" class="registration-rule-empty">
请先选择项目类型
</div>
<el-table v-else v-loading="item.registrationRuleLoading"
:data="item.registrationRules" class="mt-3" border>
<el-table-column label="项目类型子项" prop="typeName"></el-table-column>
<el-table-column label="报名人数" width="260" align="center">
<template #default="ruleScope">
<el-form-item
:prop="'tableData.' + index + '.registrationRules.' + ruleScope.$index + '.personCount'"
:rules="[
{ required: true, message: '请设置报名人数', trigger: 'change' },
{ type: 'number', min: 0, message: '报名人数不能小于0', trigger: 'change' }
]"
class="registration-rule-form-item">
<el-input-number v-model="ruleScope.row.personCount" :min="0" :max="9999"
:precision="0"
controls-position="right"></el-input-number>
</el-form-item>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="项目图片">
<el-form-item>
<image-upload v-model="item.img" :maxCount="1"></image-upload>
@@ -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;
}
`
}
@@ -0,0 +1,233 @@
const registrationFieldSetting = {
template: /*language=HTML*/ `
<el-drawer
title="设置报名信息"
:visible.sync="visible"
size="58%"
:wrapper-closable="false"
:before-close="handleClose">
<div class="registration-field-setting" v-loading="loading">
<el-alert
title="姓名、联系电话和单位名称为系统固定字段;关联字段会自动保持完整的父子关系。"
type="info"
:closable="false"
show-icon>
</el-alert>
<el-alert
v-if="projectTypeRuleMessage"
:title="projectTypeRuleMessage"
:type="projectTypeConfigurable ? 'success' : 'warning'"
class="mt-3"
:closable="false"
show-icon>
</el-alert>
<el-table :data="fieldList" class="mt-3" border>
<el-table-column label="显示" width="80" align="center">
<template #default="scope">
<el-checkbox
v-model="scope.row.selected"
:disabled="scope.row.locked || scope.row.configurable === false"
@change="(val) => onSelectedChange(scope.row, val)">
</el-checkbox>
</template>
</el-table-column>
<el-table-column label="报名字段" prop="fieldLabel" min-width="130"></el-table-column>
<el-table-column label="字段类型" min-width="120">
<template #default="scope">
{{ componentTypeLabel(scope.row.componentType) }}
</template>
</el-table-column>
<el-table-column label="数据来源" min-width="140">
<template #default="scope">
{{ dataSourceLabel(scope.row) }}
</template>
</el-table-column>
<el-table-column label="必填" width="90" align="center">
<template #default="scope">
<el-switch
v-model="scope.row.required"
:disabled="!scope.row.selected || scope.row.locked || scope.row.configurable === false">
</el-switch>
</template>
</el-table-column>
<el-table-column label="顺序" width="120" align="center">
<template #default="scope">
<el-input-number
v-model="scope.row.sort"
:disabled="!scope.row.selected || scope.row.configurable === false"
:min="1"
:max="99"
controls-position="right"
size="small">
</el-input-number>
</template>
</el-table-column>
<el-table-column label="联动说明" min-width="170">
<template #default="scope">
{{ dependencyLabel(scope.row) }}
</template>
</el-table-column>
</el-table>
<div class="registration-field-actions">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">确定</el-button>
</div>
</div>
</el-drawer>
`,
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 : "-"
}
}
}
@@ -22,7 +22,7 @@ layout("/layouts/platform_1.html"){
<div class="search-item">
<div class="search-item-label">所属活动</div>
<div class="search-item-option">
<el-select v-model="pageForm.activityId" placeholder="请选择活动">
<el-select v-model="pageForm.activityId" placeholder="请选择活动" @change="onActivityChange">
<el-option
v-for="item in activityOptions"
:key="item.id"
@@ -85,6 +85,16 @@ layout("/layouts/platform_1.html"){
<span>{{scope.$index+(pageForm.pageNumber - 1) * pageForm.pageSize + 1}} </span>
</template>
</el-table-column>
<!-- 报名信息列根据当前活动的字段配置生成。 -->
<el-table-column v-for="field in registrationFields" :key="field.fieldKey"
:label="field.fieldLabel" :prop="field.fieldKey"
:min-width="getRegistrationFieldWidth(field)">
<template slot-scope="{row}">
{{formatRegistrationField(row, field)}}
</template>
</el-table-column>
<!-- 原固定报名字段列仅屏蔽显示,保留代码便于后续恢复或对照。 -->
<template v-if="false">
<el-table-column label="姓名" prop="userName" min-width="100px"></el-table-column>
<el-table-column label="性别" prop="sex" min-width="60px"></el-table-column>
<el-table-column label="职务" prop="position" min-width="100px"></el-table-column>
@@ -115,6 +125,8 @@ layout("/layouts/platform_1.html"){
{{row.isParty ? '是':''}}
</template>
</el-table-column>
</template>
<el-table-column label="签到时间" prop="signInTime" min-width="120px"></el-table-column>
<!-- <el-table-column label="物品发放" prop="isGrant">-->
<!-- <template slot-scope="scope">-->
<!-- <el-tag v-if="scope.row.isGrant" type="success">已发放</el-tag>-->
@@ -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);
}
@@ -21,7 +21,7 @@ layout("/layouts/platform_1.html"){
<div class="search-item">
<div class="search-item-label">所属活动</div>
<div class="search-item-option">
<el-select @change="doSearch"
<el-select @change="onActivityChange"
placeholder="请选择活动查询"
style="width: 100%;"
v-model="pageForm.activityId">
@@ -54,7 +54,7 @@ layout("/layouts/platform_1.html"){
<el-select placeholder="请选择所属单位"
style="width: 100%;" clearable
filterable
@change="setModifyFormUnit"
:loading="unitLoading"
v-model="pageForm.unitId">
<el-option :label="item.name" :value="item.id"
v-for="item in units"></el-option>
@@ -74,11 +74,22 @@ layout("/layouts/platform_1.html"){
<el-button icon="el-icon-plus" type="primary" size="small" @click="openAddUser">添加报名人员</el-button>
<el-button icon="el-icon-download" type="primary" size="small" @click="doExport">导出</el-button>
</el-row>
<el-table v-loading="tableLoading" show-overflow-tooltip :data="tableData">
<el-table v-loading="tableLoading" show-overflow-tooltip :data="tableData" @sort-change="pageOrder">
<el-table-column label="序号" type="index" header-align="center" align="center"
fixed="left"
:index="indexMethod"
width="50"></el-table-column>
<!-- 报名信息列根据当前活动的字段配置生成。 -->
<el-table-column v-for="field in registrationFields" :key="field.fieldKey"
:label="field.fieldLabel" :prop="field.fieldKey"
:sortable="field.fieldKey === 'unitName' ? 'custom' : false"
:min-width="getRegistrationFieldWidth(field)">
<template slot-scope="{row}">
{{formatRegistrationField(row, field)}}
</template>
</el-table-column>
<!-- 原固定报名字段列仅屏蔽显示,保留代码便于后续恢复或对照。 -->
<template v-if="false">
<el-table-column label="姓名" prop="userName" width="100px" fixed="left"></el-table-column>
<el-table-column label="性别" prop="sex" width="60px" fixed="left"></el-table-column>
<el-table-column label="体重(kg)" prop="weight" width="100px"></el-table-column>
@@ -117,6 +128,14 @@ layout("/layouts/platform_1.html"){
{{row.isSIgnUpUser ? '是':''}}
</template>
</el-table-column>
</template>
<el-table-column label="报名时间" prop="signUpTime" width="150px"></el-table-column>
<el-table-column label="是否签到" prop="isSignIn" width="90px">
<template slot-scope="{row}">
<el-tag v-if="row.isSignIn" size="mini" type="success">已签到</el-tag>
<el-tag v-else size="mini" type="info">未签到</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="200">
<template #default="scope">
<el-button type="text" @click="openModify(scope.row)">修改</el-button>
@@ -331,6 +350,7 @@ layout("/layouts/platform_1.html"){
year: new Date().getFullYear() + '',
},
activityList: [],
registrationFields: [],
// 修改表单
modifyDialog: false,
modifyForm: {},
@@ -402,9 +422,81 @@ layout("/layouts/platform_1.html"){
},
units: [],
unitLoading: false,
}
},
methods: {
// 活动切换后同步刷新动态列和列表数据。
onActivityChange() {
this.loadRegistrationFields(() => {
this.doSearch()
})
},
// 获取当前活动启用的报名字段;字典选项用于把保存的code转换成名称。
loadRegistrationFields(callback) {
if (!this.pageForm.activityId) {
this.registrationFields = []
if (callback) callback()
return
}
$.post('/platform/activity/user/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) {
// act_user历史表字段为大写开头的ProjectType,原生SQL返回时需兼容其实际列名。
const fieldValue = row[field.fieldKey]
const value = field.fieldKey === 'projectType' && (fieldValue === null || fieldValue === undefined || fieldValue === '')
? row.ProjectType
: fieldValue
if (value === null || value === undefined || value === '') {
// 项目类型仅适用于运动员;区分正常的不适用状态与运动员漏选,便于管理员核对报名数据。
if (field.fieldKey === 'projectType') {
return this.isAthleteRegistration(row) ? '未选择' : '无需选择'
}
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
},
// 人员类型保存的是逗号分隔的字典编码,运动员编码从当前字段配置中动态解析,避免硬编码字典值。
isAthleteRegistration(row) {
const personTypeField = this.registrationFields.find((item) => item.fieldKey === 'personType')
const athleteOption = (personTypeField && personTypeField.options || [])
.find((option) => option.label === '运动员')
if (!athleteOption || !row.personType) return false
return String(row.personType).split(',').map((code) => code.trim()).includes(athleteOption.value)
},
getRegistrationFieldWidth(field) {
if (field.fieldKey === 'unitName') return 180
if (field.fieldKey === 'phone' || field.componentType === 'dict-checkbox') return 140
return 100
},
setFormDataUnit(val) {
if (!val) {
this.formData.unitName = ''
@@ -575,23 +667,41 @@ layout("/layouts/platform_1.html"){
}
})
},
async getActivityList(){
const resp = await $.post('/platform/activity/user/getActivityList', {
getActivityList(){
$.post('/platform/activity/user/getActivityList', {
year: this.pageForm.year
}).then((resp) => {
if (resp.code === 0 && resp.data && resp.data.length > 0) {
this.activityList = resp.data
this.$set(this.pageForm, 'activityId', this.activityList[0].id)
} else {
this.activityList = []
this.$set(this.pageForm, 'activityId', '')
}
this.loadRegistrationFields(() => {
this.pageData()
})
})
if (resp.code === 0 && resp.data && resp.data.length > 0) {
this.activityList = resp.data
this.$set(this.pageForm, 'activityId', this.activityList[0].id)
} else {
this.$set(this.pageForm, 'activityId', '')
}
this.pageData()
},
async getUnits(){
const resp = await $.post('/open/common/units')
if (resp.code === 0) {
this.units = resp.data
}
// 查询面板和代报名弹窗共用启用单位列表,并按单位名称排序,便于快速检索。
getUnits(){
this.unitLoading = true
$.post('/open/common/units').then((resp) => {
if (resp.code === 0) {
const units = (resp.data || []).slice().sort((left, right) => {
return String(left.name || '').localeCompare(String(right.name || ''), 'zh-CN')
})
this.$set(this, 'units', units)
} else {
this.$set(this, 'units', [])
this.$message.error(resp.msg || '加载单位列表失败')
}
}).fail(() => {
this.$set(this, 'units', [])
this.$message.error('加载单位列表失败')
}).always(() => {
this.unitLoading = false
})
}
},
created() {
@@ -2,6 +2,16 @@ const addOrUpdate = {
template: /*language=HTML*/ `
<el-dialog :title="form.id ? '修改' : '新增'" :visible.sync="visible" width="70%">
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-form-item label="所属活动" prop="activityId">
<el-select v-model="form.activityId" filterable placeholder="请选择所属活动" style="width: 100%">
<el-option
v-for="activity in activityList"
:key="activity.id"
:label="activity.title"
:value="activity.id">
</el-option>
</el-select>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" placeholder="请输入标题"></el-input>
</el-form-item>
@@ -25,6 +35,12 @@ const addOrUpdate = {
</template>
</el-dialog>
`,
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']}],
@@ -72,7 +72,7 @@ layout("/layouts/platform_1.html"){
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
<add-or-update ref="addOrUpdateRef" @refresh="doSearch"></add-or-update>
<add-or-update ref="addOrUpdateRef" :activity-list="activityList" @refresh="doSearch"></add-or-update>
</div>
<script>
@@ -123,14 +123,19 @@ layout("/layouts/platform_1.html"){
}
})
},
async getActivityList(){
const resp = await $.post('/platform/activity/user/getActivityList')
this.activityList = resp.data
getActivityList(){
$.post('/platform/activity/user/getActivityList').then((res) => {
if (res.code === 0) {
this.activityList = res.data
} else {
this.$message.error(res.msg)
}
})
},
},
async created() {
created() {
this.getActivityList()
await this.pageData()
this.pageData()
}
})
</script>
@@ -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<ActUser> 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");
}
}
+2 -2
View File
@@ -3,7 +3,7 @@
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "趣味运动会"
"navigationBarTitleText": "赛事管理"
}
},
{
@@ -101,7 +101,7 @@
],
"globalStyle": {
"navigationBarTextStyle": "#000000",
"navigationBarTitleText": "趣味运动会",
"navigationBarTitleText": "赛事管理",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#F8F8F8"
},
+18 -3
View File
@@ -49,10 +49,9 @@
style="flex-shrink:0;width: 120px;height: 90px;border-radius: 5px;" fit="fill"/>
<view
style="margin-left:10px;flex:1;overflow:hidden;display: flex;flex-direction: column;justify-content:space-around;color: #534d4d;">
<view
style="display: flex; align-items: center; column-gap: 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<view class="activity-date-tag">
<t-icon name="time"></t-icon>
{{ moment(item.startTime).format('MM-DD') }}
<text>{{ moment(item.startTime).format('YYYY年MM月DD日') }}</text>
</view>
<view style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<t-icon name="location"></t-icon>
@@ -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;
@@ -4,13 +4,20 @@
<view class="tabs-container">
<view class="tabs-header">
<view class="tabs-title">
<text class="title-text">报名人员</text>
<text class="title-text">报名人员(总{{ unitRegistrationTotal }}人)</text>
<view class="header-actions">
<view class="person-counter">
<text class="counter-text">{{ formData.userList.length }}人</text>
</view>
<view class="header-add-btn" @click="addNewUser">
<t-icon name="add" size="16px"></t-icon>
<view class="header-action-btn header-add-btn" @click="addNewUser">
<AddIcon class="header-action-icon" size="18px" />
<text>添加</text>
</view>
<view v-if="formData.userList.length > 0"
class="header-action-btn header-remove-btn"
@click="removeUser(activeTabIndex)">
<DeleteIcon class="header-action-icon" size="17px" />
<text>删除</text>
</view>
</view>
</view>
@@ -25,9 +32,6 @@
@click="switchTab(index)"
>
<view class="tab-content">
<view class="tab-icon" v-if="activeTabIndex === index">
<t-icon name="user" size="16px"></t-icon>
</view>
<text class="tab-text">{{ user.userName || `人员${index + 1}` }}</text>
</view>
</view>
@@ -45,7 +49,6 @@
label-align="left"
scroll-to-first-error="auto"
labelWidth="100px"
:requiredMark="true"
>
<!-- <view class="form-block">-->
<!-- <view class="form-block-title">-->
@@ -68,104 +71,139 @@
<view class="form-block-title">
<t-icon name="usergroup"></t-icon>
<text>报名人员信息</text>
<text class="form-subtitle">{{ activeTabIndex + 1 }}/{{ formData.userList.length }}</text>
<view class="user-actions">
<view class="remove-user" @click="removeUser(activeTabIndex)" v-if="formData.userList.length > 1">
<t-icon name="delete" size="18px"></t-icon>
</view>
</view>
<text class="form-subtitle">
{{ formData.userList.length > 0 ? activeTabIndex + 1 : 0 }}/{{ formData.userList.length }}
</text>
</view>
<view v-if="formData.userList.length === 0" class="form-empty">
暂无报名人员,请点击“添加”
</view>
<!-- 所有人员的表单,使用v-show控制显示 -->
<view v-for="(user, index) in formData.userList" :key="index" class="form-fields-container"
v-show="activeTabIndex === index">
<t-form-item label="姓名" :name="`userList[${index}].userName`"
:rules="[{required:true,message: '请输入姓名'}]">
<t-form-item v-if="isFieldVisible('userName')" label="姓名" :name="`userList[${index}].userName`"
:rules="fieldRules('userName', '请输入姓名')">
<t-input v-model="user.userName" borderless placeholder="请输入内容"></t-input>
</t-form-item>
<t-form-item label="性别" :name="`userList[${index}].sex`" :rules="[{required:true,message: '请选择性别'}]">
<t-radio-group v-model="user.sex" borderless class="radio-box">
<t-form-item v-if="isFieldVisible('sex')" label="性别" :name="`userList[${index}].sex`"
:rules="fieldRules('sex', '请选择性别')">
<t-radio-group v-model="user.sex" borderless class="radio-box" @change="handleSexChange(user)">
<t-radio :block="false" label="男" value="男"/>
<t-radio :block="false" label="女" value="女"/>
</t-radio-group>
</t-form-item>
<t-form-item label="体重(KG)" :name="`userList[${index}].weight`"
:rules="[{required:true,message: '请输入体重拔河比赛分组需要'}]">
<t-form-item v-if="isFieldVisible('personType')" label="人员类型"
:name="'userList[' + index + '].personType'"
:rules="fieldRules('personType', '请选择人员类型')">
<t-checkbox-group v-model="user.personType" class="radio-box dict-radio-box"
@change="handlePersonTypeChange(user, $event)">
<t-checkbox v-for="option in fieldOptions('personType')" :key="option.value"
:label="option.label" :value="option.value"
:block="false" borderless
:disabled="isPersonTypeOptionDisabled(user, option)"/>
</t-checkbox-group>
</t-form-item>
<t-form-item v-if="isFieldVisible('projectType')" label="项目类型"
:name="'userList[' + index + '].projectType'"
:rules="fieldRules('projectType', '请选择项目类型')">
<t-checkbox-group v-model="user.projectType" class="radio-box dict-radio-box"
@change="handleProjectTypeChange(user, $event)">
<t-checkbox v-for="option in fieldOptions('projectType')" :key="option.value"
:label="option.label" :value="option.value" :block="false" borderless
:disabled="isProjectTypeOptionDisabled(user, option)"/>
</t-checkbox-group>
</t-form-item>
<t-form-item v-if="isFieldVisible('weight')" label="体重(KG)" :name="`userList[${index}].weight`"
:rules="fieldRules('weight', '请输入体重拔河比赛分组需要')">
<t-input placeholder="拔河比赛分组需要" type="number" borderless v-model="user.weight"></t-input>
</t-form-item>
<t-form-item label="单位名称">
<t-form-item v-if="isFieldVisible('unitName')" label="单位名称"
:name="`userList[${index}].unitName`"
:rules="fieldRules('unitName', '请输入单位名称')">
<t-input v-model="user.unitName" borderless placeholder="请输入单位名称" readonly></t-input>
</t-form-item>
<t-form-item label="职务" :name="`userList[${index}].position`"
requiredMark
:rules="[{required:true,message: '请输入职务'}]">
<t-form-item v-if="isFieldVisible('unitName')" label="单位简称">
<t-input :model-value="unitAliasName" borderless readonly></t-input>
</t-form-item>
<t-form-item v-if="isFieldVisible('position')" label="职务" :name="`userList[${index}].position`"
:rules="fieldRules('position', '请输入职务')">
<t-input v-model="user.position" borderless placeholder="请输入职务"></t-input>
</t-form-item>
<!-- <t-form-item label="身份证号" :name="`userList[${index}].idCard`"-->
<!-- :rules="[{required:true,message: '请输入身份证号码'},{idcard:true, message: '请输入正确的身份证号码'}]">-->
<!-- <t-input v-model="user.idCard" borderless placeholder="请输入身份证号码"></t-input>-->
<!-- </t-form-item>-->
<t-form-item label="联系电话" :name="`userList[${index}].phone`"
:rules="[{required:true,message: '请输入联系电话'},{telnumber:true, message: '请输入正确的联系电话'}]">
<t-form-item v-if="isFieldVisible('phone')" label="联系电话" :name="`userList[${index}].phone`"
:rules="fieldRules('phone', '请输入联系电话', [{telnumber:true, message: '请输入正确的联系电话'}])">
<t-input v-model="user.phone" borderless placeholder="请输入联系电话"></t-input>
</t-form-item>
<t-form-item label="是否住宿" :name="`userList[${index}].isStay`"
:rules="[{required:true,message: '请选择是否住宿'}]">
<t-form-item v-if="isFieldVisible('birthday')" label="出生日期"
:name="'userList[' + index + '].birthday'"
:rules="fieldRules('birthday', '请选择出生日期')">
<t-input v-model="user.birthday" type="date" borderless placeholder="请选择出生日期"></t-input>
</t-form-item>
<t-form-item v-if="isFieldVisible('isStay')" label="是否住宿" :name="`userList[${index}].isStay`"
:rules="fieldRules('isStay', '请选择是否住宿')">
<t-radio-group v-model="user.isStay" class="radio-box">
<t-radio :block="false" name="radio" :value="true" label="是"/>
<t-radio :block="false" name="radio" :value="false" label="否"/>
</t-radio-group>
</t-form-item>
<t-form-item label="是否联络员" :name="`userList[${index}].isContacts`"
:rules="[{required:true,message: '请选择是否为联络员'}]">
<t-form-item v-if="isFieldVisible('isContacts')" label="是否联络员" :name="`userList[${index}].isContacts`"
:rules="fieldRules('isContacts', '请选择是否为联络员')">
<t-radio-group v-model="user.isContacts" class="radio-box">
<t-radio :block="false" name="radio" :value="true" label="是"/>
<t-radio :block="false" name="radio" :value="false" label="否"/>
</t-radio-group>
</t-form-item>
<t-form-item label="交通方式" :name="`userList[${index}].traffic`"
:rules="[{required:true,message: '请选择交通方式'}]">
<t-form-item v-if="isFieldVisible('traffic')" label="交通方式" :name="`userList[${index}].traffic`"
:rules="fieldRules('traffic', '请选择交通方式')">
<t-radio-group v-model="user.traffic" class="radio-box">
<t-radio :block="false" name="radio" :value="'自驾'" label="自驾"/>
<t-radio :block="false" name="radio" :value="'公共交通'" label="公共交通"/>
</t-radio-group>
</t-form-item>
<t-form-item v-if="user.traffic === '自驾'" label="车牌号" :name="`userList[${index}].carNo`"
:rules="[{required:true,message: '请输入车牌号'}]">
<t-form-item v-if="isFieldVisible('carNo') && user.traffic === '自驾'" label="车牌号"
:name="`userList[${index}].carNo`" :rules="fieldRules('carNo', '请输入车牌号')">
<t-input v-model="user.carNo" borderless placeholder="请输入车牌号"></t-input>
</t-form-item>
<t-form-item v-if="user.traffic === '公共交通'" label="是否接站" :name="`userList[${index}].isStation`"
:rules="[{required:true,message: '请选择是否接站'}]">
<t-form-item v-if="isFieldVisible('isStation') && user.traffic === '公共交通'" label="是否接站"
:name="`userList[${index}].isStation`" :rules="fieldRules('isStation', '请选择是否接站')">
<t-radio-group v-model="user.isStation" class="radio-box">
<t-radio :block="false" name="radio" :value="true" label="是"/>
<t-radio :block="false" name="radio" :value="false" label="否"/>
</t-radio-group>
</t-form-item>
<t-form-item v-if="user.isStation" label="接站地址" :name="`userList[${index}].stationAddress`"
:rules="[{required:true,message: '请输入接站地址'}]">
<t-form-item v-if="isFieldVisible('stationAddress') && user.traffic === '公共交通' && user.isStation"
label="接站地址" :name="`userList[${index}].stationAddress`"
:rules="fieldRules('stationAddress', '请输入接站地址')">
<t-input v-model="user.stationAddress" borderless placeholder="请输入接站地址" maxlength="50"></t-input>
</t-form-item>
<!-- <t-form-item label="展示才艺" :name="`userList[${index}].isShowSkill`" :rules="[{required:true,message: '请选择是否展示才艺'}]">-->
<!-- <t-radio-group v-model="user.isShowSkill" class="radio-box">-->
<!-- <t-radio :block="false" name="radio" :value="true" label="是"/>-->
<!-- <t-radio :block="false" name="radio" :value="false" label="否"/>-->
<!-- </t-radio-group>-->
<!-- </t-form-item>-->
<!-- <template v-if="formData.userList[index]?.isShowSkill">-->
<!-- <t-form-item label="才艺名称" :name="`userList[${index}].skillName`" :rules="[{required:user.isShowSkill===true,message: '请填写才艺名称'}]">-->
<!-- <t-input v-model="user.skillName" borderless placeholder="请输入才艺名称"></t-input>-->
<!-- </t-form-item>-->
<!-- <t-form-item label="所需器材" :name="`userList[${index}].equip`" :rules="[{required:user.isShowSkill===true,message: '请填写才艺所需器材'}]">-->
<!-- <t-input v-model="user.equip" borderless placeholder="请填写才艺所需器材"></t-input>-->
<!-- </t-form-item>-->
<!-- </template>-->
<t-form-item label="参加茶叙会" :name="`userList[${index}].isParty`"
:rules="[{required:true,message: '请选择是否参加茶叙会'}]">
<t-form-item v-if="isFieldVisible('isShowSkill')" label="展示才艺"
:name="`userList[${index}].isShowSkill`"
:rules="fieldRules('isShowSkill', '请选择是否展示才艺')">
<t-radio-group v-model="user.isShowSkill" class="radio-box">
<t-radio :block="false" name="radio" :value="true" label="是"/>
<t-radio :block="false" name="radio" :value="false" label="否"/>
</t-radio-group>
</t-form-item>
<t-form-item v-if="isFieldVisible('skillName') && user.isShowSkill" label="才艺名称"
:name="`userList[${index}].skillName`" :rules="fieldRules('skillName', '请填写才艺名称')">
<t-input v-model="user.skillName" borderless placeholder="请输入才艺名称"></t-input>
</t-form-item>
<t-form-item v-if="isFieldVisible('equip') && user.isShowSkill" label="所需器材"
:name="`userList[${index}].equip`" :rules="fieldRules('equip', '请填写才艺所需器材')">
<t-input v-model="user.equip" borderless placeholder="请填写才艺所需器材"></t-input>
</t-form-item>
<t-form-item v-if="isFieldVisible('isParty')" label="参加茶叙会" :name="`userList[${index}].isParty`"
:rules="fieldRules('isParty', '请选择是否参加茶叙会')">
<t-radio-group v-model="user.isParty" class="radio-box">
<t-radio :block="false" name="radio" :value="true" label="是"/>
<t-radio :block="false" name="radio" :value="false" label="否"/>
@@ -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;
@@ -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'});
+47 -2
View File
@@ -4,6 +4,9 @@
<image class="header-bg"
src="/static/img/index4.png"
mode="aspectFill"></image>
<view v-if="currentActivityTitle" class="header-activity-title">
{{ displayActivityTitle }}
</view>
</view>
<view class="menu-container">
@@ -55,7 +58,7 @@
<t-swiper-item v-for="(item, index) in actList" :key="index" style="height: 192px">
<view class="activity-list" @click="navigateTo('/pages/activity/detail/index?id=' + item.id)">
<view class="activity-item">
<t-image :src=config.fileDomain+item.cover shape="round"
<t-image :src="getActivityCoverUrl(item.cover)" shape="round"
class="activity-image" fit="fill"/>
<view class="activity-info">
<view class="activity-title">{{ item.title }}</view>
@@ -105,7 +108,7 @@
</template>
<script setup>
import {onMounted, ref} from "vue";
import {computed, onMounted, ref} from "vue";
import {onLoad, onShow} from "@dcloudio/uni-app";
import {Toast} from "tdesign-mobile-vue";
import {useRequest} from "@/utils/request";
@@ -116,6 +119,20 @@ const request = useRequest()
const actList = ref([])
const wonderfulMomentList = ref([])
// 首页头图展示当前最新开启活动的标题,数据口径与登录页保持一致。
const currentActivityTitle = ref('')
// 当前活动标题在“(公司)”后分行展示,其他标题仍保留原文并按容器宽度自然换行。
const displayActivityTitle = computed(() => {
return currentActivityTitle.value.replace('(公司)', '(公司)\n')
})
// 后台仅保存封面相对路径,首页通过文件预览接口读取活动管理中上传的实际图片。
const getActivityCoverUrl = (cover) => {
if (!cover) {
return ''
}
return '/api/open/file/upload/download?inline=true&filepath=' + encodeURIComponent(cover)
}
function navigateTo(url) {
uni.navigateTo({
@@ -133,6 +150,17 @@ function getActivity() {
})
}
// 公开接口返回当前最新开启活动信息;请求失败时不显示标题,也不阻断首页其他内容。
function getLatestActivityTitle() {
request.get('/open/common/latestActivityOrganizer').then(({code, data}) => {
if (code === 0 && data) {
currentActivityTitle.value = data.title || ''
}
}).catch((error) => {
console.error('获取当前活动标题失败', error)
})
}
function getWonderfulMoment() {
request.post('/platform/activity/wonderfulMoment/getWonderfulMoment').then(res=>{
if(res.code === 0){
@@ -154,6 +182,7 @@ function handleWonderfulMoment(row) {
}
onShow(() => {
getLatestActivityTitle()
getActivity()
getWonderfulMoment()
})
@@ -178,6 +207,22 @@ uni-page-body {
object-fit: cover;
}
.header-activity-title {
position: absolute;
top: 61%;
left: 50%;
z-index: 1;
width: 62%;
transform: translate(-50%, -50%);
color: #fff;
font-size: 16px;
font-weight: 600;
line-height: 1.5;
text-align: center;
white-space: pre-line;
text-shadow: 0 1px 3px rgba(122, 24, 24, 0.45);
}
// 菜单区域样式
.menu-container {
display: flex;
+67 -48
View File
@@ -52,7 +52,7 @@
</div>
<view class="title-content">
<text class="main-title">第二届"钟声杯"直属工会趣味运动会</text>
<text class="main-title">{{ activityOrganizer.title || '赛事管理' }}</text>
<view class="sub-title-container">
<text class="sub-title">欢迎使用</text>
</view>
@@ -89,7 +89,8 @@
/>
</view>
<!-- 验证码输入框 -->
<!-- 测试阶段临时关闭登录验证码输入和发送入口;恢复正式登录时需取消本段注释。 -->
<!--
<view class="input-box" v-if="loginForm.loginMethod==='sms'">
<t-icon name="shield-error" size="20px" class="input-icon"/>
<input
@@ -103,9 +104,10 @@
{{ countDownText }}
</view>
</view>
-->
<!-- 密码输入框 -->
<view class="input-box" v-else>
<view class="input-box" v-if="loginForm.loginMethod==='password'">
<t-icon name="user-password" size="20px" class="input-icon"/>
<input
class="input"
@@ -131,8 +133,8 @@
<!-- 底部信息 -->
<view class="footer-info">
<view class="footer-item">主办单位:浙江省教育工会</view>
<view class="footer-item">承办单位:杭州医学院</view>
<view class="footer-item">主办单位:{{ activityOrganizer.hostUnit || '--' }}</view>
<view class="footer-item">承办单位:{{ activityOrganizer.coHostUnit || '--' }}</view>
<view class="footer-item">技术支持:武汉地大三思科技有限公司(智慧工会提供商)</view>
</view>
</view>
@@ -155,6 +157,27 @@ const loginForm = reactive({
loginMethod: 'sms'
})
// 当前最新开启活动的主办、承办单位;没有可用活动时页面显示“--”。
const activityOrganizer = reactive({
title: '',
hostUnit: '',
coHostUnit: ''
})
// 登录页处于未登录状态,因此通过公开接口获取当前最新开启活动的单位信息。
const getLatestActivityOrganizer = () => {
request.get('/open/common/latestActivityOrganizer').then(({code, data}) => {
if (code === 0 && data) {
activityOrganizer.title = data.title || ''
activityOrganizer.hostUnit = data.hostUnit || ''
activityOrganizer.coHostUnit = data.coHostUnit || ''
}
}).catch((error) => {
// 单位信息属于辅助展示,请求失败时保留占位内容,不阻断登录流程。
console.error('获取当前活动单位信息失败', error)
})
}
// 倒计时相关
const countdown = ref(0)
const timer = ref(null)
@@ -237,7 +260,9 @@ const startCountDown = () => {
// 在页面挂载时恢复倒计时状态
onMounted(() => {
restoreCountdownState()
// 测试阶段不启用登录验证码,因此不再恢复验证码倒计时状态。
// restoreCountdownState()
getLatestActivityOrganizer()
})
// 在页面卸载前清除定时器
@@ -248,82 +273,76 @@ onBeforeUnmount(() => {
}
})
// 获取验证码
async function getSmsCode() {
// 获取登录短信验证码:手机号通过路径参数传递,接口返回 code 和 msg 表示发送结果。
/* 测试阶段临时关闭登录验证码发送;恢复正式登录时需同时恢复验证码输入和后端校验。
const getSmsCode = () => {
if (!loginForm.phone) {
Toast('请输入手机号码');
return
}
// 验证手机号码
if (!/^1[3-9]\d{9}$/.test(loginForm.phone)) {
Toast('请输入正确的手机号码');
return
}
if (isCountingDown.value) return
try {
const {code, data, msg} = await request.post('/client/auth/send-sms-code/' + loginForm.phone)
request.post('/client/auth/send-sms-code/' + loginForm.phone).then(({code, msg}) => {
if (code === 0) {
Toast({message: '验证码发送成功', theme: 'success'})
// 开始倒计时
startCountDown()
} else {
Toast({message: msg, theme: 'error'})
}
} catch (e) {
Toast({message: e.message, theme: 'error'})
}
}).catch((error) => {
Toast({message: error.message, theme: 'error'})
})
}
*/
async function handleLogin() {
const handleLogin = () => {
if (!loginForm.phone) {
Toast('请输入手机号码');
return
}
if (!loginForm.smsCode && loginForm.loginMethod === 'sms') {
Toast('请输入验证码');
return
}
// 测试阶段允许已注册手机号直接登录,暂不校验验证码是否填写。
// if (!loginForm.smsCode && loginForm.loginMethod === 'sms') {
// Toast('请输入验证码');
// return
// }
if (!loginForm.password && loginForm.loginMethod === 'password') {
Toast('请输入密码');
return
}
try {
userStore.smsLogin(loginForm).then(() => {
userStore.getUserInfo().then(() => {
Toast({message: '登录成功', theme: 'success'})
const redirect = uni.getStorageSync("REDIRECT_URL")
if(redirect){
if(redirect.startsWith('/pages/index/index') || redirect.startsWith('/pages/activity/rank/index') || redirect.startsWith('/pages/profile/index')){
uni.switchTab({
url: redirect
})
}else{
uni.navigateTo({
url: redirect
})
}
uni.removeStorageSync("REDIRECT_URL")
}else{
userStore.smsLogin(loginForm).then(() => {
userStore.getUserInfo().then(() => {
Toast({message: '登录成功', theme: 'success'})
const redirect = uni.getStorageSync("REDIRECT_URL")
if(redirect){
if(redirect.startsWith('/pages/index/index') || redirect.startsWith('/pages/activity/rank/index') || redirect.startsWith('/pages/profile/index')){
uni.switchTab({
url: '/pages/index/index'
url: redirect
})
}else{
uni.navigateTo({
url: redirect
})
}
}).catch(e => {
console.log(e)
Toast({message: e.message, theme: 'error'})
})
}).catch(e => {
uni.removeStorageSync("REDIRECT_URL")
}else{
uni.switchTab({
url: '/pages/index/index'
})
}
}).catch((e) => {
console.log(e)
Toast({message: e.message, theme: 'error'})
})
} catch (e) {
console.error(e)
}).catch((e) => {
console.log(e)
Toast({message: e.message, theme: 'error'})
}
})
}
function navigateToRegister() {
@@ -384,7 +403,7 @@ body {
}
.main-title {
font-size: 36px;
font-size: 34px;
font-weight: bold;
margin-bottom: 8px;
display: block;
+68 -10
View File
@@ -53,7 +53,7 @@
</div>
<view class="title-content">
<text class="main-title">第二届"钟声杯"直属工会趣味运动会</text>
<text class="main-title">{{ currentActivityTitle || '赛事管理' }}</text>
<view class="sub-title-container">
<text class="sub-title">用户注册</text>
</view>
@@ -95,6 +95,19 @@
<t-icon name="chevron-right" size="20px" class="arrow-icon"/>
</view>
<!-- 单位简称首次登记后作为单位公共信息使用,已有简称由后台回填且注册时不会被覆盖。 -->
<view class="input-box">
<t-icon name="edit" size="20px" class="input-icon"/>
<input
class="input"
type="text"
v-model="formData.unitAliasName"
placeholder="请输入单位简称(如:南邮工会)"
maxlength="100"
:disabled="unitAliasLocked"
/>
</view>
<!-- &lt;!&ndash; 身份证输入框 &ndash;&gt;-->
<!-- <view class="input-box">-->
<!-- <t-icon name="creditcard" size="20px" class="input-icon"/>-->
@@ -116,8 +129,8 @@
</t-radio-group>
</view>
<!-- 出生年月输入框 -->
<view class="input-box">
<!-- 当前注册流程暂不采集出生日期,保留原选择控件便于后续恢复。 -->
<view v-if="false" class="input-box">
<t-icon name="creditcard" size="20px" class="input-icon"/>
<input
class="input"
@@ -155,6 +168,12 @@
</view>
</view>
<!-- 短信可能被系统拦截或受双卡设置影响,提前提示用户检查手机接收状态。 -->
<view class="sms-reminder">
<text class="sms-reminder-title">温馨提醒:</text>
<text>如果您长时间没有收到短信,请检查手机是否当做骚扰短信被拦截,或您是否有两个手机卡号,可能需要切换主副卡</text>
</view>
<!-- 注册按钮 -->
<view class="submit-btn" @click="handleRegister">
{{ registering ? '注册中...' : '立即注册' }}
@@ -242,14 +261,29 @@ import {useUserStore} from "@/store";
const userStore = useUserStore();
// 注册页与登录页使用同一口径展示当前最新开启活动的标题。
const currentActivityTitle = ref('');
const getLatestActivityTitle = () => {
useRequest().get('/open/common/latestActivityOrganizer').then(({code, data}) => {
if (code === 0 && data) {
currentActivityTitle.value = data.title || '';
}
}).catch((error) => {
// 标题属于辅助展示,请求失败时使用“赛事管理”占位,不阻断注册流程。
console.error('获取当前活动标题失败', error);
});
};
// 表单数据
const formData = reactive({
userName: '',
phone: '',
unitId: '',
unitName: '',
unitAliasName: '',
// idCard: '',
birthday: '',
birthday: null,
smsCode: ''
});
@@ -264,6 +298,7 @@ const unitList = ref([]);
const searchKeyword = ref('');
const selectedUnitId = ref('');
const scrollViewId = ref('');
const unitAliasLocked = ref(false);
// 筛选单位列表
const filteredUnits = computed(() => {
@@ -341,6 +376,8 @@ const confirmUnitSelection = () => {
if (selectedUnit) {
formData.unitId = selectedUnit.id;
formData.unitName = selectedUnit.name;
formData.unitAliasName = selectedUnit.aliasName || '';
unitAliasLocked.value = Boolean(selectedUnit.aliasName);
}
showUnitPicker.value = false;
};
@@ -371,7 +408,7 @@ const sendVerificationCode = async () => {
sendingCode.value = true;
try {
const {code, msg} = await useRequest().post('/client/auth//send-reg-code/' + formData.phone);
const {code, msg} = await useRequest().post('/client/auth/send-reg-code/' + formData.phone);
if (code === 0) {
Toast({message: '验证码已发送'});
@@ -416,14 +453,17 @@ const handleRegister = async () => {
// Toast('请输入身份证号');
// return
// }
if (!formData.birthday) {
Toast('请选择出生日期');
return
}
// 当前注册流程暂不要求出生日期,恢复字段显示时再同步启用必填校验。
if (!formData.unitId) {
Toast('请选择单位');
return
}
const unitAliasName = formData.unitAliasName.trim();
if (!unitAliasName) {
Toast('请输入单位简称');
return
}
formData.unitAliasName = unitAliasName;
// if (!formData.idCard.match(/^[1-9]\d{5}(18|19|([23]\d))\d{2}((0[1-9])|(10|11|12))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/)) {
// Toast('请输入正确的身份证号');
// return
@@ -474,6 +514,7 @@ const goToLogin = () => {
// 页面加载时获取单位列表
onMounted(() => {
getLatestActivityTitle();
getUnitList();
});
</script>
@@ -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;
+9 -1
View File
@@ -41,7 +41,7 @@
</view>
</view>
<view style="display: flex;">
<image :src="item.cover" mode="aspectFill"
<image :src="getActivityCoverUrl(item.cover)" mode="aspectFill"
style="flex-shrink:0;width: 120px;height: 90px;border-radius: 5px;"></image>
<view
style="margin-left:10px;flex:1;overflow:hidden;display: flex;flex-direction: column;justify-content:space-around;color: #534d4d;">
@@ -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;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 127 KiB

+3 -2
View File
@@ -1,3 +1,4 @@
export const config = {
fileDomain: 'https://3skj.ltd',
}
// 后台图片字段保存相对路径,统一通过当前环境的 API 代理读取,避免绑定固定部署域名。
fileDomain: '/api/open/file/upload/download?inline=true&filepath=',
}
+100
View File
@@ -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;
};
+2 -2
View File
@@ -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/, '')
}