Merge remote-tracking branch 'origin/master'

This commit is contained in:
2026-10-20 11:57:08 +08:00
70 changed files with 2956 additions and 543 deletions
@@ -48,6 +48,13 @@ public class RedisConstant {
*/
public static final String UCENTER_SMSCODE = PLATFORM_REDIS_PREFIX + "ucenter:smscode:";
/** 拼图挑战与凭证按匿名会话隔离,频率键单独存放。 */
public static final String REDIS_SLIDER_CAPTCHA_KEY = PLATFORM_REDIS_PREFIX + "login:slider:";
public static final String REDIS_SLIDER_RATE_KEY = PLATFORM_REDIS_PREFIX + "login:slider-rate:";
/** 登录短信专用缓存前缀,避免验证码与其他业务场景交叉使用。 */
public static final String LOGIN_SMSCODE = PLATFORM_REDIS_PREFIX + "login:sms:";
/**
* 接口重复提交前缀
*/
@@ -13,6 +13,7 @@ public enum LoginType {
QI_YE_WECHAT("企业微信"),
WECHAT("微信"),
PC_LOCAL("PC本地"),
SMS_CODE("手机验证码"),
MOBILE_LOCAL("手机本地"),
CAS("CAS");
@@ -7,10 +7,12 @@ import com.budwk.app.base.result.Result;
import com.budwk.app.sys.models.Sys_config;
import com.budwk.app.sys.services.SysConfigService;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
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.integration.jedis.pubsub.PubSubService;
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;
@@ -139,12 +141,15 @@ public class SysConfController {
@At
@Ok("json:full")
@SaCheckPermission("sys.manager.conf")
@Aop(TransAop.READ_COMMITTED)
public Object data(@Param("pageNumber") int pageNumber,
@Param("pageSize") int pageSize,
@Param("pageOrderName") String pageOrderName,
@Param("pageOrderBy") String pageOrderBy,
@Param("configKey") String configKey) {
try {
// 由 service 补充默认关闭的入口参数,管理员可通过现有“是/否”控件修改。
sysConfigService.ensureSmsLoginVisibleConfig(SecurityUtil.getUserId());
ensureAppImageConfig("AppHomeImg", "PC首页轮播图");
ensureAppImageConfig("H5AppHomeImg", "移动端首页轮播图");
ensureAppImageConfig("AppFeaturedActivityImg", "精彩活动页顶部图片");
@@ -16,16 +16,20 @@ import com.budwk.app.base.utils.RsaUtils;
import com.budwk.app.sys.models.Sys_log;
import com.budwk.app.sys.models.Sys_user;
import com.budwk.app.sys.services.SysUserService;
import com.budwk.app.sys.services.SysConfigService;
import com.budwk.app.web.commons.auth.service.ValidateService;
import com.budwk.app.zhgh.dayofficework.message.service.SliderCaptchaService;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.web.commons.base.Globals;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.jasig.cas.client.util.AbstractCasFilter;
import org.jasig.cas.client.validation.Assertion;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.integration.jedis.RedisService;
import org.nutz.ioc.impl.PropertiesProxy;
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;
@@ -35,6 +39,7 @@ import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Filters;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import org.nutz.mvc.annotation.POST;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@@ -55,19 +60,19 @@ import java.util.concurrent.ConcurrentHashMap;
public class SysLoginController {
private static final Log log = Logs.get();
private static final LoginRsaKeyRing LOGIN_RSA_KEY_RING = new LoginRsaKeyRing();
/**
* TODO: 临时屏蔽回环地址及指定局域网 IP 的平台登录验证码,全部恢复时改为 false。
*/
private static final boolean DISABLE_LOGIN_CAPTCHA = true;
@Inject
private SysUserService sysUserService;
@Inject
private SysConfigService sysConfigService;
@Inject
private RedisService redisService;
@Inject
private SLogService sLogService;
@Inject
private ValidateService validateService;
@Inject
private SliderCaptchaService sliderCaptchaService;
@Inject
protected PropertiesProxy conf;
@At("")
@@ -75,13 +80,17 @@ public class SysLoginController {
@ApiOperation("用户本地登录页面")
@Filters
public String login(HttpServletRequest req, HttpSession session) {
// 只向模板提供入口显示状态,短信发送及验证码登录接口继续使用原有规则。
req.setAttribute("smsLoginVisible", sysConfigService.isSmsLoginVisible());
return "beetl:/platform/sys/login.html";
}
@At
@Ok("re")
@Filters
public String h5() {
public String h5(HttpServletRequest req) {
// 手机端复用同一个登录模板,读取相同的系统参数。
req.setAttribute("smsLoginVisible", sysConfigService.isSmsLoginVisible());
return "beetl:/platform/sys/login.html";
}
@@ -93,6 +102,13 @@ public class SysLoginController {
}
/**
* 账号密码登录:username 为账号,password 为 RSA 密文,keyId 为公钥编号;
* platformKey/platformCaptcha 为当前会话、当前账号的滑块一次性凭证。
* 返回 Result:code=0 表示登录成功,msg 为成功提示或失败原因。
*/
@POST
@Aop(TransAop.READ_COMMITTED)
@At("/doLogin")
@Ok("json")
@ApiOperation("用户本地账号密码登录")
@@ -124,14 +140,9 @@ public class SysLoginController {
// return Result.error("登录失败次数过多,请5分钟后再试");
// }
try {
// 验证码校验
if (!isLoginCaptchaDisabled(req)) {
try {
validateService.checkCode(captchaKey, captchaCode);
} catch (BaseException e) {
return Result.error(e.getMessage());
}
}
// 所有访问地址均需消费绑定当前账号的滑块凭证,旧图形码不能用于登录。
sliderCaptchaService.consume(session.getId(), captchaKey, captchaCode,
SliderCaptchaService.PASSWORD, username);
// 解密密码
LoginRsaKey rsaKey = LOGIN_RSA_KEY_RING.getPrivateKey(keyId);
@@ -162,19 +173,6 @@ public class SysLoginController {
}
}
private boolean isLoginCaptchaDisabled(HttpServletRequest req) {
if (!DISABLE_LOGIN_CAPTCHA || req == null) {
return false;
}
String serverName = req.getServerName();
// 临时允许该局域网地址免验证码;恢复 IP 验证码时删除此条件,并同步删除登录页中的该 IP。
return "localhost".equalsIgnoreCase(serverName)
|| "192.168.0.108".equals(serverName)
|| "127.0.0.1".equals(serverName)
|| "0:0:0:0:0:0:0:1".equals(serverName)
|| "::1".equals(serverName);
}
@At(value = "/platform/sso/login", top = true)
@Ok("re")
@ApiOperation("用户cas登录统一入口")
@@ -277,6 +275,120 @@ public class SysLoginController {
}
}
/**
* 发送系统绑定手机号的登录验证码,滑块凭证和账号可用性由 service 再次校验。
*
* @param mobile 已绑定系统账号的 11 位大陆手机号
* @param captchaKey /checkSliderCaptcha 返回的 platformKey
* @param captchaCode /checkSliderCaptcha 返回的一次性 platformCaptcha
* @param request 当前请求,供后端按连接来源限流
* @return Result,成功 data 包含 retryAfter 重发等待秒数、expireIn 验证码有效秒数;失败 msg 为原因
*/
@At("/mobileCaptcha")
@POST
@Ok("json")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("发送手机登录验证码")
public Object mobileCaptcha(@Param("mobile") String mobile,
@Param("platformKey") String captchaKey,
@Param("platformCaptcha") String captchaCode,
HttpServletRequest request) {
if (StrUtil.isBlank(mobile) || !mobile.trim().matches("^1[3-9]\\d{9}$")) {
return Result.error("请输入正确的手机号码");
}
if (StrUtil.isBlank(captchaKey) || StrUtil.isBlank(captchaCode)) {
return Result.error("请先完成滑块验证");
}
try {
return Result.success("短信验证码已发送", sysUserService.sendLoginSmsCode(mobile.trim(), captchaKey, captchaCode, request));
} catch (BaseException e) {
return Result.error(e.getMessage());
} catch (Exception e) {
log.error("发送登录短信验证码失败", e);
return Result.error("短信发送失败,请稍后重试或联系管理员");
}
}
/**
* 使用与手机号绑定的一次性短信验证码登录,不接收密码或 RSA 公钥参数。
*
* @param mobile 获取短信时填写的 11 位大陆手机号
* @param smsCode 用户收到的六位数字验证码
* @param request 当前登录请求,供 service 创建原有 Sa-Token 会话和登录日志
* @return Result,code 为 0 表示完成登录;失败 msg 为校验或登录失败原因
*/
@At("/doSmsLogin")
@POST
@Ok("json")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("手机验证码登录")
public Object doSmsLogin(@Param("mobile") String mobile, @Param("smsCode") String smsCode,
HttpServletRequest request) {
if (StrUtil.isBlank(mobile) || !mobile.trim().matches("^1[3-9]\\d{9}$")) {
return Result.error("请输入正确的手机号码");
}
if (StrUtil.isBlank(smsCode) || !smsCode.trim().matches("^\\d{6}$")) {
return Result.error("请输入六位短信验证码");
}
try {
sysUserService.loginBySmsCode(mobile.trim(), smsCode.trim(), request);
return Result.success("login.success");
} catch (BaseException e) {
return Result.error(e.getMessage());
} catch (Exception e) {
log.error("手机验证码登录失败", e);
return Result.error("登录失败,请稍后重试或联系管理员");
}
}
/**
* scene 传 password(账号登录)或 sms(发送短信),subject 传对应账号或手机号。
* 返回 Result.data:challengeId、background/piece 图片、width/height/pieceSize/pieceY 像素信息及 expiresIn 秒数。
* 会话 ID 仅从服务器读取,业务校验、图片生成和频控均由 service 完成。
*/
@At("/sliderCaptcha")
@POST
@Ok("json")
@Aop(TransAop.READ_COMMITTED)
public Object sliderCaptcha(@Param("scene") String scene, @Param("subject") String subject,
HttpServletRequest request, HttpServletResponse response) {
response.setHeader("Cache-Control", "no-store");
try {
return Result.success(sliderCaptchaService.create(request.getSession().getId(),
request.getRemoteAddr(), scene, subject));
} catch (BaseException e) {
return Result.error(e.getMessage());
} catch (Exception e) {
log.error("生成滑块验证码失败", e);
return Result.error("验证码加载失败,请稍后重试");
}
}
/**
* challengeId 传挑战编号,offset 传拼图左侧在原始 320px 图片中的横坐标。
* 返回 Result.data:platformKey、platformCaptcha 为一次性凭证,expiresIn 为有效秒数。
*/
@At("/checkSliderCaptcha")
@POST
@Ok("json")
@Aop(TransAop.READ_COMMITTED)
public Object checkSliderCaptcha(@Param("challengeId") String challengeId, @Param("offset") String offset,
HttpServletRequest request, HttpServletResponse response) {
response.setHeader("Cache-Control", "no-store");
if (offset == null || !offset.matches("[0-9]{1,3}(\\.[0-9]{1,3})?")) {
return Result.error("滑块位置不正确,请重新验证");
}
try {
return Result.success(sliderCaptchaService.verify(request.getSession().getId(),
challengeId, Double.parseDouble(offset)));
} catch (BaseException e) {
return Result.error(e.getMessage());
} catch (Exception e) {
log.error("校验滑块验证码失败", e);
return Result.error("验证失败,请稍后重试");
}
}
@At("/captcha")
@Ok("json")
@ApiOperation("获取验证码")
@@ -10,6 +10,23 @@ import java.util.List;
* Created by wizzer on 2016/12/23.
*/
public interface SysConfigService extends BaseService<Sys_config> {
/** 仅控制登录页面短信入口的可见性,不作为后端短信接口的访问开关。 */
String SMS_LOGIN_VISIBLE_KEY = "AppSmsLoginEnabled";
/**
* 补充缺失的短信登录入口参数,初始值为 false,已有管理员配置保持不变。
*
* @param createdBy 当前打开系统参数列表的管理员 ID,用于记录参数创建人
*/
void ensureSmsLoginVisibleConfig(String createdBy);
/**
* 读取短信登录入口显示配置,不依赖异步更新的全局缓存。
*
* @return 仅参数值为 true 时返回 true;参数缺失、为空或非法时返回 false
*/
boolean isSmsLoginVisible();
/**
* 查询所有数据
* @return
@@ -8,6 +8,7 @@ import com.budwk.app.base.service.BaseService;
import com.budwk.app.sys.models.Sys_menu;
import com.budwk.app.sys.models.Sys_role;
import com.budwk.app.sys.models.Sys_user;
import org.nutz.lang.util.NutMap;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
@@ -155,14 +156,37 @@ public interface SysUserService extends BaseService<Sys_user> {
Sys_user loginByPassword(String loginname, String passowrd) throws BaseException;
/**
* 通过短信验证码登录
* 根据手机号获取唯一可登录用户,并加载所属单位及分工会。
*
* @param mobile 手机号
* @return
* @throws BaseException
* @param mobile 已在系统人员资料中登记的 11 位大陆手机号,允许首尾空格
* @return Sys_user,包含用户基本资料、unit 单位及 union 分工会关联
* @throws BaseException 手机号格式不正确、未绑定用户、关联多条用户或账号已禁用
*/
Sys_user loginByMobile(String mobile) throws BaseException;
/**
* 为已登记且唯一有效的用户发送登录短信验证码。
*
* @param mobile 系统人员资料中登记的 11 位大陆手机号
* @param captchaKey 滑块验证接口返回的 platformKey
* @param captchaCode 滑块验证接口返回的一次性 platformCaptcha 凭证
* @param request 当前 HTTP 请求,用于取得服务端接收到的客户端 IP 并限制发送频率
* @return NutMap,retryAfter 为再次发送前应等待的秒数,expireIn 为短信验证码有效秒数
* @throws BaseException 用户不可登录、滑块凭证错误、请求过于频繁或短信发送失败
*/
NutMap sendLoginSmsCode(String mobile, String captchaKey, String captchaCode, HttpServletRequest request);
/**
* 校验登录短信验证码并建立与账号密码登录一致的 Sa-Token 会话。
*
* @param mobile 系统人员资料中登记的 11 位大陆手机号
* @param smsCode 发送到该手机号的登录短信验证码
* @param request 当前 HTTP 请求,供既有登录流程记录 IP 及取得跳转参数
* @return String,登录成功后应访问的项目页面地址
* @throws BaseException 用户不可登录、关联资料缺失或短信验证码校验失败
*/
String loginBySmsCode(String mobile, String smsCode, HttpServletRequest request);
/**
* 通过用户名获取用户信息
* @param loginname
@@ -6,8 +6,11 @@ import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.base.utils.PageUtil;
import com.budwk.app.sys.models.Sys_config;
import com.budwk.app.sys.services.SysConfigService;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.DaoException;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Lang;
import org.nutz.lang.Strings;
@@ -24,6 +27,35 @@ public class SysConfigServiceImpl extends BaseServiceImpl<Sys_config> implements
super(dao);
}
/** {@inheritDoc} */
@Override
@Aop(TransAop.READ_COMMITTED)
public synchronized void ensureSmsLoginVisibleConfig(String createdBy) {
if (fetch(SMS_LOGIN_VISIBLE_KEY) != null) {
return;
}
Sys_config config = new Sys_config();
config.setConfigKey(SMS_LOGIN_VISIBLE_KEY);
config.setConfigValue("false");
config.setNote("是否开启短信登录(仅控制登录页入口显示)");
config.setCreatedBy(createdBy);
try {
insert(config);
} catch (DaoException e) {
// 多实例同时首次打开列表时,另一个实例可能已插入;已有值不能被默认值覆盖。
if (fetch(SMS_LOGIN_VISIBLE_KEY) == null) {
throw e;
}
}
}
/** {@inheritDoc} */
@Override
public boolean isSmsLoginVisible() {
Sys_config config = fetch(SMS_LOGIN_VISIBLE_KEY);
return config != null && "true".equals(Strings.trim(config.getConfigValue()));
}
public List<Sys_config> getAllList() {
return this.query(Cnd.where("delFlag", "=", false));
}
@@ -19,6 +19,7 @@ import com.budwk.app.sys.services.SysMenuService;
import com.budwk.app.sys.services.SysRoleService;
import com.budwk.app.sys.services.SysUserService;
import com.budwk.app.sys.views.View_user;
import com.budwk.app.web.commons.auth.service.ValidateService;
import com.budwk.app.web.commons.base.Globals;
import com.budwk.app.zhgh.staffmanage.specialstaff.model.SpecialStaff;
import org.nutz.aop.interceptor.ioc.TransAop;
@@ -34,6 +35,7 @@ import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Lang;
import org.nutz.lang.Strings;
import org.nutz.lang.random.R;
import org.nutz.lang.util.NutMap;
import org.nutz.plugins.wkcache.annotation.CacheDefaults;
import org.nutz.plugins.wkcache.annotation.CacheRemove;
import org.nutz.plugins.wkcache.annotation.CacheRemoveAll;
@@ -63,6 +65,8 @@ public class SysUserServiceImpl extends BaseServiceImpl<Sys_user> implements Sys
private SLogService sLogService;
@Inject
private RedisService redisService;
@Inject
private ValidateService validateService;
@Override
@CacheResult(cacheKey = "${userId}_getPermissionList")
@@ -368,16 +372,90 @@ public class SysUserServiceImpl extends BaseServiceImpl<Sys_user> implements Sys
return user;
}
/**
* 按登记手机号确认唯一且未禁用的账号,补齐后续登录会话使用的组织关系。
*
* @param mobile 已登记的 11 位大陆手机号,查询前移除首尾空格
* @return Sys_user,包含 unit 单位和按单位关联的 union 分工会信息
* @throws BaseException 手机号无效、未登记、关联多条用户或账号已禁用
*/
@Override
public Sys_user loginByMobile(String mobile) throws BaseException {
Sys_user user = this.fetch(Cnd.where("mobile", "=", mobile));
if (user == null) {
throw new BaseException("用户不存在");
String normalizedMobile = Strings.sNull(mobile).trim();
if (!normalizedMobile.matches("^1[3-9]\\d{9}$")) {
throw new BaseException("请输入正确的手机号码");
}
// 手机号不是唯一字段;任何重复关联都拒绝登录,避免任取账号或绕过禁用状态。
List<Sys_user> users = this.query(Cnd.where("mobile", "=", normalizedMobile));
if (users.isEmpty()) {
throw new BaseException("该手机号未绑定系统账号,请联系管理员");
}
if (users.size() > 1) {
throw new BaseException("该手机号关联多个账号,请联系管理员");
}
Sys_user user = users.get(0);
if (user.isDisabled()) {
throw new BaseException("用户被禁用");
}
user = this.fetchLinks(user, "unit");
if (Lang.isNotEmpty(user.getUnit()) && StrUtil.isNotBlank(user.getUnit().getUnionId())) {
user.setUnion(dao().fetch(Sys_union.class, user.getUnit().getUnionId()));
}
return user;
}
/**
* 先确认手机号唯一且账号可用,再交由验证码服务校验滑块凭证、限流并实际发送短信。
*
* @param mobile 系统人员资料中登记的 11 位大陆手机号
* @param captchaKey 滑块验证接口返回的 platformKey
* @param captchaCode 滑块验证接口返回的一次性 platformCaptcha 凭证
* @param request 当前请求,以 getRemoteAddr 取得的地址作为服务端发送限流依据
* @return NutMap,retryAfter 为重发等待秒数,expireIn 为短信验证码有效秒数
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public NutMap sendLoginSmsCode(String mobile, String captchaKey, String captchaCode, HttpServletRequest request) {
if (request == null) {
throw new BaseException("登录请求无效");
}
Sys_user user = this.loginByMobile(mobile);
return validateService.sendLoginSmsCode(user.getMobile(), captchaKey, captchaCode, request.getRemoteAddr(), request.getSession().getId());
}
/**
* 使用短信验证码登录既有账号,验证成功后复用原有会话、日志和跳转处理。
*
* @param mobile 已登记且只关联一个账号的 11 位大陆手机号
* @param smsCode 登录短信中的验证码,成功校验后一次性消费
* @param request 当前请求,供既有登录流程记录客户端 IP 并读取跳转参数
* @return String,原有登录流程生成的登录成功跳转地址
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public String loginBySmsCode(String mobile, String smsCode, HttpServletRequest request) {
if (request == null) {
throw new BaseException("登录请求无效");
}
Sys_user user = this.loginByMobile(mobile);
// loginPlus 会读取视图中的工会小组等资料,必须在建立登录态之前确认关联资料存在。
if (dao().fetch(View_user.class, Cnd.where("id", "=", user.getId())) == null) {
throw new BaseException("账号关联资料不完整,请联系管理员");
}
validateService.checkLoginSmsCode(user.getMobile(), smsCode);
try {
return this.loginPlus(user, LoginType.SMS_CODE, request);
} catch (RuntimeException e) {
// 仅在进入创建登录态的流程后清理;手机号或验证码校验失败不会注销原有登录。
try {
StpUtil.logout();
} catch (RuntimeException cleanupException) {
e.addSuppressed(cleanupException);
}
throw e;
}
}
@Override
public Sys_user loginByLoginName(String loginname) {
Sys_user user = this.fetch(Cnd.where("loginname", "=", loginname));
@@ -446,6 +524,7 @@ public class SysUserServiceImpl extends BaseServiceImpl<Sys_user> implements Sys
}
@Override
@Aop(TransAop.READ_COMMITTED)
public String loginPlus(Sys_user user, LoginType loginType, HttpServletRequest request) {
String wxOpenId = request.getParameter("wxOpenId");
@@ -3,19 +3,30 @@ package com.budwk.app.web.commons.auth.service;
import com.budwk.app.base.constant.RedisConstant;
import com.budwk.app.base.exception.BaseException;
import com.budwk.app.base.utils.RsaUtil;
import com.budwk.app.zhgh.dayofficework.message.service.TencentSmsService;
import com.budwk.app.zhgh.dayofficework.message.service.SliderCaptchaService;
import com.wf.captcha.ArithmeticCaptcha;
import lombok.extern.slf4j.Slf4j;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.integration.jedis.JedisAgent;
import org.nutz.integration.jedis.RedisService;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.impl.PropertiesProxy;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Strings;
import org.nutz.lang.random.R;
import org.nutz.lang.util.NutMap;
import redis.clients.jedis.Jedis;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.SecureRandom;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.Arrays;
import java.util.Collections;
import java.util.Locale;
/**
* @author wizzer@qq.com
@@ -23,14 +34,45 @@ import java.util.Base64;
@IocBean
@Slf4j
public class ValidateService {
private static final int SMS_EXPIRE_SECONDS = 300;
// 同一手机号的重发等待时间与短信验证码有效期一致,发送成功后五分钟内禁止重发。
private static final int SMS_RETRY_SECONDS = 300;
private static final SecureRandom SMS_RANDOM = new SecureRandom();
// 计数与有效期原子设置,避免并发发送绕过限流或产生永不过期的计数键。
private static final String HOUR_LIMIT_SCRIPT = "local count=redis.call('incr',KEYS[1]); "
+ "if count==1 then redis.call('expire',KEYS[1],3600) end; return count";
// 成功发送后才保存验证码;带号码 hash tag 的键可在同一个 Redis 分片原子更新。
private static final String SAVE_SMS_SCRIPT = "if redis.call('get',KEYS[1])~=ARGV[1] then return 0 end; "
+ "redis.call('set',KEYS[2],ARGV[2],'EX',ARGV[3]); redis.call('del',KEYS[3]); "
+ "redis.call('expire',KEYS[1],ARGV[4]); return 1";
private static final String RELEASE_LOCK_SCRIPT = "if redis.call('get',KEYS[1])==ARGV[1] "
+ "then return redis.call('del',KEYS[1]) else return 0 end";
// 校验、错误计数和消费必须原子完成,同一码只能有一个请求成功,五次输错后作废。
private static final String VERIFY_SMS_SCRIPT = "local code=redis.call('get',KEYS[1]); "
+ "if not code then return 0 end; if code~=ARGV[1] then "
+ "local count=redis.call('incr',KEYS[2]); local ttl=redis.call('ttl',KEYS[1]); "
+ "redis.call('expire',KEYS[2],math.max(ttl,1)); "
+ "if count>=5 then redis.call('del',KEYS[1],KEYS[2]); return -2 end; return -1 end; "
+ "redis.call('del',KEYS[1],KEYS[2]); return 1";
@Inject
private RedisService redisService;
@Inject
private JedisAgent jedisAgent;
@Inject
private PropertiesProxy conf;
@Inject
private TencentSmsService tencentSmsService;
@Inject
private SliderCaptchaService sliderCaptchaService;
/**
* 获取验证码
*
* @return
* @return key 为图形验证码标识,codeUrl 为可直接展示的 Base64 图片;验证码有效期为三分钟
*/
@Aop(TransAop.READ_COMMITTED)
public NutMap getCaptcha() {
String uuid = R.UU32();
ArithmeticCaptcha captcha = new ArithmeticCaptcha(120, 40);
@@ -41,21 +83,66 @@ public class ValidateService {
}
/**
* 发送短信验证码
* 校验发送条件后发送登录短信,只有腾讯云确认发送成功才写入有效验证码。
*
* @param mobile 手机号码
* @throws BaseException
* @param mobile 已由用户 service 确认唯一绑定、未禁用的 11 位大陆手机号
* @param captchaKey 滑块验证接口返回的 platformKey
* @param captchaCode 滑块验证接口返回的一次性 platformCaptcha 凭证
* @param sessionId 服务端当前 Servlet 会话 ID,不接受前端指定
* @param clientIp 请求连接的来源 IP,用于后端发送频控
* @return retryAfter 为重发等待秒数,expireIn 为本次短信验证码有效秒数,不返回验证码或 Redis 键
* @throws BaseException 参数无效、滑块凭证不正确、频控触发或短信发送失败
*/
public void getSmsCode(String mobile) throws BaseException {
String text = R.captchaNumber(4);
String codeFromRedis = redisService.get(RedisConstant.UCENTER_SMSCODE + mobile + ":LOCK");
if (Strings.isNotBlank(codeFromRedis)) {
throw new BaseException("请1分钟之后再试");
@Aop(TransAop.READ_COMMITTED)
public NutMap sendLoginSmsCode(String mobile, String captchaKey, String captchaCode, String clientIp, String sessionId) {
if (mobile == null || !mobile.matches("^1[3-9]\\d{9}$") || Strings.isBlank(clientIp)) {
throw new BaseException("手机号或请求来源不正确");
}
sliderCaptchaService.consume(sessionId, captchaKey, captchaCode, SliderCaptchaService.SMS, mobile);
tencentSmsService.checkLoginConfiguration();
String mobileKey = RedisConstant.LOGIN_SMSCODE + "{" + mobile + "}";
String lockKey = mobileKey + ":LOCK";
String lockToken = R.UU32();
try (Jedis jedis = jedisAgent.getResource()) {
if (!"OK".equals(jedis.set(lockKey, lockToken, "NX", "EX", SMS_RETRY_SECONDS))) {
throw new BaseException("验证码发送频繁,请5分钟后再试");
}
}
boolean saved = false;
try {
try (Jedis jedis = jedisAgent.getResource()) {
long mobileCount = ((Number) jedis.eval(HOUR_LIMIT_SCRIPT,
Collections.singletonList(mobileKey + ":HOUR"), Collections.emptyList())).longValue();
long ipCount = ((Number) jedis.eval(HOUR_LIMIT_SCRIPT,
Collections.singletonList(RedisConstant.LOGIN_SMSCODE + "ip:" + clientIp), Collections.emptyList())).longValue();
if (mobileCount > Math.max(1, conf.getInt("sms.login.mobile-hour-limit", 10))
|| ipCount > Math.max(1, conf.getInt("sms.login.ip-hour-limit", 30))) {
throw new BaseException("验证码请求次数过多,请稍后再试");
}
}
String code = String.format(Locale.ROOT, "%06d", SMS_RANDOM.nextInt(1000000));
tencentSmsService.sendLoginCode(mobile, code);
try (Jedis jedis = jedisAgent.getResource()) {
Number saveResult = (Number) jedis.eval(SAVE_SMS_SCRIPT,
Arrays.asList(lockKey, mobileKey, mobileKey + ":FAIL"),
Arrays.asList(lockToken, code, String.valueOf(SMS_EXPIRE_SECONDS), String.valueOf(SMS_RETRY_SECONDS)));
if (saveResult.longValue() != 1) {
throw new BaseException("短信验证码处理超时,请稍后重新获取");
}
saved = true;
}
return NutMap.NEW().addv("retryAfter", SMS_RETRY_SECONDS).addv("expireIn", SMS_EXPIRE_SECONDS);
} finally {
// 失败只释放本请求持有的锁,不覆盖旧验证码,也不删除其他请求的新发送锁。
if (!saved) {
try (Jedis jedis = jedisAgent.getResource()) {
jedis.eval(RELEASE_LOCK_SCRIPT, Collections.singletonList(lockKey), Collections.singletonList(lockToken));
} catch (RuntimeException e) {
log.warn("登录短信发送锁释放失败,锁将在五分钟内自动过期");
}
}
}
// smsSendServer.sendCode(mobile, text);
log.debug("sms code:::" + text);
redisService.setex(RedisConstant.UCENTER_SMSCODE + mobile, 300, text);
redisService.setex(RedisConstant.UCENTER_SMSCODE + mobile + ":LOCK", 60, text);
}
/**
@@ -65,6 +152,7 @@ public class ValidateService {
* @param code 验证码的值
* @throws BaseException
*/
@Aop(TransAop.READ_COMMITTED)
public void checkCode(String key, String code) throws BaseException {
String codeFromRedis = redisService.get(RedisConstant.UCENTER_CAPTCHA + key);
@@ -81,27 +169,34 @@ public class ValidateService {
}
/**
* 核验短信验证码
* 按服务端收到的手机号核验登录短信验证码,成功后原子删除,输错五次作废。
*
* @param mobile 手机号码
* @param code 验证码值
* @throws BaseException
* @param mobile 本次登录的 11 位大陆手机号,与发送时的手机号完全一致
* @param code 用户输入的六位数字短信验证码
* @throws BaseException 验证码为空、格式错误、不匹配、过期或已使用
*/
public void checkSMSCode(String mobile, String code) throws BaseException {
String codeFromRedis = redisService.get(RedisConstant.UCENTER_SMSCODE + mobile);
if (Strings.isBlank(code)) {
throw new BaseException("请输入短信验证码");
@Aop(TransAop.READ_COMMITTED)
public void checkLoginSmsCode(String mobile, String code) throws BaseException {
if (mobile == null || !mobile.matches("^1[3-9]\\d{9}$") || code == null || !code.matches("^\\d{6}$")) {
throw new BaseException("请输入正确的手机号和六位短信验证码");
}
if (Strings.isEmpty(codeFromRedis)) {
throw new BaseException("短信验证码已过期");
String mobileKey = RedisConstant.LOGIN_SMSCODE + "{" + mobile + "}";
try (Jedis jedis = jedisAgent.getResource()) {
long result = ((Number) jedis.eval(VERIFY_SMS_SCRIPT,
Arrays.asList(mobileKey, mobileKey + ":FAIL"), Collections.singletonList(code))).longValue();
if (result == 0) {
throw new BaseException("短信验证码已过期或已使用,请重新获取");
}
if (result == -2) {
throw new BaseException("短信验证码错误次数过多,请重新获取");
}
if (result != 1) {
throw new BaseException("短信验证码不正确");
}
}
if (!Strings.equalsIgnoreCase(code, codeFromRedis)) {
throw new BaseException("短信验证码不正确");
}
redisService.del(RedisConstant.UCENTER_SMSCODE + mobile);
}
@Aop(TransAop.READ_COMMITTED)
public String decryptPwd(String keyId, String password) throws Exception {
// 从 Redis 获取私钥
String rsaKey = RedisConstant.RSA_KEY_PREFIX + keyId;
@@ -1,6 +1,7 @@
package com.budwk.app.zhgh.club.controller.apply;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
@@ -47,8 +48,19 @@ public class ClubUserClubListController {
public void index() {
}
/**
* 打开移动端社团列表,共用电脑端的社团查询及入退会状态。
*
* @return 无返回值,由模板渲染社团列表页面
*/
@At("/h5")
@Ok("beetl:/platform/zhghh5/club/list/index.html")
@SaCheckPermission(value = {"h5.club.join.list", "h5.club.join.apply"}, mode = SaMode.OR)
public void h5Index() {
}
@At
@SaCheckPermission("club.join.list")
@SaCheckPermission(value = {"club.join.list", "h5.club.join.list", "h5.club.join.apply"}, mode = SaMode.OR)
public Result pageData(@Valid PageForm pageForm, Boolean hasJoin, String clubName, String clubType) {
Cnd cnd = Cnd.NEW();
cnd.and("dismiss", "=", false);
@@ -71,7 +83,7 @@ public class ClubUserClubListController {
}
@At
@SaCheckPermission("club.join.list")
@SaCheckPermission(value = {"club.join.list", "h5.club.join.list", "h5.club.join.apply"}, mode = SaMode.OR)
public Result getCount() {
// 获取所有社团
List<SysClub> list = sysClubService.query();
@@ -96,7 +108,7 @@ public class ClubUserClubListController {
}
@At
@SaCheckPermission("club.join.list")
@SaCheckPermission(value = {"club.join.list", "h5.club.join.list", "h5.club.join.apply"}, mode = SaMode.OR)
public Result findOne(@Valid String id) {
ClubRegisterVo clubRegisterVo = sysClubService.findOne(id);
return Result.success(clubRegisterVo);
@@ -74,10 +74,22 @@ public class ClubUserJoinApplyController {
public void index() {
}
/**
* 移动端首次入会先选择社团,已选社团或已有申请继续进入申请表单。
*
* @param clubId 已选择的社团ID
* @param bizId 已有入会申请ID
* @param taskId 重新提交申请时的流程任务ID
* @return 无申请上下文时跳转社团列表,否则渲染原申请页面
*/
@At("/h5")
@SaCheckPermission("h5.club.join.apply")
@Ok("beetl:/platform/zhghh5/club/apply/index.html")
public void h5Index() {
@Ok("re")
public String h5Index(@Param("clubId") String clubId, @Param("bizId") String bizId, @Param("taskId") String taskId) {
if (StrUtil.isBlank(clubId) && StrUtil.isBlank(bizId) && StrUtil.isBlank(taskId)) {
return ">>:/platform/club/join/list/h5";
}
return "beetl:/platform/zhghh5/club/apply/index.html";
}
@At
@@ -2,12 +2,17 @@ package com.budwk.app.zhgh.club.controller.common;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.bpm.enums.BpmProcessInstanceStatusEnum;
import com.budwk.app.bpm.enums.BpmProcessTaskStatusEnum;
import com.budwk.app.flow.enums.ProcessInstanceStateEnum;
import com.budwk.app.sys.enums.SysFileEngineTypeEnum;
import com.budwk.app.sys.services.SysFileService;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.club.model.ClubUser;
@@ -19,10 +24,17 @@ import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.AdaptBy;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.POST;
import org.nutz.mvc.annotation.Param;
import org.nutz.mvc.upload.TempFile;
import org.nutz.mvc.upload.UploadAdaptor;
import java.util.List;
import java.util.Locale;
import java.util.Set;
@IocBean
@At("/platform/club/common")
@@ -36,6 +48,36 @@ public class ClubCommonController {
@Inject
private SysClubService sysClubService;
@Inject
private SysFileService sysFileService;
/**
* 上传社团缩略图,限制图片大小并复用系统文件服务校验图片内容及保存文件。
*
* @param file 用户上传的 JPG、PNG 或 GIF 图片,大小不得超过 2MB
* @return 成功时返回图片下载地址,文件无效或超过限制时返回错误提示
*/
@At
@POST
@AdaptBy(type = UploadAdaptor.class, args = {"ioc:fileUpload"})
@SaCheckPermission(value = {"club.register.apply", "club.infoManage.manage"}, mode = SaMode.OR)
public Result uploadThumbnail(@Param("file") TempFile file) {
if (file == null || StrUtil.isBlank(file.getSubmittedFileName())) {
return Result.error("请选择社团缩略图");
}
if (file.getSize() > 2L * 1024 * 1024) {
return Result.error("社团缩略图大小不能超过2MB");
}
String suffix = FileUtil.extName(file.getSubmittedFileName()).toLowerCase(Locale.ROOT);
if (!Set.of("jpg", "jpeg", "png", "gif").contains(suffix)) {
return Result.error("社团缩略图仅支持JPG、PNG、GIF图片");
}
try {
return Result.success().addData(sysFileService.uploadReturnUrl(SysFileEngineTypeEnum.MINIO.getValue(), file));
} catch (Exception e) {
return Result.error(e.getMessage());
}
}
@At
@SaCheckLogin
@@ -37,6 +37,11 @@ public class SysClub extends BaseModel {
@NotEmpty(message = "社团名称不能为空")
private String clubName;
@Column
@Comment("社团缩略图")
@ColDefine(type = ColType.VARCHAR, width = 1000)
private String thumbnail;
@Column
@Comment("社团编码")
@ColDefine(type = ColType.VARCHAR, width = 100)
@@ -112,11 +112,14 @@ public class SysClubServiceImpl extends BaseServiceImpl<SysClub> implements SysC
if (StrUtil.isBlank(clubId)) {
return new ClubRegisterVo();
}
// 与社团列表保持一致,按社团成员角色汇总会长、秘书长姓名,避免多名任职人员导致详情记录重复。
Sql sql = Sqls.create("""
SELECT
club.*,
d.NAME AS typeName,
u.username concatPersonName,
(select GROUP_CONCAT(username) from sys_user where id in (select userId from club_user u where u.clubId=club.id and JSON_CONTAINS(u.roleCode, '"CLUB_PRESIDENT"'))) as clubLeader,
(select GROUP_CONCAT(username) from sys_user where id in (select userId from club_user u where u.clubId=club.id and JSON_CONTAINS(u.roleCode, '"CLUB_SECRETARY"'))) as clubSecretary,
( SELECT count( 1 ) FROM club_user scu WHERE scu.clubId = club.id ) currentPeopleNum,
(select GROUP_CONCAT(username) from sys_user where id in (select sponsorId from sys_club_sponsor where clubId = club.id)) as sponsorName,
(select GROUP_CONCAT(sponsorId) from sys_club_sponsor where clubId = club.id) as sponsor
@@ -19,4 +19,10 @@ public class ClubRegisterVo extends SysClub {
private String sponsorName;
private Integer currentPeopleNum;
private String sponsor;
@ApiModelProperty("会长")
private String clubLeader;
@ApiModelProperty("秘书长")
private String clubSecretary;
}
@@ -84,6 +84,7 @@ public class H5HealthCheckupController {
Sql sql = Sqls.create("""
select
*,
(select count(*) from health_checkup_project_subject subject where subject.projectId=p.id) as subjectCount,
(select count(*) from health_checkup_user_selection s where s.projectId=p.id and s.selectUserId=@userId) as selectCount
from
health_checkup_project p
@@ -121,6 +122,8 @@ public class H5HealthCheckupController {
p.choiceTimeStart,
p.choiceTimeEnd,
p.cover,
p.healthCheckupType,
(select count(*) from health_checkup_project_subject subject where subject.projectId=p.id) as subjectCount,
sub.optionName
FROM
health_checkup_user_selection us
@@ -0,0 +1,269 @@
package com.budwk.app.zhgh.dayofficework.message.service;
import com.budwk.app.base.constant.RedisConstant;
import com.budwk.app.base.exception.BaseException;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.integration.jedis.RedisService;
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 javax.imageio.ImageIO;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.Area;
import java.awt.geom.Ellipse2D;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.UUID;
/**
* PC 登录拼图验证码:原图仅在服务端缩放,正确横坐标只保存在 Redis。
* 数据库事务注解遵循项目规范;Redis 状态转换的原子性由 Lua 保证。
*/
@IocBean
public class SliderCaptchaService {
public static final String PASSWORD = "password";
public static final String SMS = "sms";
private static final int WIDTH = 320;
private static final int HEIGHT = 160;
private static final int PIECE_SIZE = 50;
private static final int CHALLENGE_SECONDS = 180;
private static final int TICKET_SECONDS = 60;
// PC 和手机端共用图片池:0~9 为风景,10~19 为动物,文件位于 static/captcha/slider。
private static final String[] BACKGROUNDS = {
"0.jpg", "1.jpg", "2.jpg", "3.jpg", "4.jpg",
"5.jpg", "6.jpg", "7.jpg", "8.jpg", "9.jpg",
"10.jpg", "11.jpg", "12.jpg", "13.jpg", "14.jpg",
"15.jpg", "16.jpg", "17.jpg", "18.jpg", "19.jpg"
};
private final SecureRandom random = new SecureRandom();
private volatile BufferedImage[] backgrounds;
@Inject
private RedisService redisService;
// 计数和首次设置有效期必须同时完成,防止进程中断留下永不过期的限流键。
private static final String RATE_SCRIPT = "local n = redis.call('INCR', KEYS[1]); "
+ "if n == 1 then redis.call('EXPIRE', KEYS[1], 60) end; return n";
// 一个挑战仅允许提交一次。旧挑战不能删除刷新后生成的新挑战,已通过状态不能再次换票。
private static final String VERIFY_SCRIPT = "local raw = redis.call('GET', KEYS[1]); "
+ "if not raw then return 0 end; local s = cjson.decode(raw); "
+ "if s.id ~= ARGV[1] or s.state ~= 'pending' then return 0 end; "
+ "if math.abs(s.x - tonumber(ARGV[2])) > 4 then redis.call('DEL', KEYS[1]); return -1 end; "
+ "redis.call('SETEX', KEYS[1], ARGV[4], "
+ "cjson.encode({id=s.id,state='passed',ticket=ARGV[3],binding=s.binding})); return 1";
// 比对与删除在同一个 Redis 命令中执行,并发登录最多只能消费一次凭证。
private static final String CONSUME_SCRIPT = "local raw = redis.call('GET', KEYS[1]); "
+ "if not raw then return 0 end; local s = cjson.decode(raw); "
+ "if s.state ~= 'passed' or s.id ~= ARGV[1] or s.ticket ~= ARGV[2] or s.binding ~= ARGV[3] then return 0 end; "
+ "redis.call('DEL', KEYS[1]); return 1";
/**
* 创建当前会话的新挑战,覆盖该会话之前的挑战或登录凭证。
*
* @param sessionId Servlet 生成的当前会话 ID,不接受前端自行指定
* @param remoteAddress Servlet 连接的客户端 IP,用于独立于会话的请求限流
* @param scene password 为账号登录,sms 为发送登录短信
* @param subject password 场景传原始账号,sms 场景传已去除首尾空格的手机号
* @return NutMap:challengeId 为挑战编号;background/piece 为图像 data URL;
* width/height/pieceSize/pieceY 为原图像素尺寸和拼图纵坐标;expiresIn 为有效秒数
*/
@Aop(TransAop.READ_COMMITTED)
public NutMap create(String sessionId, String remoteAddress, String scene, String subject) throws IOException {
String binding = binding(scene, subject);
String sessionHash = digest(sessionId);
long ipCount = ((Number) redisService.eval(RATE_SCRIPT, 1,
RedisConstant.REDIS_SLIDER_RATE_KEY + "ip:" + digest(remoteAddress))).longValue();
long sessionCount = ((Number) redisService.eval(RATE_SCRIPT, 1,
RedisConstant.REDIS_SLIDER_RATE_KEY + "session:" + sessionHash)).longValue();
if (ipCount > 120 || sessionCount > 20) {
throw new BaseException("获取拼图过于频繁,请稍后再试");
}
BufferedImage[] images = getBackgrounds();
BufferedImage original = images[random.nextInt(images.length)];
// 左侧预留拖动起点,四周留边,避免拼图越界或用户无需拖动就能通过。
int x = 60 + random.nextInt(WIDTH - PIECE_SIZE - 70);
int y = 10 + random.nextInt(HEIGHT - PIECE_SIZE - 20);
Area shape = new Area(new Rectangle2D.Double(0, 8, 42, 42));
shape.add(new Area(new Ellipse2D.Double(13, 0, 16, 16)));
shape.add(new Area(new Ellipse2D.Double(34, 21, 16, 16)));
shape.subtract(new Area(new Ellipse2D.Double(-8, 21, 16, 16)));
BufferedImage background = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB);
Graphics2D bg = background.createGraphics();
try {
bg.drawImage(original, 0, 0, null);
bg.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
bg.translate(x, y);
bg.setColor(new Color(0, 0, 0, 170));
bg.fill(shape);
bg.setColor(new Color(255, 255, 255, 210));
bg.setStroke(new BasicStroke(1.5f));
bg.draw(shape);
} finally {
bg.dispose();
}
BufferedImage piece = new BufferedImage(PIECE_SIZE, PIECE_SIZE, BufferedImage.TYPE_INT_ARGB);
Graphics2D pg = piece.createGraphics();
try {
pg.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
pg.setClip(shape);
pg.drawImage(original, -x, -y, null);
pg.setClip(null);
pg.setColor(Color.WHITE);
pg.setStroke(new BasicStroke(1.5f));
pg.draw(shape);
} finally {
pg.dispose();
}
String challengeId = newId();
NutMap response = NutMap.NEW().addv("challengeId", challengeId)
.addv("background", imageUrl(background, "jpg"))
.addv("piece", imageUrl(piece, "png"))
.addv("width", WIDTH).addv("height", HEIGHT)
.addv("pieceSize", PIECE_SIZE).addv("pieceY", y)
.addv("expiresIn", CHALLENGE_SECONDS);
redisService.setex(RedisConstant.REDIS_SLIDER_CAPTCHA_KEY + sessionHash, CHALLENGE_SECONDS,
Json.toJson(NutMap.NEW().addv("id", challengeId).addv("state", "pending").addv("x", x).addv("binding", binding)));
return response;
}
/**
* 校验拖动位置并签发登录凭证,失败后当前挑战作废。
*
* @param sessionId 当前 Servlet 会话 ID
* @param challengeId create 返回的 32 位挑战编号
* @param offset 拼图左边缘在原图中的横坐标,前端需将显示像素换算为原图像素
* @return NutMap:platformKey 为挑战编号,platformCaptcha 为随机的一次性凭证,expiresIn 为有效秒数
*/
@Aop(TransAop.READ_COMMITTED)
public NutMap verify(String sessionId, String challengeId, double offset) {
if (!validId(challengeId) || !Double.isFinite(offset) || offset < 0 || offset > WIDTH - PIECE_SIZE) {
throw new BaseException("拼图参数不正确,请重新获取");
}
String ticket = newId();
long result = ((Number) redisService.eval(VERIFY_SCRIPT, 1,
RedisConstant.REDIS_SLIDER_CAPTCHA_KEY + digest(sessionId), challengeId,
Double.toString(offset), ticket, Integer.toString(TICKET_SECONDS))).longValue();
if (result != 1) {
throw new BaseException(result == -1 ? "未对齐缺口,请重新验证" : "拼图已失效,请重新验证");
}
return NutMap.NEW().addv("platformKey", challengeId).addv("platformCaptcha", ticket)
.addv("expiresIn", TICKET_SECONDS);
}
/**
* PC 账号认证之前消费拼图凭证。无返回值代表校验通过,失败抛出验证码认证异常。
*
* @param sessionId 当前 Servlet 会话 ID,保证其他会话不能使用此凭证
* @param challengeId 登录参数 platformKey,取自 verify 的返回结果
* @param ticket 登录参数 platformCaptcha,必须为 verify 签发的一次性凭证
* @param scene 本次操作用途 password 或 sms,不能跨用途使用
* @param subject 本次操作的账号或手机号,必须与创建挑战时一致
*/
@Aop(TransAop.READ_COMMITTED)
public void consume(String sessionId, String challengeId, String ticket, String scene, String subject) {
if (Strings.isBlank(challengeId) || Strings.isBlank(ticket)) {
throw new BaseException("请先完成拼图验证");
}
if (!validId(challengeId) || !validId(ticket)) {
throw new BaseException("拼图验证已失效,请重新验证");
}
long result = ((Number) redisService.eval(CONSUME_SCRIPT, 1,
RedisConstant.REDIS_SLIDER_CAPTCHA_KEY + digest(sessionId), challengeId, ticket, binding(scene, subject))).longValue();
if (result != 1) {
throw new BaseException("拼图验证已失效,请重新验证");
}
}
/** 将用途与账号绑定到摘要中,防止验证后换号或跨用途消费;不在 Redis 保存账号明文。 */
private String binding(String scene, String subject) {
if ((!PASSWORD.equals(scene) && !SMS.equals(scene)) || Strings.isBlank(subject)
|| subject.length() > 200 || (SMS.equals(scene) && !subject.matches("^1[3-9]\\d{9}$"))) {
throw new BaseException("请先填写正确的账号或手机号");
}
return digest(scene + ":" + subject);
}
/** 缩小后的原图只缓存一份且不再修改;使用资源流以兼容 JAR 部署。 */
private BufferedImage[] getBackgrounds() throws IOException {
if (backgrounds == null) {
synchronized (this) {
if (backgrounds == null) {
BufferedImage[] loaded = new BufferedImage[BACKGROUNDS.length];
for (int i = 0; i < BACKGROUNDS.length; i++) {
try (InputStream input = SliderCaptchaService.class.getResourceAsStream(
"/static/captcha/slider/" + BACKGROUNDS[i])) {
if (input == null) {
throw new IOException("拼图背景资源缺失:" + BACKGROUNDS[i]);
}
BufferedImage source = ImageIO.read(input);
if (source == null) {
throw new IOException("拼图背景无法读取:" + BACKGROUNDS[i]);
}
loaded[i] = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = loaded[i].createGraphics();
try {
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BICUBIC);
graphics.drawImage(source, 0, 0, WIDTH, HEIGHT, null);
} finally {
graphics.dispose();
}
}
}
backgrounds = loaded;
}
}
}
return backgrounds;
}
private String imageUrl(BufferedImage image, String format) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
if (!ImageIO.write(image, format, output)) {
throw new IOException("不支持的拼图编码:" + format);
}
return "data:image/" + ("jpg".equals(format) ? "jpeg" : format) + ";base64,"
+ Base64.getEncoder().encodeToString(output.toByteArray());
}
private String newId() {
return UUID.randomUUID().toString().replace("-", "");
}
private boolean validId(String value) {
return value != null && value.matches("[a-f0-9]{32}");
}
/** Redis 键中不暴露原始会话 ID 或 IP,也避免外部字符影响键结构。 */
private String digest(String value) {
if (Strings.isBlank(value)) {
throw new BaseException("会话已失效,请刷新登录页面");
}
try {
byte[] bytes = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("无法计算拼图会话标识", e);
}
}
}
@@ -0,0 +1,23 @@
package com.budwk.app.zhgh.dayofficework.message.service;
/**
* 腾讯云登录验证码短信服务,与校内通讯平台的业务通知短信分别配置。
*/
public interface TencentSmsService {
/**
* 校验登录短信开关和腾讯云必填配置,配置缺失时禁止生成有效验证码。
*
* @throws com.budwk.app.base.exception.BaseException 未启用短信或缺少凭证、签名、应用及模板配置
*/
void checkLoginConfiguration();
/**
* 向单个已绑定账号的大陆手机号发送登录验证码。
*
* @param mobile 不带 +86 的 11 位大陆手机号,由登录业务层完成账号绑定及停用校验
* @param code 六位数字验证码,作为登录短信模板的唯一变量
* @throws com.budwk.app.base.exception.BaseException 配置无效、腾讯云调用失败或目标号码发送状态不为 Ok
*/
void sendLoginCode(String mobile, String code);
}
@@ -0,0 +1,147 @@
package com.budwk.app.zhgh.dayofficework.message.service.impl;
import com.budwk.app.base.exception.BaseException;
import com.budwk.app.zhgh.dayofficework.message.service.TencentSmsService;
import com.tencentcloudapi.common.Credential;
import com.tencentcloudapi.common.exception.TencentCloudSDKException;
import com.tencentcloudapi.common.profile.ClientProfile;
import com.tencentcloudapi.common.profile.HttpProfile;
import com.tencentcloudapi.sms.v20190711.SmsClient;
import com.tencentcloudapi.sms.v20190711.models.SendSmsRequest;
import com.tencentcloudapi.sms.v20190711.models.SendSmsResponse;
import com.tencentcloudapi.sms.v20190711.models.SendStatus;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.impl.PropertiesProxy;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Strings;
import org.nutz.log.Log;
import org.nutz.log.Logs;
/**
* 沿用参考项目的腾讯云 v20190711 SDK、+86 号码和单变量登录模板。
*/
@IocBean
public class TencentSmsServiceImpl implements TencentSmsService {
private static final Log log = Logs.get();
@Inject
private PropertiesProxy conf;
/** {@inheritDoc} */
@Override
public void checkLoginConfiguration() {
if (!conf.getBoolean("sms.enabled", false)) {
throw new BaseException("短信登录暂未启用,请使用账号密码登录");
}
requiredConfig("sms.tencent.secret-id");
requiredConfig("sms.tencent.secret-key");
requiredConfig("sms.tencent.appid");
requiredConfig("sms.tencent.sign");
requiredConfig("sms.tencent.tpl.login_code");
}
/** {@inheritDoc} */
@Override
@Aop(TransAop.READ_COMMITTED)
public void sendLoginCode(String mobile, String code) {
if (mobile == null || !mobile.matches("^1[3-9]\\d{9}$")
|| code == null || !code.matches("^\\d{6}$")) {
throw new BaseException("短信手机号或验证码格式不正确");
}
checkLoginConfiguration();
// 单调时钟统计本次 SDK 调用(含客户端初始化)的耗时,不受系统时间校准影响。
long startedAt = System.nanoTime();
try {
Credential credential = new Credential(requiredConfig("sms.tencent.secret-id"), requiredConfig("sms.tencent.secret-key"));
HttpProfile httpProfile = new HttpProfile();
httpProfile.setEndpoint("sms.tencentcloudapi.com");
// 超时应小于发送锁有效期,避免慢请求与下一次重发同时写入验证码。
httpProfile.setConnTimeout(5);
httpProfile.setReadTimeout(10);
httpProfile.setWriteTimeout(10);
ClientProfile profile = new ClientProfile();
profile.setHttpProfile(httpProfile);
SmsClient client = new SmsClient(credential, conf.get("sms.tencent.region", "ap-guangzhou"), profile);
SendSmsRequest request = new SendSmsRequest();
request.setSmsSdkAppid(requiredConfig("sms.tencent.appid"));
request.setSign(requiredConfig("sms.tencent.sign"));
request.setTemplateID(requiredConfig("sms.tencent.tpl.login_code"));
request.setPhoneNumberSet(new String[]{"+86" + mobile});
request.setTemplateParamSet(new String[]{code});
checkSendStatus(client.SendSms(request), "+86" + mobile);
} catch (TencentCloudSDKException e) {
// SDK 网络异常可能没有 errorCode,实际异常类型及原因保存在 message 中。
// 各字段先脱敏再输出,不直接打印异常对象,避免原始消息中的敏感信息进入日志。
long elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000L;
log.warnf("腾讯云登录短信调用失败,code=%s,requestId=%s,elapsedMs=%s,reason=%s",
sanitizeDiagnostic(e.getErrorCode(), mobile, code),
sanitizeDiagnostic(e.getRequestId(), mobile, code), elapsedMillis,
sanitizeDiagnostic(e.getMessage(), mobile, code));
throw new BaseException("短信发送失败,请稍后重试或联系管理员");
}
}
/**
* 保留 SDK 异常诊断信息,移除可能夹带的凭证、手机号与短信码,并限制为单行日志。
*
* @param value SDK 返回的错误码、请求编号或异常消息,空值显示为 unavailable
* @param mobile 本次发送的大陆手机号,禁止在诊断字段中原样输出
* @param code 本次发送的六位短信验证码,禁止在诊断字段中原样输出
* @return 已脱敏的单行文本,最长 1000 个字符,超长内容附加省略标记
*/
private String sanitizeDiagnostic(String value, String mobile, String code) {
if (Strings.isBlank(value)) {
return "unavailable";
}
String sanitized = value;
String[] sensitiveValues = {Strings.trim(conf.get("sms.tencent.secret-id", "")),
Strings.trim(conf.get("sms.tencent.secret-key", "")), mobile, code};
for (String sensitive : sensitiveValues) {
if (!Strings.isBlank(sensitive)) {
sanitized = sanitized.replace(sensitive, "[REDACTED]");
}
}
// 同时遮盖异常文本中其他大陆手机号、签名及授权字段;先脱敏,再截断。
sanitized = sanitized.replaceAll("(?<!\\d)(?:\\+86)?1[3-9]\\d{9}(?!\\d)", "[PHONE]")
.replaceAll("(?i)(authorization|signature|token)[\"']?\\s*[:=]\\s*(\"[^\"]*\"|'[^']*'|[^,;}\\r\\n]+)",
"$1=[REDACTED]")
.replaceAll("[\\p{Cntrl}\\u2028\\u2029]", " ").trim();
return sanitized.length() > 1000 ? sanitized.substring(0, 1000) + "..." : sanitized;
}
/**
* 核对单号码业务发送状态,HTTP 调用成功不代表短信发送成功。
*
* @param response 腾讯云发送响应,必须恰有一个与请求号码相同的成功状态
* @param phoneNumber 请求中的 +86 国际格式号码
*/
private void checkSendStatus(SendSmsResponse response, String phoneNumber) {
SendStatus[] statuses = response == null ? null : response.getSendStatusSet();
if (statuses == null || statuses.length != 1 || statuses[0] == null
|| !"Ok".equals(statuses[0].getCode())
|| !phoneNumber.equals(statuses[0].getPhoneNumber())) {
String statusCode = statuses != null && statuses.length > 0 && statuses[0] != null
? statuses[0].getCode() : "EmptyStatus";
log.warnf("腾讯云登录短信未发送成功,code=%s,requestId=%s", statusCode,
response == null ? "" : response.getRequestId());
throw new BaseException("短信发送失败,请稍后重试或联系管理员");
}
}
/**
* 获取必填腾讯云配置,错误提示仅包含配置名称。
*
* @param key sms.tencent 下的完整配置键
* @return 去除首尾空格的非空配置值
*/
private String requiredConfig(String key) {
String value = Strings.trim(conf.get(key, ""));
if (Strings.isBlank(value)) {
throw new BaseException("短信服务配置不完整,请联系管理员检查 " + key);
}
return value;
}
}
@@ -313,4 +313,15 @@ public class UnionReimburseApplyController {
public Result getBudgetBalance(String reimburseFundSource, String clubId) {
return unionReimburseService.getBudgetBalance(reimburseFundSource, clubId, null);
}
/**
* 查询可申请社团经费报销的社团。
*
* @return 全部未解散社团列表
*/
@At
@SaCheckPermission(value = {"unionReimburse.apply", "h5.unionReimburse.apply"}, mode = SaMode.OR)
public Result listReimburseClubs() {
return Result.success(unionReimburseService.listReimburseClubs());
}
}
@@ -121,6 +121,16 @@ public class UnionReimburseCollectController {
applyListSort(cnd, pageForm);
sql.setCondition(cnd);
Pagination<NutMap> pagination = unionReimburseService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql);
// 复用打印流水号,按当日全部申请排序,避免分页或筛选改变编号。
for (NutMap row : pagination.getList()) {
UnionReimburse form = new UnionReimburse();
form.setId(row.getString("id"));
// SQL映射可能返回日期字符串,兼容日期对象后再参与流水号计算。
Object createTime = row.get("createTime");
form.setCreateTime(createTime instanceof java.util.Date ? (java.util.Date) createTime
: (createTime == null ? null : DateUtil.parse(createTime.toString())));
row.put("serialNo", unionReimburseExportService.buildPrintSerialNo(form));
}
return Result.success(pagination);
}
@@ -166,6 +176,7 @@ public class UnionReimburseCollectController {
WHEN rei.reimburseProject = 'UNION_REIMBURSE_PROJECT_2' THEN '文体活动'
WHEN rei.reimburseProject = 'UNION_REIMBURSE_PROJECT_3' THEN '日常活动'
WHEN rei.reimburseProject = 'UNION_REIMBURSE_PROJECT_4' THEN '专项活动'
WHEN rei.reimburseProject = 'UNION_REIMBURSE_PROJECT_5' THEN '社团活动'
ELSE rei.reimburseProject
END as reimbursementItemName,
u.mobile
@@ -249,6 +260,8 @@ public class UnionReimburseCollectController {
.orElse(null);
if (projectDict != null) {
item.setReimburseProject(projectDict.getName());
} else if ("UNION_REIMBURSE_PROJECT_5".equals(item.getReimburseProject())) {
item.setReimburseProject("社团活动");
}
}
// 转换报销经费来源
@@ -2,6 +2,7 @@ package com.budwk.app.zhgh.dayofficework.unionReimburse.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.page.Pagination;
@@ -10,6 +11,7 @@ import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.PageUtil;
import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburse;
import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseService;
import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseExportService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.aop.interceptor.ioc.TransAop;
@@ -36,6 +38,9 @@ public class UnionReimburseReviewController {
@Inject
private UnionReimburseService unionReimburseService;
@Inject
private UnionReimburseExportService unionReimburseExportService;
@At("/index")
@Ok("beetl:/platform/zhgh/dayofficework/unionReimburse/review/index.html")
@SaCheckPermission("unionReimburse.review")
@@ -88,6 +93,16 @@ public class UnionReimburseReviewController {
cnd.desc("info.createdAt");
sql.setCondition(cnd);
Pagination<NutMap> pagination = unionReimburseService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql);
// 复用打印流水号,按当日全部申请排序,避免分页或筛选改变编号。
for (NutMap row : pagination.getList()) {
UnionReimburse form = new UnionReimburse();
form.setId(row.getString("id"));
// SQL映射可能返回日期字符串,兼容日期对象后再参与流水号计算。
Object createTime = row.get("createTime");
form.setCreateTime(createTime instanceof java.util.Date ? (java.util.Date) createTime
: (createTime == null ? null : DateUtil.parse(createTime.toString())));
row.put("serialNo", unionReimburseExportService.buildPrintSerialNo(form));
}
return Result.success(pagination);
}
@@ -24,4 +24,11 @@ public interface UnionReimburseExportService {
* @return 活动及慰问模板通用的平铺字段及 payments、invoices 循环行
*/
Map<String, Object> buildExportData(UnionReimburse form);
/**
* 列表与打印共用按申请日期及当日申请时间排序的流水号,历史申请参与相同排序。
* @param form 报销申请,必须包含申请时间和单据ID
* @return 年月日加至少三位当日序号,例如20261009001;缺少必要字段或申请不存在时抛出业务异常
*/
String buildPrintSerialNo(UnionReimburse form);
}
@@ -58,10 +58,17 @@ public interface UnionReimburseService extends BaseService<UnionReimburse> {
Result recognizeInvoice(String fileId, Boolean verifySwitch);
/**
* 查询报销可用经费余额,同时校验是否已分配以及协会归属权限。
* 查询报销可用经费余额,同时校验是否已分配、经费来源权限及社团有效性。
*/
Result getBudgetBalance(String reimburseFundSource, String clubId, String unionId);
/**
* 查询未解散社团,供社团经费报销选择。
*
* @return 全部未解散社团的ID、名称和编码列表
*/
List<NutMap> listReimburseClubs();
/**
* 审核报销,审核通过时扣减对应预算并新增预算使用详情。
*/
@@ -7,6 +7,8 @@ import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import com.budwk.app.base.exception.BaseException;
import com.budwk.app.base.utils.MoneyUtil;
import com.budwk.app.base.utils.OfficePlusUtil;
@@ -20,23 +22,10 @@ import com.budwk.app.zhgh.staffbenefit.condolence.model.CondolenceType;
import com.deepoove.poi.XWPFTemplate;
import com.deepoove.poi.config.Configure;
import com.deepoove.poi.plugin.table.LoopRowTableRenderPolicy;
import com.deepoove.poi.data.PictureType;
import com.deepoove.poi.data.Pictures;
import com.deepoove.poi.template.ElementTemplate;
import com.google.zxing.*;
import com.google.zxing.client.j2se.BufferedImageLuminanceSource;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.HybridBinarizer;
import com.google.zxing.qrcode.QRCodeWriter;
import com.google.zxing.qrcode.QRCodeReader;
import org.apache.poi.xwpf.usermodel.*;
import org.apache.poi.util.Units;
import javax.imageio.ImageIO;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import lombok.extern.slf4j.Slf4j;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigDecimal;
@@ -139,7 +128,7 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ
return CONDOLENCE_TEMPLATE;
}
/** 两个入口按慰问类型选模板并共用字段和循环行配置;汇总打印额外补充编号二维码。 */
/** 两个入口按慰问类型选模板并共用流水号字段和循环行配置,不再生成二维码。 */
private byte[] renderDocument(UnionReimburse form, boolean asPdf, boolean collectPrint) {
String templateCode = resolveTemplateCode(form, collectPrint);
Path docx = null;
@@ -157,9 +146,6 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ
XWPFTemplate template = XWPFTemplate.compile(input, config);
OutputStream output = Files.newOutputStream(docx)) {
Map<String, Object> data = buildExportData(form);
if (collectPrint) {
prepareCollectQrCode(template, data, form.getDocumentNo());
}
template.render(data).write(output);
}
if (!asPdf) {
@@ -193,71 +179,41 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ
}
/**
* 汇总打印沿用老系统的单据编号二维码,仅修改本次生成的文档,不覆盖模板。
* @param template 当前已编译模板,优先使用 qrcode 图片标记,其次替换标题区的固定二维码
* @param data 本次渲染数据,qrcode 字段为 80×80 的 PNG 图片对象
* @param documentNo 原始单据编号,缺失时拒绝打印,避免用业务 ID 冒充编号
* 按申请日期和当日申请时间顺序生成打印流水号,六类模板使用同一编号口径。
* 历史单据也按申请时间参与排序,同一秒内用创建时间及主键确定顺序;
* 不按打印顺序编号,也不按当前用户或报销状态过滤申请。
*
* @param form 报销申请,使用其单据ID读取数据库中的申请日期
* @return 年月日加至少三位当日序号,例如 20261009001
*/
private void prepareCollectQrCode(XWPFTemplate template, Map<String, Object> data,
String documentNo) throws Exception {
if (StrUtil.isBlank(documentNo)) {
throw new BaseException("报销单编号为空,无法生成打印二维码,请先核对单据编号");
@Override
public String buildPrintSerialNo(UnionReimburse form) {
if (form == null || StrUtil.isBlank(form.getId())) {
throw new BaseException("报销单据ID为空,无法生成打印流水号");
}
var matrix = new QRCodeWriter().encode(documentNo, BarcodeFormat.QR_CODE, 80, 80);
var image = MatrixToImageWriter.toBufferedImage(matrix);
data.put("qrcode", Pictures.ofBufferedImage(image, PictureType.PNG).size(80, 80).create());
boolean hasTag = template.getElementTemplates().stream()
.anyMatch(item -> item instanceof ElementTemplate
&& "qrcode".equals(((ElementTemplate) item).getTagName()));
if (hasTag) {
return;
// 日期范围和编号前缀均取数据库原值,避免Java日期转换及参数绑定影响当天范围。
// 先对目标申请当天的全部单据排序,再筛选目标ID,保持审核、汇总和打印的编号一致。
Sql sql = Sqls.create("""
SELECT DATE_FORMAT(numbered.createTime, '%Y%m%d') AS serialDate, numbered.seq
FROM (
SELECT info.id, info.createTime,
ROW_NUMBER() OVER (ORDER BY info.createTime, info.createdAt, info.id) AS seq
FROM union_reimburse info
INNER JOIN union_reimburse target ON target.id = @id
WHERE info.createTime >= DATE(target.createTime)
AND info.createTime < DATE_ADD(DATE(target.createTime), INTERVAL 1 DAY)
) numbered
WHERE numbered.id = @id
""");
sql.setParam("id", form.getId());
sql.setCallback(Sqls.callback.map());
dao.execute(sql);
NutMap result = sql.getObject(NutMap.class);
if (result == null || !(result.get("seq") instanceof Number sequence)
|| sequence.longValue() < 1 || StrUtil.isBlank(result.getString("serialDate"))) {
throw new BaseException("未找到报销申请或申请时间为空,无法生成打印流水号");
}
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
ImageIO.write(image, "png", bytes);
XWPFDocument document = template.getXWPFDocument();
// 部分现用模板内嵌了固定二维码。仅识别第一张业务表格之前的二维码,保留徽标等普通图片。
boolean replaced = false;
for (IBodyElement element : document.getBodyElements()) {
if (element instanceof XWPFTable) break;
if (!(element instanceof XWPFParagraph)) continue;
for (XWPFRun run : ((XWPFParagraph) element).getRuns()) {
for (XWPFPicture picture : run.getEmbeddedPictures()) {
if (picture.getPictureData() == null) continue;
var existing = ImageIO.read(new ByteArrayInputStream(picture.getPictureData().getData()));
if (existing == null) continue;
try {
new QRCodeReader().decode(new BinaryBitmap(new HybridBinarizer(
new BufferedImageLuminanceSource(existing))));
} catch (NotFoundException | ChecksumException | FormatException e) {
continue;
}
String relationship = document.addPictureData(bytes.toByteArray(), Document.PICTURE_TYPE_PNG);
picture.getCTPicture().getBlipFill().getBlip().setEmbed(relationship);
replaced = true;
}
}
}
if (replaced) return;
// 无二维码标记或固定二维码的模板,在正文开头添加右对齐图片,不依赖模板重新上传。
XWPFParagraph paragraph;
if (document.getBodyElements().isEmpty()) {
paragraph = document.createParagraph();
} else {
IBodyElement first = document.getBodyElements().get(0);
var cursor = first instanceof XWPFParagraph
? ((XWPFParagraph) first).getCTP().newCursor()
: ((XWPFTable) first).getCTTbl().newCursor();
try {
paragraph = document.insertNewParagraph(cursor);
} finally {
cursor.dispose();
}
}
paragraph.setAlignment(ParagraphAlignment.RIGHT);
paragraph.setSpacingBefore(0);
paragraph.setSpacingAfter(0);
paragraph.createRun().addPicture(new ByteArrayInputStream(bytes.toByteArray()),
Document.PICTURE_TYPE_PNG, "qrcode.png", Units.pixelToEMU(80), Units.pixelToEMU(80));
return result.getString("serialDate") + String.format(Locale.ROOT, "%03d", sequence.longValue());
}
/** 平铺字段供两类模板复用,支付总额与发票总额分别计算,不使用第一条支付代替全部明细。 */
@@ -266,8 +222,11 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ
Map<String, Object> data = new LinkedHashMap<>();
data.put("date", date(form.getCreateTime()));
data.put("no", text(form.getDocumentNo()));
data.put("serialNo", buildPrintSerialNo(form));
String unit = text(form.getUnitName());
data.put("union", text(form.getUnionName()) + (unit.isEmpty() ? "" : "(" + unit + ")"));
data.put("union", "UNION_REIMBURSE_FUND_SOURCE_3".equals(form.getReimburseFundSource())
? text(form.getClubName())
: text(form.getUnionName()) + (unit.isEmpty() ? "" : "(" + unit + ")"));
data.put("handler", text(form.getUserName()));
data.put("login", text(form.getLoginName()));
data.put("phone", text(form.getMobile()));
@@ -276,6 +235,9 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ
+ ("UNION_REIMBURSE_PROJECT_1".equals(project) ? "√" : "□") + "慰问 "
+ ("UNION_REIMBURSE_PROJECT_3".equals(project) ? "√" : "□") + "日常活动 "
+ ("UNION_REIMBURSE_PROJECT_4".equals(project) ? "√" : "□") + "专项活动");
if ("UNION_REIMBURSE_PROJECT_5".equals(project)) {
data.put("project", "√社团活动");
}
data.put("activity", text(form.getActivityName()));
data.put("reason", text(form.getPaymentNotes()));
data.put("notes", text(form.getNotes()));
@@ -11,7 +11,6 @@ import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.sys.models.Sys_file;
import com.budwk.app.sys.views.View_user;
import com.budwk.app.sys.services.SysFileService;
import com.budwk.app.zhgh.club.model.ClubUser;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.club.model.OutlayManageClub;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.model.OutlayUseDetail;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.school.model.OutlayManageSchool;
@@ -148,6 +147,10 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
if (amountResult != null) {
return amountResult;
}
Result activityAmountResult = validateActivityApplyMoney(unionReimburse);
if (activityAmountResult != null) {
return activityAmountResult;
}
UnionReimburse oldRecord = null;
if (StrUtil.isNotBlank(unionReimburse.getId())) {
oldRecord = this.fetch(unionReimburse.getId());
@@ -159,6 +162,18 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
}
}
Result sourceResult = validateApplicantFundSource(unionReimburse);
if (sourceResult != null) {
return sourceResult;
}
if (oldRecord != null
&& ("UNION_REIMBURSE_FUND_SOURCE_3".equals(oldRecord.getReimburseFundSource())
|| "UNION_REIMBURSE_FUND_SOURCE_3".equals(unionReimburse.getReimburseFundSource()))
&& !Integer.valueOf(1).equals(oldRecord.getStateId())
&& !Integer.valueOf(5).equals(oldRecord.getStateId())) {
return Result.error("社团报销仅允许修改草稿或退回的申请");
}
// 人员身份和敏感资料由服务端补齐,避免前端删减字段导致丢失或被篡改。
if (oldRecord == null) {
View_user handler = dao().fetch(View_user.class, Cnd.where("id", "=", SecurityUtil.getUserId()));
@@ -491,6 +506,18 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
*/
@Override
public Result getBudgetBalance(String reimburseFundSource, String clubId, String unionId) {
if (!canApplyFundSource(reimburseFundSource)) {
return Result.error("当前角色无权使用该经费来源");
}
if ("UNION_REIMBURSE_FUND_SOURCE_3".equals(reimburseFundSource)
&& listReimburseClubs().stream().noneMatch(club -> Objects.equals(clubId, club.getString("id")))) {
return Result.error("请选择有效的社团");
}
return queryBudgetBalance(reimburseFundSource, clubId, unionId);
}
// 审核人按申请单的经费归属校验预算,不要求审核人同时是该社团负责人。
private Result queryBudgetBalance(String reimburseFundSource, String clubId, String unionId) {
if (StrUtil.isBlank(reimburseFundSource)) {
return Result.error("请选择经费来源");
}
@@ -515,16 +542,13 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
if (StrUtil.isBlank(clubId)) {
return Result.error("请选择协会");
}
List<String> myClubIds = getCurrentUserClubIds();
if (!isAdmin() && !myClubIds.contains(clubId)) {
return Result.error("当前用户无权使用该协会经费");
}
OutlayManageClub outlayManageClub = dao().fetch(OutlayManageClub.class,
Cnd.where("clubId", "=", clubId).and("year", "=", DateUtil.thisYear()));
if (outlayManageClub == null) {
return Result.error("该协会本年度未生成预算记录,请先在协会预算分配中补分配");
}
return Result.success(formatMoney(outlayManageClub.getTotalQuota().subtract(outlayManageClub.getUsedQuota())));
return Result.success(formatMoney(safeMoney(outlayManageClub.getTotalQuota()).subtract(safeMoney(outlayManageClub.getUsedQuota()))));
}
return Result.error("不支持的经费来源");
}
@@ -533,6 +557,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
* 审核通过时统一校验余额、扣减预算并写入预算使用详情,避免控制器分散业务逻辑。
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public Result reviewApply(String reimburseId, String reviewOpinion, int submitType) {
if (!List.of(3, 4, 5).contains(submitType)) {
return Result.error("无效的审核操作类型");
@@ -541,6 +566,25 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
if (dbRecord == null) {
return Result.error("记录不存在");
}
if ("UNION_REIMBURSE_FUND_SOURCE_3".equals(dbRecord.getReimburseFundSource())) {
// 同一申请审核串行处理,避免重复点击导致社团已使用额度累计两次。
Sql lockSql = Sqls.create("SELECT * FROM union_reimburse WHERE id=@id FOR UPDATE");
lockSql.setParam("id", reimburseId);
lockSql.setEntity(dao().getEntity(UnionReimburse.class));
lockSql.setCallback(Sqls.callback.entities());
dao().execute(lockSql);
List<UnionReimburse> records = lockSql.getList(UnionReimburse.class);
if (records.isEmpty()) {
return Result.error("记录不存在");
}
dbRecord = records.get(0);
if (submitType == 3 && Integer.valueOf(3).equals(dbRecord.getStateId())) {
return Result.success();
}
if (!Integer.valueOf(2).equals(dbRecord.getStateId())) {
return Result.error("仅可审核待审核确认的社团报销");
}
}
if (submitType == 3) {
// 已通过的单据再次触发通过时直接返回,避免重复扣减预算。
if (Integer.valueOf(3).equals(dbRecord.getStateId())) {
@@ -668,8 +712,43 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
unionReimburse.setInvoiceDetails(invoiceDetails);
}
private boolean isManualReimburseAmount(UnionReimburse form) {
return "UNION_REIMBURSE_PROJECT_2".equals(form.getReimburseProject())
|| "UNION_REIMBURSE_PROJECT_3".equals(form.getReimburseProject())
|| "UNION_REIMBURSE_PROJECT_4".equals(form.getReimburseProject());
}
/**
* 有发票时汇总票面金额和张数;无发票时按已校验的支付明细汇总金额,张数为零。
* 文体、日常、专项活动保留手填报销金额,有发票时限制票面总额,无发票不设此上限。
* @param form 已通过发票和支付信息校验的申请
* @return 金额必填、精度或上限校验不通过时返回错误,通过返回null
*/
private Result validateActivityApplyMoney(UnionReimburse form) {
if (!isManualReimburseAmount(form)) {
return null;
}
Double money = form.getMoney();
if (money == null) {
return Result.error("请填写报销金额");
}
if (!Double.isFinite(money) || money <= 0
|| BigDecimal.valueOf(money).stripTrailingZeros().scale() > 2) {
return Result.error("报销金额必须为正数,且最多两位小数");
}
if (Lang.isNotEmpty(form.getInvoiceDetails())) {
BigDecimal invoiceTotal = BigDecimal.ZERO;
for (UnionReimburseInvoiceDetail detail : form.getInvoiceDetails()) {
invoiceTotal = invoiceTotal.add(BigDecimal.valueOf(detail.getInvoiceAmount()).setScale(2, RoundingMode.HALF_UP));
}
if (BigDecimal.valueOf(money).compareTo(invoiceTotal) > 0) {
return Result.error("报销金额不能超过发票总额(" + invoiceTotal.toPlainString() + "元)");
}
}
return null;
}
/**
* 汇总票面金额和张数;三类活动保留手填金额,其余项目无发票时按支付明细汇总。
*/
private void fillInvoiceSummary(UnionReimburse unionReimburse) {
double totalMoney = 0D;
@@ -692,7 +771,10 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
}
totalMoney = paymentTotal.setScale(2, RoundingMode.HALF_UP).doubleValue();
}
unionReimburse.setMoney(BigDecimal.valueOf(totalMoney).setScale(2, RoundingMode.HALF_UP).doubleValue());
// 三类活动使用已校验的手填申请金额,不能被发票或支付合计覆盖。
if (!isManualReimburseAmount(unionReimburse)) {
unionReimburse.setMoney(BigDecimal.valueOf(totalMoney).setScale(2, RoundingMode.HALF_UP).doubleValue());
}
unionReimburse.setInvoiceNumber(String.valueOf(invoiceFileCount));
}
@@ -826,7 +908,11 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
if (unionReimburse.getActivityTime() == null) {
return Result.error("请选择活动时间");
}
if (Lang.isEmpty(unionReimburse.getFiles())) {
// 文体、日常和社团活动附件选填,其他活动保留附件必填校验。
if (!"UNION_REIMBURSE_PROJECT_2".equals(unionReimburse.getReimburseProject())
&& !"UNION_REIMBURSE_PROJECT_3".equals(unionReimburse.getReimburseProject())
&& !"UNION_REIMBURSE_PROJECT_5".equals(unionReimburse.getReimburseProject())
&& Lang.isEmpty(unionReimburse.getFiles())) {
return Result.error("请上传附件");
}
return validateInvoiceDetails(unionReimburse);
@@ -929,7 +1015,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
* 提交和审核前统一校验预算是否已分配、余额是否充足。
*/
private Result validateFundSourceBeforeSubmit(UnionReimburse unionReimburse) {
Result balanceResult = getBudgetBalance(unionReimburse.getReimburseFundSource(), unionReimburse.getClubId(), unionReimburse.getUnionId());
Result balanceResult = queryBudgetBalance(unionReimburse.getReimburseFundSource(), unionReimburse.getClubId(), unionReimburse.getUnionId());
if (balanceResult == null || balanceResult.getCode() != 0) {
return balanceResult == null ? Result.error("经费余额校验失败") : balanceResult;
}
@@ -985,11 +1071,19 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
if (outlayManageClub == null) {
return Result.error("该协会本年度未生成预算记录,请先在协会预算分配中补分配");
}
if (outlayManageClub.getTotalQuota().subtract(outlayManageClub.getUsedQuota()).compareTo(applyMoney) < 0) {
return Result.error("协会经费余额不足");
// 本年度分配总额度保持不变,审批金额原子累加至已使用额度,余额由两者相减得到。
Sql deductSql = Sqls.create("""
UPDATE outlay_manage_club
SET usedQuota = COALESCE(usedQuota, 0) + @amount
WHERE id = @id
AND COALESCE(totalQuota, 0) - COALESCE(usedQuota, 0) >= @amount
""");
deductSql.setParam("id", outlayManageClub.getId());
deductSql.setParam("amount", applyMoney);
dao().execute(deductSql);
if (deductSql.getUpdateCount() != 1) {
return Result.error("社团经费余额不足");
}
outlayManageClub.setUsedQuota(outlayManageClub.getUsedQuota().add(applyMoney));
dao().updateIgnoreNull(outlayManageClub);
insertUseDetail(unionReimburse, outlayManageClub.getId(), applyMoney);
return Result.success();
}
@@ -1201,6 +1295,9 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
if ("UNION_REIMBURSE_PROJECT_4".equals(reimburseProject)) {
return "专项活动申请";
}
if ("UNION_REIMBURSE_PROJECT_5".equals(reimburseProject)) {
return "社团活动申请";
}
return "报销申请";
}
@@ -1208,16 +1305,57 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
return money == null ? 0D : money.setScale(2, RoundingMode.HALF_UP).doubleValue();
}
private List<String> getCurrentUserClubIds() {
if (isAdmin()) {
return new ArrayList<>();
/**
* 查询未解散社团,社团选择和余额查询不按负责人归属过滤。
*
* @return 全部未解散社团的ID、名称和编码
*/
@Override
public List<NutMap> listReimburseClubs() {
Sql sql = Sqls.create("""
SELECT c.id, c.clubName, c.clubCode
FROM sys_club c
WHERE c.dismiss = 0
ORDER BY c.clubCode, c.id
""");
return listMap(sql);
}
// 社团经费按菜单权限授权,其他经费保留原申请角色判断。
private boolean canApplyFundSource(String source) {
if (StrUtil.isBlank(source)) {
return false;
}
List<ClubUser> clubUsers = dao().query(ClubUser.class, Cnd.where("userId", "=", SecurityUtil.getUserId()));
return clubUsers.stream()
.map(ClubUser::getClubId)
.filter(StrUtil::isNotBlank)
.distinct()
.collect(Collectors.toList());
boolean branchRole = AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_CHAIRMAN.name(), RoleConstant.BRANCH_UNION_ADMIN.name());
boolean clubRole = AuthUtil.hasRoleOr(RoleConstant.CLUB_MANAGER.name(), RoleConstant.CLUB_PRESIDENT.name());
return switch (source) {
case "UNION_REIMBURSE_FUND_SOURCE_1" -> isAdmin()
|| (!branchRole && !clubRole && AuthUtil.hasRoleOr(RoleConstant.SCHOOL_OUTLAY_ADMIN.name()));
case "UNION_REIMBURSE_FUND_SOURCE_2" -> isAdmin() || branchRole;
case "UNION_REIMBURSE_FUND_SOURCE_3" -> AuthUtil.hasPermission("unionReimburse.club");
default -> false;
};
}
private Result validateApplicantFundSource(UnionReimburse form) {
if (!canApplyFundSource(form.getReimburseFundSource())) {
return Result.error("当前角色无权使用该经费来源");
}
if ("UNION_REIMBURSE_FUND_SOURCE_3".equals(form.getReimburseFundSource())) {
if (!"UNION_REIMBURSE_PROJECT_5".equals(form.getReimburseProject())) {
return Result.error("社团经费请选择社团活动");
}
NutMap club = listReimburseClubs().stream()
.filter(item -> Objects.equals(form.getClubId(), item.getString("id")))
.findFirst().orElse(null);
if (club == null) {
return Result.error("请选择有效的社团");
}
form.setClubName(club.getString("clubName"));
} else if ("UNION_REIMBURSE_PROJECT_5".equals(form.getReimburseProject())) {
return Result.error("社团活动只能使用社团经费");
}
return null;
}
private Result validateInvoiceDuplicate(UnionReimburse unionReimburse) {
@@ -12,6 +12,8 @@ import com.budwk.app.flow.engine.FlowEngine;
import com.budwk.app.flow.entity.ProcessInstance;
import com.budwk.app.flow.entity.ProcessTask;
import com.budwk.app.flow.enums.ProcessSubmitTypeEnum;
import com.budwk.app.flow.enums.ProcessInstanceStateEnum;
import com.budwk.app.flow.enums.ProcessTaskStateEnum;
import com.budwk.app.flow.service.FlowCommonService;
import com.budwk.app.sys.models.Sys_union_group;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
@@ -31,6 +33,7 @@ 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.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
@@ -87,9 +90,12 @@ public class MemberApplyController {
@SaCheckPermission("member.apply.submit")
@SLog(tag = "会员入会申请", msg = "提交申请,申请人: ${args[0].username}")
public Result submit(@Param("data") MemberApplyRecord memberApplyRecord){
if (StrUtil.isBlank(memberApplyRecord.getId())) {
memberCommonService.validateApplyOrChangeIsDoing(null, "apply");
lockApplicant();
String blockMessage = getApplyBlockMessage(currentUser(), memberApplyRecord.getId(), null);
if (StrUtil.isNotBlank(blockMessage)) {
return Result.error(blockMessage);
}
memberApplyRecord.setUserId(SecurityUtil.getUserId());
// 开启流程实例
Dict args = Dict.create();
// 设置流程变量
@@ -126,9 +132,15 @@ public class MemberApplyController {
@SaCheckPermission("member.apply.submit")
@SLog(tag = "会员入会申请", msg = "重新提交申请,申请人: ${args[0].username}")
public Result submitAgain(@Param("data") MemberApplyRecord memberApplyRecord, @Param("taskId") Long taskId) {
if (StrUtil.isBlank(memberApplyRecord.getId())) {
memberCommonService.validateApplyOrChangeIsDoing(null, "apply");
lockApplicant();
String blockMessage = getApplyBlockMessage(currentUser(), memberApplyRecord.getId(), taskId);
if (StrUtil.isNotBlank(blockMessage)) {
return Result.error(blockMessage);
}
if (taskId == null) {
return Result.error("申请任务不存在或已处理,请勿重复提交");
}
memberApplyRecord.setUserId(SecurityUtil.getUserId());
Dict dict = Dict.create();
// 设置流程变量
if ("HMC".equals(Globals.SchoolCode)) {
@@ -190,14 +202,86 @@ public class MemberApplyController {
/**
* 获取当前登录用户信息,是用来判断当前用户是否是会员
* @return
* 获取当前登录用户信息及入会申请限制,供页面进入时提示。
*
* @param id 当前编辑的申请ID,新申请可为空
* @param taskId 重新提交的申请任务ID,新申请可为空
* @return 用户资料及禁止提交的原因;合法退回重提不视为重复申请
*/
@At
@ApiOperation("获取当前登录用户信息")
@SaCheckPermission("member.apply.submit")
public Result getSelfUserInfo() {
return Result.success(dao.fetch(View_user.class, Cnd.where("id", "=", SecurityUtil.getUserId())));
public Result getSelfUserInfo(String id, Long taskId) {
View_user user = currentUser();
if (user == null) {
return Result.error("无法获取当前用户信息");
}
NutMap data = Lang.obj2map(user, NutMap.class);
data.setv("applicationBlockMessage", getApplyBlockMessage(user, id, taskId));
return Result.success(data);
}
private View_user currentUser() {
return dao.fetch(View_user.class, Cnd.where("id", "=", SecurityUtil.getUserId()));
}
// 在提交事务中锁定申请人,避免并发请求同时通过检查并开启多个流程。
private void lockApplicant() {
Sql sql = Sqls.create("SELECT id FROM sys_user WHERE id=@userId FOR UPDATE");
sql.setParam("userId", SecurityUtil.getUserId());
sql.setCallback(Sqls.callback.records());
dao.execute(sql);
}
// 页面提示与提交接口共用实时校验;只放行本人退回或撤回后仍待处理的申请节点。
private String getApplyBlockMessage(View_user user, String id, Long taskId) {
if (user == null) {
return "无法获取当前用户信息";
}
if (user.getMember() != null && user.getMember() != 0) {
return "您已是工会会员,无需重复提交入会申请";
}
if (StrUtil.isNotBlank(id)) {
MemberApplyRecord record = dao.fetch(MemberApplyRecord.class, id);
if (record == null || !SecurityUtil.getUserId().equals(record.getUserId())) {
return "申请记录不存在或不属于当前用户";
}
}
Long excludedInstanceId = -1L;
if (taskId != null) {
ProcessTask task = dao.fetch(ProcessTask.class, taskId);
ProcessInstance instance = task == null ? null : dao.fetch(ProcessInstance.class, task.getProcessInstanceId());
if (StrUtil.isBlank(id) || task == null || instance == null
|| !"startTask".equals(task.getTaskName())
|| !ProcessTaskStateEnum.DOING.getCode().equals(task.getTaskState())
|| !ProcessInstanceStateEnum.DOING.getCode().equals(instance.getState())
|| !id.equals(instance.getBusinessNo())) {
return "申请任务不存在或已处理,请勿重复提交";
}
excludedInstanceId = instance.getId();
}
// 检查全部申请,避免较新的草稿遮蔽仍在审批中的旧申请。
Sql sql = Sqls.create("""
SELECT COUNT(*)
FROM member_apply_record r
INNER JOIN wf_process_instance p ON p.businessNo = r.id
WHERE r.userId = @userId AND p.state IN (@doing, @pending)
AND p.id <> @excludedInstanceId
""");
sql.setParam("userId", SecurityUtil.getUserId());
sql.setParam("doing", ProcessInstanceStateEnum.DOING.getCode());
sql.setParam("pending", ProcessInstanceStateEnum.PENDING.getCode());
sql.setParam("excludedInstanceId", excludedInstanceId);
sql.setCallback(Sqls.callback.integer());
dao.execute(sql);
if (sql.getInt() > 0) {
return "您已有入会申请正在处理中,请勿重复提交";
}
if (taskId == null && StrUtil.isNotBlank(id)
&& dao.count(ProcessInstance.class, Cnd.where("businessNo", "=", id)) > 0) {
return "该申请已提交,请勿重复提交";
}
return null;
}
}
@@ -0,0 +1,19 @@
-- 社团缩略图保存系统文件下载地址;保留可空以兼容未上传图片的历史社团。
-- 字段存在时跳过,避免重复执行迁移时报错。
SET @club_thumbnail_column_count = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sys_club'
AND COLUMN_NAME = 'thumbnail'
);
SET @club_thumbnail_ddl = IF(
@club_thumbnail_column_count = 0,
'ALTER TABLE sys_club ADD COLUMN thumbnail VARCHAR(1000) NULL COMMENT ''社团缩略图'' AFTER clubName',
'SELECT 1'
);
PREPARE club_thumbnail_stmt FROM @club_thumbnail_ddl;
EXECUTE club_thumbnail_stmt;
DEALLOCATE PREPARE club_thumbnail_stmt;
@@ -0,0 +1,38 @@
-- 社团活动属于报销项目的正式字典配置,两端沿用现有字典接口读取。
-- 仅补充缺失记录;已有记录(包括已禁用记录)不覆盖,不在查询接口中添加兜底项。
INSERT INTO sys_dict (
id, parentId, path, name, remark, code, disabled, location, hasChildren,
createdBy, createdAt, updatedBy, updatedAt, delFlag
)
SELECT
REPLACE(UUID(), '-', ''),
parent.id,
CONCAT(parent.path, LPAD(IFNULL((
SELECT MAX(CAST(RIGHT(child.path, 4) AS UNSIGNED))
FROM sys_dict child
WHERE child.path LIKE CONCAT(parent.path, '____')
), 0) + 1, 4, '0')),
'社团活动',
'',
'UNION_REIMBURSE_PROJECT_5',
0,
IFNULL((SELECT MAX(child.location) FROM sys_dict child WHERE child.parentId = parent.id), 0) + 1,
0,
'', UNIX_TIMESTAMP(NOW()) * 1000,
'', UNIX_TIMESTAMP(NOW()) * 1000,
0
FROM sys_dict parent
WHERE parent.code = 'UNION_REIMBURSE_PROJECT'
AND NOT EXISTS (
SELECT 1
FROM (SELECT code FROM sys_dict WHERE code = 'UNION_REIMBURSE_PROJECT_5') existing_item
);
UPDATE sys_dict parent
SET parent.hasChildren = 1
WHERE parent.code = 'UNION_REIMBURSE_PROJECT'
AND EXISTS (
SELECT 1
FROM (SELECT parentId FROM sys_dict WHERE code = 'UNION_REIMBURSE_PROJECT_5') club_activity
WHERE club_activity.parentId = parent.id
);
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+486 -109
View File
@@ -31,11 +31,17 @@
<div class="login-form">
<div class="my-3 text-6xl font-semibold">${AppName!}${base!}</div>
<div class="flx-center-center">
<div class="p-12 bg-white rounded-3xl" style="box-sizing: border-box">
<el-form ref="loginForm" :model="loginForm" :rules="loginRules" auto-complete="on" label-position="left">
<div class="login-card p-12 bg-white rounded-3xl" style="box-sizing: border-box">
<div class="login-tabs" role="tablist" aria-label="登录方式">
<button type="button" role="tab" :aria-selected="loginMode === 'password'" :class="{ active: loginMode === 'password' }" :disabled="formLoading || smsSending || captchaVisible" @click="switchLoginMode('password')">账号密码</button>
<button v-if="smsLoginVisible" type="button" role="tab" :aria-selected="loginMode === 'sms'" :class="{ active: loginMode === 'sms' }" :disabled="formLoading || smsSending || captchaVisible" @click="switchLoginMode('sms')">手机验证码</button>
</div>
<el-form v-show="loginMode === 'password'" ref="loginForm" :model="loginForm" :rules="loginRules" auto-complete="on" label-position="left">
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
:value="loginForm.username"
@input="$set(loginForm, 'username', $event)"
:disabled="formLoading || captchaVisible"
type="text"
auto-complete="off"
placeholder="账号"
@@ -44,7 +50,9 @@
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
:value="loginForm.password"
@input="$set(loginForm, 'password', $event)"
:disabled="formLoading || captchaVisible"
type="password"
auto-complete="off"
placeholder="密码"
@@ -52,37 +60,58 @@
prefix-icon="el-icon-lock"
></el-input>
</el-form-item>
<el-form-item v-if="!captchaDisabled" prop="platformCaptcha">
<el-input
v-model="loginForm.platformCaptcha"
auto-complete="off"
placeholder="验证码"
style="width: 60%"
@keyup.enter.native="handleLogin"
prefix-icon="el-icon-key"
></el-input>
<div class="login-code">
<img :src="codeUrl" @click="getCode" />
</div>
</el-form-item>
<el-form-item style="width: 100%">
<el-button
:loading="loading"
:loading="formLoading"
size="medium"
type="primary"
style="width: 100%; --color-primary: #1867b0"
@click.native.prevent="handleLogin"
>
<span v-if="!loading">登 录</span>
<span v-if="!formLoading">登 录</span>
<span v-else>登 录 中...</span>
</el-button>
</el-form-item>
</el-form>
<el-form v-show="smsLoginVisible && loginMode === 'sms'" ref="smsForm" :model="smsForm" :rules="smsRules" auto-complete="off" label-position="left">
<el-form-item prop="mobile">
<el-input :value="smsForm.mobile" @input="handleMobileChange" :disabled="formLoading || smsSending || captchaVisible" maxlength="11" inputmode="tel" placeholder="请输入系统绑定的手机号" prefix-icon="el-icon-mobile-phone"></el-input>
</el-form-item>
<el-form-item prop="smsCode">
<el-input :value="smsForm.smsCode" @input="$set(smsForm, 'smsCode', $event)" :disabled="formLoading || captchaVisible" maxlength="6" inputmode="numeric" autocomplete="one-time-code" placeholder="6位短信验证码" @keyup.enter.native="handleSmsLogin" prefix-icon="el-icon-message">
<el-button slot="append" :loading="smsSending" :disabled="formLoading || smsCountdown > 0 || captchaVisible" @click="sendSmsCode">{{ smsCountdown > 0 ? smsCountdown + '秒后重发' : '获取验证码' }}</el-button>
</el-input>
</el-form-item>
<el-form-item style="width: 100%">
<el-button :loading="formLoading" :disabled="smsSending || captchaVisible" size="medium" type="primary" style="width: 100%; --color-primary: #1867b0" @click.native.prevent="handleSmsLogin">
<span v-if="!formLoading">登 录</span>
<span v-else>登 录 中...</span>
</el-button>
</el-form-item>
<p class="login-tip">请使用系统账号绑定的手机号</p>
</el-form>
</div>
</div>
</div>
</div>
</div>
<!-- 两种登录方式共用滑块弹框;系统返回键只关闭当前弹框。 -->
<el-dialog title="安全验证" custom-class="captcha-dialog" :visible="captchaVisible" :before-close="closeCaptcha" :close-on-click-modal="false" append-to-body>
<div class="captcha-panel" v-loading="captchaLoading">
<div class="captcha-heading">
<span>拖动滑块,完成拼图</span>
<el-button type="text" :disabled="captchaLoading || captchaChecking" @click="loadSlider">换一张</el-button>
</div>
<div class="captcha-picture">
<template v-if="sliderData">
<img :key="sliderData.challengeId + '-background'" class="captcha-background" :src="sliderData.background" alt="拼图背景" @load="onSliderImageLoad('background', $event)" @error="onSliderImageError" />
<img :key="sliderData.challengeId + '-piece'" class="captcha-piece" :src="sliderData.piece" :style="pieceStyle" alt="待移动拼图" @load="onSliderImageLoad('piece', $event)" @error="onSliderImageError" />
</template>
</div>
<input type="range" class="captcha-range" min="0" :max="sliderMax" step="1" :value="sliderOffset" :disabled="!sliderReady || captchaChecking || captchaLoading" aria-label="拖动滑块使拼图对齐缺口" @input="onSliderInput" @change="checkSlider" />
<div class="captcha-hint" role="status">{{ captchaChecking ? '正在验证…' : captchaHint }}</div>
</div>
</el-dialog>
</div>
</body>
<!-- import Vue before Element -->
@@ -97,18 +126,12 @@
<script src="${base!}/assets/platform/plugins/sm-crypto/sm2.js"></script>
<!-- import commonUtil -->
<script src="${base!}/assets/platform/js/util/commonUtil.js"></script>
<script src="${base!}/assets/platform/js/util/h5PopupHistory.js"></script>
<!-- import jsencrypt-->
<script src="${base!}/assets/platform/plugins/jsencrypt/jsencrypt.min.js"></script>
<script nonce="${cspNonce!}">
// 在加载 lodash 后、使用前插入
const originalDefaultsDeep = window._.defaultsDeep;
window._.defaultsDeep = function(...args) {
// 先对所有参数做原型污染清洗
const cleanArgs = args.map(arg => sanitizeForPrototypePollution(arg));
return originalDefaultsDeep.apply(window._, cleanArgs);
};
function sanitizeForPrototypePollution(obj) {
// 登录页未加载 lodash 时跳过补丁,避免在 Vue 初始化前中断脚本。
const sanitizeForPrototypePollution = (obj) => {
if (obj === null || typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return obj.map(sanitizeForPrototypePollution);
@@ -122,29 +145,64 @@
}
return clean;
}
if (window._ && typeof window._.defaultsDeep === "function") {
const originalDefaultsDeep = window._.defaultsDeep;
window._.defaultsDeep = (...args) => {
const cleanArgs = args.map(arg => sanitizeForPrototypePollution(arg));
return originalDefaultsDeep.apply(window._, cleanArgs);
};
}
</script>
<script nonce="${cspNonce!}">
// 临时屏蔽指定局域网 IP 的验证码;恢复时删除 192.168.0.108,并同步删除后端对应条件。
const DISABLE_LOGIN_CAPTCHA = ["localhost", "127.0.0.1", "::1", "192.168.0.108"].includes(window.location.hostname)
// 本页沿用原 axios 响应结构(res.data),不使用会解包响应的全局拦截器。
Vue.prototype.$axios = axios
new Vue({
el: "#app",
data() {
return {
codeUrl: "",
captchaDisabled: DISABLE_LOGIN_CAPTCHA,
loginMode: "password",
// 服务端仅输出布尔值;参数缺失时默认隐藏短信入口。
smsLoginVisible: "${smsLoginVisible!false}" === "true",
loginForm: {
username: "",
password: "",
platformKey: "",
grantType: "captcha"
},
loginRules: {
username: [{ required: true, trigger: ["blur", "change"], message: "账号不能为空" }],
password: [{ required: true, trigger: ["blur", "change"], message: "密码不能为空" }],
platformCaptcha: []
},
loading: false,
smsForm: {
mobile: "",
smsCode: ""
},
smsRules: {
mobile: [
{ required: true, trigger: "blur", message: "请输入手机号" },
{ pattern: /^1[3-9]\d{9}$/, trigger: "blur", message: "请输入正确的11位手机号" }
],
smsCode: [
{ required: true, trigger: "blur", message: "请输入短信验证码" },
{ pattern: /^\d{6}$/, trigger: "blur", message: "请输入6位数字短信验证码" }
]
},
formLoading: false,
smsSending: false,
captchaVisible: false,
captchaLoading: false,
captchaChecking: false,
captchaHint: '拖动下方滑块,将拼图移到缺口处',
sliderData: null,
sliderOffset: 0,
sliderImages: { background: false, piece: false },
sliderVersion: 0,
sliderTimer: null,
sliderContext: null,
popupToken: null,
pageShowHandler: null,
smsCountdown: 0,
smsTimer: null,
rasParams: {
publicKey: '',
keyId: '',
@@ -152,72 +210,251 @@
},
}
},
methods: {
getCode() {
if (this.captchaDisabled) {
return
computed: {
sliderMax() {
return this.sliderData ? this.sliderData.width - this.sliderData.pieceSize : 270
},
sliderReady() {
return !!this.sliderData && this.sliderImages.background && this.sliderImages.piece
},
pieceStyle() {
if (!this.sliderData) return {}
return {
width: this.sliderData.pieceSize / this.sliderData.width * 100 + '%',
left: this.sliderOffset / this.sliderData.width * 100 + '%',
top: this.sliderData.pieceY / this.sliderData.height * 100 + '%'
}
axios.post("/platform/login/captcha").then((resp) => {
if (resp.data.code === 0) {
this.codeUrl = resp.data.data.codeUrl
this.loginForm.platformKey = resp.data.data.key
}
}
},
methods: {
switchLoginMode(mode) {
if (mode === 'sms' && !this.smsLoginVisible) return
if (this.formLoading || this.smsSending || this.captchaVisible || this.loginMode === mode) return
this.$set(this, 'loginMode', mode)
this.$nextTick(() => {
this.$refs.loginForm.clearValidate()
this.$refs.smsForm.clearValidate()
})
},
handleMobileChange(mobile) {
if (mobile !== this.smsForm.mobile) {
this.$set(this.smsForm, 'mobile', mobile)
// 短信验证码只适用于发送时的号码;改号后清空输入,保留发送倒计时。
this.$set(this.smsForm, 'smsCode', '')
}
},
// Element 按字段回调校验结果,所有字段完成后返回是否允许提交。
validateSmsFields(fields) {
return new Promise((resolve) => {
let remaining = fields.length
let valid = true
this.$refs.smsForm.validateField(fields, (message) => {
valid = valid && !message
remaining -= 1
if (remaining === 0) resolve(valid)
})
})
},
handleLogin() {
this.$refs.loginForm.validate(async (valid) => {
if (valid) {
await this.ensurePublicKey()
const { publicKey, keyId } = this.rasParams
if (!publicKey || !keyId) {
this.$message({
message: "Login key unavailable, please refresh and try again",
type: "error"
})
return
}
// 加密密码
const encrypt = new JSEncrypt()
encrypt.setPublicKey(publicKey)
const encryptedPassword = encrypt.encrypt(this.loginForm.password)
axios
.post("/platform/login/doLogin", null, {
params: {
...this.loginForm,
//password: window.btoa(this.loginForm.password)
password: encryptedPassword,
keyId: keyId
}
})
.then((resp) => {
if (resp.data.code === 0) {
this.$message.success("登录成功")
const redirect = new URLSearchParams(window.location.search).get("redirect")
if (redirect) {
window.location.href = "${base!}" + redirect || ""
} else {
window.location.href = "${base!}/platform/home"
}
} else {
this.getCode()
this.$message({
message: resp.data.msg,
type: "error"
})
}
})
.catch((error) => {
console.log(error)
})
} else {
console.log("cccccc")
}
if (this.formLoading || this.smsSending || this.captchaVisible || this.loginMode !== 'password') return
this.$refs.loginForm.validate((valid) => {
if (valid) this.openSlider('password')
})
},
// 获取公钥
sendSmsCode() {
if (this.smsSending || this.formLoading || this.captchaVisible || this.smsCountdown > 0) return
this.validateSmsFields(['mobile']).then((valid) => {
if (valid && !this.captchaVisible && !this.formLoading && !this.smsSending && this.smsCountdown === 0) this.openSlider('sms')
})
},
// 固定本次验证的用途及输入,避免异步返回时把凭证用于另一个账号或号码。
openSlider(scene) {
this.$set(this, 'sliderContext', {
scene: scene,
subject: scene === 'password' ? this.loginForm.username : this.smsForm.mobile,
password: scene === 'password' ? this.loginForm.password : ''
})
this.$set(this, 'captchaVisible', true)
window.h5PopupHistory.open(this.popupToken, 'login-slider')
this.loadSlider()
},
// scene/subject 绑定操作;返回图片、挑战编号和原图尺寸,不包含正确横坐标。
loadSlider() {
if (!this.captchaVisible || !this.sliderContext || this.captchaLoading || this.captchaChecking) return
clearTimeout(this.sliderTimer)
this.$set(this, 'sliderVersion', this.sliderVersion + 1)
const version = this.sliderVersion
this.$set(this, 'sliderData', null)
this.$set(this, 'sliderOffset', 0)
this.$set(this, 'sliderImages', { background: false, piece: false })
this.$set(this, 'captchaLoading', true)
this.$set(this, 'captchaHint', '图片加载中…')
return this.$axios.post('${base!}/platform/login/sliderCaptcha', null, {
params: { scene: this.sliderContext.scene, subject: this.sliderContext.subject }
}).then((resp) => {
if (version !== this.sliderVersion || !this.captchaVisible) return
if (resp.data.code !== 0 || !resp.data.data) throw new Error(resp.data.msg || '滑块加载失败')
this.$set(this, 'sliderData', resp.data.data)
this.$set(this, 'captchaHint', '拖动下方滑块,将拼图移到缺口处')
this.$set(this, 'sliderTimer', setTimeout(() => {
if (version !== this.sliderVersion) return
this.$set(this, 'sliderData', null)
this.$set(this, 'captchaHint', '拼图已过期,请点击“换一张”重新验证')
}, Math.max(1, Number(resp.data.data.expiresIn) || 180) * 1000))
}).catch((error) => {
if (version !== this.sliderVersion || !this.captchaVisible) return
this.$set(this, 'captchaHint', error.message || '加载失败,请点击“换一张”重试')
}).finally(() => {
if (version === this.sliderVersion) this.$set(this, 'captchaLoading', false)
})
},
onSliderImageLoad(kind, event) {
// 旧图片延迟触发 load 时不能把新一轮挑战提前标记为可拖动。
if (this.sliderData && event.target.getAttribute('src') === this.sliderData[kind]) {
this.$set(this.sliderImages, kind, true)
}
},
onSliderImageError() {
this.$set(this, 'sliderImages', { background: false, piece: false })
this.$set(this, 'captchaHint', '图片加载失败,请点击“换一张”重试')
},
onSliderInput(event) {
if (!this.sliderReady || this.captchaChecking) return
this.$set(this, 'sliderOffset', Math.max(0, Math.min(this.sliderMax, Number(event.target.value))))
},
// offset 使用原图像素;服务器返回一次性凭证,关闭弹框历史后再提交登录或发送短信。
checkSlider() {
if (!this.sliderReady || this.captchaChecking || !this.captchaVisible) return
const version = this.sliderVersion
const context = Object.assign({}, this.sliderContext)
this.$set(this, 'captchaChecking', true)
return this.$axios.post('${base!}/platform/login/checkSliderCaptcha', null, {
params: { challengeId: this.sliderData.challengeId, offset: this.sliderOffset }
}).then((resp) => {
if (version !== this.sliderVersion || !this.captchaVisible) return
if (resp.data.code !== 0 || !resp.data.data) throw new Error(resp.data.msg || '验证失败,请重试')
const ticket = resp.data.data
if (context.scene === 'password' && (context.subject !== this.loginForm.username || context.password !== this.loginForm.password)) {
throw new Error('账号信息已变化,请重新验证')
}
if (context.scene === 'sms' && context.subject !== this.smsForm.mobile) throw new Error('手机号已变化,请重新验证')
const loadingField = context.scene === 'password' ? 'formLoading' : 'smsSending'
this.$set(this, loadingField, true)
// clear 会同步触发关闭回调,因此使用局部快照保留本次请求参数。
return window.h5PopupHistory.clear(this.popupToken).then(() => {
return context.scene === 'password' ? this.submitPassword(context, ticket) : this.submitSms(context, ticket)
}).finally(() => {
this.$set(this, loadingField, false)
})
}).catch((error) => {
if (version !== this.sliderVersion || !this.captchaVisible) return
this.$set(this, 'sliderData', null)
this.$set(this, 'captchaHint', (error.message || '验证失败') + ',请点击“换一张”')
}).finally(() => {
if (version === this.sliderVersion) this.$set(this, 'captchaChecking', false)
})
},
closeCaptcha() {
// 关闭按钮、ESC 与系统返回共用全局历史管理器,不单独注册 popstate。
if (!window.h5PopupHistory.close(this.popupToken, 'login-slider')) this.resetSlider()
},
resetSlider() {
clearTimeout(this.sliderTimer)
this.$set(this, 'sliderVersion', this.sliderVersion + 1)
this.$set(this, 'captchaVisible', false)
this.$set(this, 'captchaLoading', false)
this.$set(this, 'captchaChecking', false)
this.$set(this, 'sliderData', null)
this.$set(this, 'sliderContext', null)
this.$set(this, 'sliderOffset', 0)
},
// 沿用 RSA 公钥加密;只提交服务器签发的滑块凭证,不提交滑块横坐标作为登录凭证。
submitPassword(context, ticket) {
return this.ensurePublicKey().then(() => {
const encrypt = new JSEncrypt()
encrypt.setPublicKey(this.rasParams.publicKey)
const encryptedPassword = encrypt.encrypt(context.password)
if (!encryptedPassword) throw new Error('密码加密失败,请刷新页面后重试')
return this.$axios.post('${base!}/platform/login/doLogin', null, {
params: { username: context.subject, password: encryptedPassword, keyId: this.rasParams.keyId,
platformKey: ticket.platformKey, platformCaptcha: ticket.platformCaptcha, grantType: 'captcha' }
})
}).then((resp) => {
this.handleLoginResult(resp.data)
}).catch((error) => {
this.$message.error(error.message || '登录失败,请重新验证后重试')
})
},
// mobile 为绑定号码,两个 platform 参数为滑块凭证;返回重发等待时间及短信有效时间。
submitSms(context, ticket) {
return this.$axios.post('${base!}/platform/login/mobileCaptcha', null, {
params: { mobile: context.subject, platformKey: ticket.platformKey, platformCaptcha: ticket.platformCaptcha }
}).then((resp) => {
if (resp.data.code !== 0) throw new Error(resp.data.msg || '短信验证码发送失败')
this.$set(this.smsForm, 'smsCode', '')
// 优先使用后端重发等待秒数,缺失时按五分钟倒计时。
this.startSmsCountdown(Number((resp.data.data || {}).retryAfter) || 300)
this.$message.success('验证码已发送,5分钟内有效')
}).catch((error) => {
this.$message.error(error.message || '短信验证码发送失败,请重新验证后重试')
})
},
startSmsCountdown(seconds) {
if (this.smsTimer) {
clearInterval(this.smsTimer)
}
const retryAt = Date.now() + Math.max(1, seconds) * 1000
this.$set(this, "smsCountdown", Math.ceil(seconds))
this.$set(this, "smsTimer", setInterval(() => {
this.$set(this, "smsCountdown", Math.max(0, Math.ceil((retryAt - Date.now()) / 1000)))
if (this.smsCountdown === 0) {
clearInterval(this.smsTimer)
this.$set(this, "smsTimer", null)
}
}, 1000))
},
handleSmsLogin() {
if (this.formLoading || this.smsSending || this.captchaVisible || this.loginMode !== "sms") {
return
}
// mobile 为当前手机号,smsCode 为6位短信码;接口返回标准 Result,code=0 时按原路径进入系统。
this.$set(this, "formLoading", true)
this.validateSmsFields(["mobile", "smsCode"])
.then((valid) => {
if (!valid) {
return
}
return this.$axios.post("${base!}/platform/login/doSmsLogin", null, {
params: { mobile: this.smsForm.mobile, smsCode: this.smsForm.smsCode }
}).then((resp) => {
this.handleLoginResult(resp.data)
})
})
.catch((error) => {
this.$message.error(error.message || "登录失败,请稍后重试")
})
.finally(() => {
this.$set(this, "formLoading", false)
})
},
// 两种登录方式共用原有成功提示与登录后跳转,保持 redirect 参数行为。
handleLoginResult(result) {
if (result.code !== 0) {
throw new Error(result.msg || "登录失败,请稍后重试")
}
this.$message.success("登录成功")
const redirect = new URLSearchParams(window.location.search).get("redirect")
window.h5PopupHistory.clear(this.popupToken).then(() => {
window.location.href = redirect ? "${base!}" + redirect : "${base!}/platform/home"
})
},
// 公钥接口沿用原 GET 方式;返回 publicKey、keyId、expireAt 供密码登录加密和过期判断。
fetchPublicKey() {
return axios.get("/platform/login/publicKey").then(res => {
this.rasParams = res.data.data
return this.$axios.get("${base!}/platform/login/publicKey").then((res) => {
if (res.data.code !== 0 || !res.data.data || !res.data.data.publicKey || !res.data.data.keyId) {
throw new Error(res.data.msg || "登录公钥获取失败,请稍后重试")
}
this.$set(this, "rasParams", res.data.data)
})
},
ensurePublicKey() {
@@ -229,8 +466,18 @@
},
},
created() {
this.fetchPublicKey()
this.getCode()
this.$set(this, 'popupToken', window.h5PopupHistory.register(() => this.resetSlider()))
this.$set(this, 'pageShowHandler', (event) => {
// 从浏览器往返缓存恢复时丢弃旧挑战,避免显示已过期凭证或遗留弹框。
if (event.persisted) window.h5PopupHistory.clear(this.popupToken).then(() => this.resetSlider())
})
window.addEventListener('pageshow', this.pageShowHandler)
},
beforeDestroy() {
clearInterval(this.smsTimer)
clearTimeout(this.sliderTimer)
window.removeEventListener('pageshow', this.pageShowHandler)
window.h5PopupHistory.clear(this.popupToken).then(() => window.h5PopupHistory.unregister(this.popupToken))
}
})
</script>
@@ -263,6 +510,8 @@
}
.login-container {
position: relative;
z-index: 1;
width: 100vw;
height: 100vh;
display: grid;
@@ -291,6 +540,121 @@
width: 500px;
}
.login-card {
width: 100%;
}
.login-tabs {
display: flex;
gap: 32px;
justify-content: center;
margin-bottom: 28px;
border-bottom: 1px solid #edf0f5;
}
.login-tabs button {
padding: 0 0 14px;
border: 0;
border-bottom: 3px solid transparent;
background: transparent;
color: #7c8798;
font: inherit;
font-size: 17px;
cursor: pointer;
}
.login-tabs button.active {
border-bottom-color: #1867b0;
color: #1867b0;
font-weight: 600;
}
.login-tabs button:disabled {
cursor: default;
}
.captcha-dialog {
width: 400px;
max-width: calc(100vw - 32px);
border-radius: 6px;
}
.captcha-dialog .el-dialog__body {
padding: 10px 20px 20px;
}
.captcha-panel {
padding: 12px;
border: 1px solid #dcdfe6;
border-radius: 6px;
background: #f7faff;
line-height: 1.5;
}
.captcha-heading {
display: flex;
justify-content: space-between;
align-items: center;
color: #606266;
margin-bottom: 8px;
}
.captcha-picture {
position: relative;
padding-top: 50%;
overflow: hidden;
border-radius: 4px;
background: #e9eef5;
}
.captcha-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.captcha-piece {
position: absolute;
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, .5));
}
.captcha-picture img {
pointer-events: none;
user-select: none;
}
.captcha-range {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 40px;
margin: 12px 0 0;
border-radius: 5px;
background: #e4eaf2;
cursor: ew-resize;
touch-action: pan-y;
}
.captcha-range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 42px;
height: 40px;
border-radius: 5px;
border: 2px solid #fff;
background: #409eff;
box-shadow: 0 1px 4px rgba(0, 0, 0, .2);
}
.captcha-range::-moz-range-thumb {
width: 38px;
height: 36px;
border-radius: 5px;
border: 2px solid #fff;
background: #409eff;
}
.captcha-range:focus-visible { outline: 2px solid #409eff; outline-offset: 3px; }
.captcha-range:disabled { opacity: .55; cursor: wait; }
.captcha-hint { margin-top: 6px; color: #909399; font-size: 12px; text-align: center; }
.login-tip {
margin: -4px 0 0;
color: #8a94a4;
font-size: 12px;
line-height: 20px;
}
.font-semibold {
font-weight: 600;
}
@@ -317,18 +681,6 @@
border-radius: 1.5rem;
}
.login-code {
display: inline-block;
float: right;
width: 38%;
height: 38px;
}
.login-code img {
vertical-align: middle;
cursor: pointer;
}
.login-bg-img {
position: fixed;
width: 100%;
@@ -344,7 +696,32 @@
}
.login-container .login-box .login-form {
width: 100vw !important;
width: 100% !important;
padding: 24px 20px;
box-sizing: border-box;
}
.login-card {
padding: 30px 22px;
}
.text-6xl {
font-size: 24px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.login-tabs {
gap: 26px;
}
.login-tabs button {
font-size: 16px;
}
.el-input-group__append {
padding: 0 14px;
}
}
</style>
@@ -8,6 +8,12 @@ const REGISTER_INFO_COMPONENT = {
<el-link type="primary" @click="openChart">点击查看流程图</el-link>
</div>
<el-descriptions :column="3" border class="table_fixed">
<el-descriptions-item label="社团缩略图" :span="3">
<el-image v-if="viewData.thumbnail" :src="viewData.thumbnail" :preview-src-list="[viewData.thumbnail]" fit="contain" style="width: 180px; height: 120px">
<span slot="error">暂无图片</span>
</el-image>
<span v-else>暂无图片</span>
</el-descriptions-item>
<el-descriptions-item label="社团名称">
{{viewData.clubName}}
</el-descriptions-item>
@@ -2,9 +2,19 @@
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<style>
.club-join-apply-page .custom-card > .card-header { padding: 12px 24px; }
.club-join-apply-page .club-apply-back-bar { display: flex; align-items: center; }
.club-join-apply-page .club-apply-back-bar .el-button { padding: 0; font-size: 16px; }
.club-join-apply-page .club-apply-title { margin-bottom: 20px; }
</style>
<div id="app" v-cloak class="club-join-apply-page">
<custom-card>
<snaker-start slot="header" label="文体社团会员申请" define_key="XHRH"></snaker-start>
<div slot="header" class="club-apply-back-bar">
<el-button type="text" icon="el-icon-back" @click="backToClubList">返回</el-button>
</div>
<snaker-start class="club-apply-title" label="文体社团会员申请" define_key="XHRH"></snaker-start>
<el-form :model="formData" ref="formRef" :rules="formRules" label-width="0" label-suffix=":" class="flow-task-form">
<el-descriptions border class="descriptions-form">
<el-descriptions-item label="社团" :span="3">
@@ -156,6 +166,9 @@ layout("/layouts/platform.html"){
}
},
methods: {
backToClubList() {
commonUtil.pjaxPush('/platform/club/join/list');
},
onSave() {
this.$confirm("您确定保存吗?", "提示", {
confirmButtonText: "确定",
@@ -224,6 +237,16 @@ layout("/layouts/platform.html"){
this.$axios.post("/platform/club/common/listClub").then((res) => {
if (res.code === 0) {
this.clubOptions = res.data
// 从卡片进入新申请时预选社团,编辑已有申请仍使用原记录。
const clubId = GetQueryString("clubId")
if (!this.bizId && clubId) {
const club = this.clubOptions.find(item => item.id === clubId)
if (club) {
this.$set(this.formData, "clubId", club.id)
this.$set(this.formData, "clubName", club.clubName)
this.checkApplyClub(club.id)
}
}
}
})
},
@@ -11,9 +11,36 @@ layout("/layouts/platform.html"){
.el-tabs__header {
margin: 0;
}
.club-directory .club-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 20px; }
.club-directory .club-card { display: flex; flex-direction: column; min-width: 0; overflow: hidden; border: 1px solid #e4edf8; border-radius: 10px; background: #fff; box-shadow: 0 7px 20px rgba(55, 85, 126, .08); }
.club-directory .club-card-main { flex: 1; min-width: 0; }
.club-directory .club-card-cover { width: 100%; height: 180px; overflow: hidden; background: #edf4fc; }
.club-directory .club-card-cover .el-image { width: 100%; height: 100%; }
.club-directory .club-image-empty { display: flex; flex-direction: column; gap: 8px; height: 100%; align-items: center; justify-content: center; color: #8da1ba; font-size: 13px; background: #edf4fc; }
.club-directory .club-image-empty i { font-size: 34px; }
.club-directory .club-card-info { min-width: 0; padding: 14px 16px; }
.club-directory .club-card-name { margin: 0; color: #303846; font-size: 16px; font-weight: 400; line-height: 24px; overflow-wrap: anywhere; }
.club-directory .club-card-meta { margin: 5px 0 0; color: #98a2b2; font-size: 13px; line-height: 20px; overflow-wrap: anywhere; }
.club-directory .club-card-tags { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 10px; padding-top: 10px; border-top: 1px solid #edf1f6; }
.club-directory .club-card-tag { display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 4px; background: #edf5ff; color: #397de0; font-size: 12px; line-height: 18px; }
.club-directory .club-card-tag-label { margin-right: 4px; color: #8aa7cf; }
.club-directory .club-card-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 0 14px; margin: 0 16px; border-top: 1px solid #edf1f6; }
.club-directory .club-card-actions .el-button { height: 32px; margin: 0; padding: 0 14px; border: 0; border-radius: 16px; font-size: 13px; }
.club-directory .club-card-actions .el-button--primary { width: 108px; color: #fff; background: #1989fa; box-shadow: 0 5px 12px rgba(25, 137, 250, .18); }
.club-directory .club-card-actions .el-button--primary.is-plain { width: auto; color: #397de0; background: #edf5ff; box-shadow: none; }
.club-directory .club-card-actions .el-button--danger { width: 108px; color: #fff; background: #ee0a24; box-shadow: 0 5px 12px rgba(238, 10, 36, .18); }
.club-directory .club-card-sort { display: flex; justify-content: flex-end; margin-bottom: 16px; }
.club-directory .club-card-sort .el-select { width: 168px; }
.club-directory .club-detail-cover { width: 160px; max-width: 100%; height: 110px; overflow: hidden; border-radius: 8px; background: #f8fafc; }
.club-directory .club-detail-cover .el-image { display: block; width: 100%; height: 100%; border-radius: 8px; }
@media (max-width: 640px) {
.club-directory .club-card-grid { grid-template-columns: minmax(0, 1fr); }
.club-directory .club-card-cover { height: 150px; }
.club-directory .club-detail-cover { width: 120px; height: 90px; }
}
</style>
<div id="app" v-cloak>
<div id="app" v-cloak class="club-directory">
<guava ref="guava">
<template>
<el-card shadow="never">
@@ -38,29 +65,56 @@ layout("/layouts/platform.html"){
<el-radio-button :label="true">{{'已加入社团' + hasJoinCount + '(退会)'}}</el-radio-button>
</el-radio-group>
</table-tool>
<el-table :data="tableData" row-key="id" style="width: 100%">
<el-table-column :index="indexMethod" label="序号" type="index" width="80px"></el-table-column>
<el-table-column label="社团编码" sortable prop="clubCode"></el-table-column>
<el-table-column label="社团名称" prop="clubName" show-overflow-tooltip width="200px"></el-table-column>
<el-table-column label="成立时间" prop="foundTime"></el-table-column>
<el-table-column label="会长" prop="clubLeader" show-overflow-tooltip></el-table-column>
<el-table-column label="秘书长" prop="clubSecretary" show-overflow-tooltip></el-table-column>
<el-table-column sortable label="当前人数" show-overflow-tooltip prop="currentPeopleNum"></el-table-column>
<el-table-column label="操作" width="250px">
<template slot-scope="scope">
<el-button size="mini" type="primary" @click="openDetail(scope.row)">查看</el-button>
<el-button size="mini" v-if="hasJoin === false" type="primary" @click="doSubmit(scope.row)">申请加入</el-button>
<el-button size="mini" v-else type="danger" @click="onExit(scope.row)">申请退出</el-button>
</template>
</el-table-column>
</el-table>
<div class="club-card-sort">
<el-select v-model="clubSort" size="small" aria-label="社团排序" @change="changeClubSort">
<el-option label="社团编码升序" value="clubCode:ascending"></el-option>
<el-option label="社团编码降序" value="clubCode:descending"></el-option>
<el-option label="当前人数升序" value="currentPeopleNum:ascending"></el-option>
<el-option label="当前人数降序" value="currentPeopleNum:descending"></el-option>
</el-select>
</div>
<div class="club-card-grid" v-loading="tableLoading">
<article v-for="row in tableData" :key="row.id" class="club-card">
<div class="club-card-main">
<div class="club-card-cover">
<el-image v-if="row.thumbnail" :src="row.thumbnail" :alt="row.clubName + '缩略图'" fit="cover" :preview-src-list="[row.thumbnail]">
<div slot="error" class="club-image-empty"><i class="el-icon-picture-outline"></i><span>暂无图片</span></div>
</el-image>
<div v-else class="club-image-empty"><i class="el-icon-picture-outline"></i><span>暂无图片</span></div>
</div>
<div class="club-card-info">
<h3 class="club-card-name">{{row.clubName}}</h3>
<p class="club-card-meta">成立时间:{{row.foundTime || '--'}}</p>
<div class="club-card-tags">
<span class="club-card-tag"><span class="club-card-tag-label">会长</span>{{row.clubLeader || '--'}}</span>
<span class="club-card-tag"><span class="club-card-tag-label">社团类型</span>{{formatClubType(row.clubType)}}</span>
<span class="club-card-tag"><span class="club-card-tag-label">会费标准</span>{{row.due == null || row.due === '' ? '--' : row.due + ' 元/年'}}</span>
</div>
</div>
</div>
<div class="club-card-actions">
<el-button size="mini" type="primary" plain icon="el-icon-view" @click="openDetail(row)">查看</el-button>
<el-button size="mini" v-if="hasJoin === false" type="primary" icon="el-icon-right" @click="doSubmit(row)">申请加入</el-button>
<el-button size="mini" v-else type="danger" icon="el-icon-switch-button" @click="onExit(row)">申请退出</el-button>
</div>
</article>
</div>
<el-empty v-if="!tableLoading && !tableData.length" description="暂无社团"></el-empty>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</template>
<template #view>
<el-descriptions class="margin-top" title="" :column="2" border>
<el-descriptions-item span="2">
<el-descriptions-item label="社团图片">
<div class="club-detail-cover">
<el-image v-if="viewData.thumbnail" :src="viewData.thumbnail" :alt="viewData.clubName + '缩略图'" fit="contain" :preview-src-list="[viewData.thumbnail]">
<div slot="error" class="club-image-empty">暂无图片</div>
</el-image>
<div v-else class="club-image-empty">暂无图片</div>
</div>
</el-descriptions-item>
<el-descriptions-item>
<template slot="label">社团名称</template>
<div v-html="viewData.clubName"></div>
</el-descriptions-item>
@@ -103,6 +157,7 @@ layout("/layouts/platform.html"){
new Vue({
el: "#app",
store,
dicts: ['CLUB_REGISTER_TYPE'],
mixins: [initTableMixins],
components: {},
data() {
@@ -111,10 +166,21 @@ layout("/layouts/platform.html"){
hasJoin: false,
hasJoinCount: 0,
noJoinCount: 0,
clubSort: 'clubCode:ascending',
clickRow: {}
}
},
methods: {
formatClubType(value) {
const option = (this.dict.type.CLUB_REGISTER_TYPE || []).find((item) => String(item.code) === String(value));
return option ? option.name : '--';
},
changeClubSort(value) {
const [prop, order] = value.split(':')
this.pageForm.pageOrderName = prop
this.pageForm.pageOrderBy = order
this.doSearch()
},
onExit(row) {
this.$confirm("您确定要申请退出" + row.clubName + "吗?", "提示", {
confirmButtonText: "确定",
@@ -162,9 +228,14 @@ layout("/layouts/platform.html"){
},
pageData() {
this.pageForm.hasJoin = this.hasJoin
this.$axios.post("/platform/club/join/list/pageData", this.pageForm).then((res) => {
this.tableData = res.data.list
this.pageForm.totalCount = res.data.totalCount
this.tableLoading = true
return this.$axios.post("/platform/club/join/list/pageData", this.pageForm).then((res) => {
if (res.code === 0) {
this.tableData = res.data.list
this.pageForm.totalCount = res.data.totalCount
}
}).finally(() => {
this.tableLoading = false
})
}
},
@@ -58,6 +58,12 @@ layout("/layouts/platform.html"){
<el-tabs tab-position="top" v-model="activeName">
<el-tab-pane label="社团基础信息" name="1">
<el-descriptions style="margin-top: 20px;" class="margin-top" title="" :column="2" border>
<el-descriptions-item label="社团缩略图" :span="2">
<el-image v-if="viewData.thumbnail" :src="viewData.thumbnail" :preview-src-list="[viewData.thumbnail]" fit="contain" style="width: 180px; height: 120px">
<span slot="error">暂无图片</span>
</el-image>
<span v-else>暂无图片</span>
</el-descriptions-item>
<el-descriptions-item label="社团名称">{{viewData.clubName}}</el-descriptions-item>
<el-descriptions-item label="成立时间">{{viewData.foundTime}}</el-descriptions-item>
<el-descriptions-item label="社团编码">{{viewData.clubCode}}</el-descriptions-item>
@@ -79,6 +79,20 @@ const CLUB_FORM_TEMPLATE = {
</el-col>
</el-row>
<el-form-item label="社团缩略图" prop="thumbnail">
<file-upload
:value.sync="formData.thumbnail"
:upload_number="1"
:upload_size="2 * 1024 * 1024"
upload_mode="image"
upload_result_category="interval"
upload_result_type="url"
upload_dynamic_return_url_api="/platform/club/common/uploadThumbnail"
accept=".jpg,.jpeg,.png,.gif"
></file-upload>
<div style="color: #909399; font-size: 12px">支持 JPG、PNG、GIF 图片,大小不超过 2MB</div>
</el-form-item>
<el-form-item label="社团介绍及宗旨" prop="introduce">
<text-editor v-model="formData.introduce"></text-editor>
</el-form-item>
@@ -156,7 +170,8 @@ const CLUB_FORM_TEMPLATE = {
return {
formData: {
clubName: "",
clubType: ""
clubType: "",
thumbnail: ""
},
formRules: {
clubName: [{ required: true, message: "请填写社团名称", trigger: ["blur", "change"] }],
@@ -57,8 +57,10 @@ layout("/layouts/platform.html"){
min-width="50"
>
<template v-slot="{row}" v-if="column.prop=='surplusQuota'">
<span v-if="!row.totalQuota">{{row.totalQuota}}元</span>
<span v-else>{{(row.totalQuota-row.usedQuota).toFixed(2)}}</span>
<span>{{(Number(row.totalQuota || 0) - Number(row.usedQuota || 0)).toFixed(2)}}</span>
</template>
<template v-slot="{row}" v-else-if="column.prop=='usedQuota'">
<span>{{Number(row.usedQuota || 0).toFixed(2)}}</span>
</template>
</el-table-column>
@@ -7,6 +7,11 @@ layout("/layouts/platform.html"){
margin-top: 10px;
}
/* 社团表单改为两组字段后,左侧标签仍保持原三组字段表单的六分之一宽度。 */
.club-reimbursement-form .el-descriptions-item__label:first-child {
width: calc(100% / 6);
}
.invoice-toolbar {
display: flex;
align-items: center;
@@ -60,17 +65,21 @@ layout("/layouts/platform.html"){
日常活动申请</h3>
<h3 style="color: rgb(24, 103, 176);" v-else-if="formData.reimburseProject === 'UNION_REIMBURSE_PROJECT_4'">
专项活动申请</h3>
<h3 style="color: rgb(24, 103, 176);" v-else-if="formData.reimburseProject === 'UNION_REIMBURSE_PROJECT_5'">
社团活动申请</h3>
</template>
<el-form :model="formData" ref="formRef" :rules="formRules" label-width="0" label-suffix=":"
class="flow-task-form">
<el-tabs class="reimbursement-tabs" v-model="activeTabName">
<el-tab-pane label="申请基本信息" name="basic">
<el-descriptions :column="3" border>
<el-descriptions-item label="经费来源" span="3">
<!-- 来源或项目切换时重建表单项,避免动态布局复用旧字段的校验状态。 -->
<el-descriptions :column="formData.reimburseFundSource === 'UNION_REIMBURSE_FUND_SOURCE_3' ? 2 : 3" :class="{'club-reimbursement-form': formData.reimburseFundSource === 'UNION_REIMBURSE_FUND_SOURCE_3'}" border :key="formData.reimburseFundSource + ':' + formData.reimburseProject">
<el-descriptions-item label="经费来源" :span="formData.reimburseFundSource === 'UNION_REIMBURSE_FUND_SOURCE_3' ? 2 : 3">
<el-form-item label="经费来源" prop="reimburseFundSource">
<!-- 在表单重建前同步切换报销项目,避免延迟的 change 事件丢失。 -->
<el-radio-group v-model="formData.reimburseFundSource" size="medium"
@change="selectFundSource">
@input="selectFundSource">
<el-radio v-if="$auth.hasPermission('unionReimburse.xgh')"
label="UNION_REIMBURSE_FUND_SOURCE_1"
border>校工会经费
@@ -88,11 +97,11 @@ layout("/layouts/platform.html"){
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="报销项目" :span="3">
<el-descriptions-item label="报销项目" :span="formData.reimburseFundSource === 'UNION_REIMBURSE_FUND_SOURCE_3' ? 2 : 3">
<el-form-item label="报销项目" prop="reimburseProject">
<el-radio-group v-model="formData.reimburseProject" size="medium"
@change="handleReimburseProjectChange">
<el-radio :label="item.code" border v-for="item in reimburseProjectList">
<el-radio :label="item.code" border v-for="item in availableReimburseProjects" :key="item.code">
{{item.name}}
</el-radio>
</el-radio-group>
@@ -104,24 +113,6 @@ layout("/layouts/platform.html"){
<el-descriptions-item label="经办人">{{formData.userName}}({{formData.loginName}})
</el-descriptions-item>
<el-descriptions-item label="所属协会"
v-if="formData.reimburseFundSource === 'UNION_REIMBURSE_FUND_SOURCE_3'">
<el-form-item label="所属协会" prop="clubId">
<el-select clearable
placeholder="请选择协会"
style="width: 100%;"
v-model="formData.clubId"
@change="clubChange">
<el-option
:key="item.id"
:label="item.clubName"
:value="item.id"
v-for="item in clubOptions">
</el-option>
</el-select>
</el-form-item>
</el-descriptions-item>
<!-- <el-descriptions-item label="报销类别">-->
<!-- <el-form-item label="报销类别" prop="reimburseType">-->
<!-- <el-radio-group v-model="formData.reimburseType" size="medium">-->
@@ -149,7 +140,7 @@ layout("/layouts/platform.html"){
placeholder="请输入联系方式"></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="余额" v-if="formData.reimburseFundSource">
<el-descriptions-item label="余额" v-if="formData.reimburseFundSource && formData.reimburseFundSource !== 'UNION_REIMBURSE_FUND_SOURCE_3'">
<el-form-item label="余额">
<el-input :value="canViewFundBalance ? fundBalanceText : '******'" readonly></el-input>
</el-form-item>
@@ -273,7 +264,34 @@ layout("/layouts/platform.html"){
</el-date-picker>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="" :span="2"
<el-descriptions-item label="报销金额" v-if="isManualReimburseAmount">
<el-form-item label="报销金额" prop="money">
<el-input v-model="formData.money" placeholder="请输入报销金额,最多两位小数"></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="所属协会"
v-if="formData.reimburseFundSource === 'UNION_REIMBURSE_FUND_SOURCE_3'">
<el-form-item label="所属协会" prop="clubId">
<el-select clearable
placeholder="请选择协会"
style="width: 100%;"
v-model="formData.clubId"
@change="clubChange">
<el-option
:key="item.id"
:label="item.clubName"
:value="item.id"
v-for="item in clubOptions">
</el-option>
</el-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="余额" v-if="formData.reimburseFundSource === 'UNION_REIMBURSE_FUND_SOURCE_3'">
<el-form-item label="余额">
<el-input :value="canViewFundBalance ? fundBalanceText : '******'" readonly></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="" :span="isManualReimburseAmount ? 1 : 2"
v-if="formData.reimburseProject !== 'UNION_REIMBURSE_PROJECT_1' && formData.reimburseFundSource !== 'UNION_REIMBURSE_FUND_SOURCE_3'">
</el-descriptions-item>
@@ -399,7 +417,7 @@ layout("/layouts/platform.html"){
</el-form-item>
</el-descriptions-item>
<el-descriptions-item :span="3">
<el-descriptions-item :span="formData.reimburseFundSource === 'UNION_REIMBURSE_FUND_SOURCE_3' ? 2 : 3">
<template slot="label">
附件
</template>
@@ -438,7 +456,7 @@ layout("/layouts/platform.html"){
<el-col :span="12">
<el-form-item label="报销金额" prop="money">
<div class="invoice-field-title">报销金额</div>
<el-input v-model="formData.money" placeholder="有发票按发票汇总,无发票按支付信息汇总" readonly></el-input>
<el-input v-model="formData.money" :placeholder="isManualReimburseAmount ? '请在申请基本信息填写报销金额' : '有发票按发票汇总,无发票按支付信息汇总'" readonly></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
@@ -587,7 +605,7 @@ layout("/layouts/platform.html"){
<div>
<div class="invoice-toolbar-title">发票明细列表</div>
<div class="invoice-summary-tip">
发票选填;无发票时按支付信息汇总报销金额。上传后须通过验真,每条明细对应一张发票图片或单页 PDF。
{{ isManualReimburseAmount ? '发票选填;有发票时报销金额不得超过发票总额,无发票仍可提交。' : '发票选填;无发票时按支付信息汇总报销金额。' }}上传后须通过验真,每条明细对应一张发票图片或单页 PDF。
</div>
</div>
<div>
@@ -821,7 +839,13 @@ layout("/layouts/platform.html"){
// invoiceNumber: [{required: true, message: "请填写发票张数", trigger: ["change", "blur"]}],
// invoice: [{required: true, message: "请填写发票号码", trigger: ["change", "blur"]}],
activityName: [{required: true, message: "请填写活动名称", trigger: ["change", "blur"]}],
money: [{required: true, message: "请填写报销金额", trigger: ["change", "blur"]}],
money: [{required: true, message: "请填写报销金额", trigger: ["change", "blur"]}, {
validator: (rule, value, callback) => {
const message = this.getReimburseAmountMessage()
callback(message ? new Error(message) : undefined)
},
trigger: ["change", "blur"]
}],
condolenceRelationship: [{required: true, message: "与被慰问人关系", trigger: ["change", "blur"]}],
marryTime: [{required: true, message: "结婚时间", trigger: ["change", "blur"]}],
fertilityTime: [{required: true, message: "生育时间", trigger: ["change", "blur"]}],
@@ -835,8 +859,8 @@ layout("/layouts/platform.html"){
],
files: [{
validator: (rule, value, callback) => {
// 慰问项目不展示附件上传,提交时无需校验附件。
if (this.formData.reimburseProject === "UNION_REIMBURSE_PROJECT_1") {
// 慰问项目无需校验附件,文体、日常和社团活动附件选填。
if (["UNION_REIMBURSE_PROJECT_1", "UNION_REIMBURSE_PROJECT_2", "UNION_REIMBURSE_PROJECT_3", "UNION_REIMBURSE_PROJECT_5"].includes(this.formData.reimburseProject)) {
callback()
return
}
@@ -910,6 +934,16 @@ layout("/layouts/platform.html"){
}
},
computed: {
isManualReimburseAmount() {
return ["UNION_REIMBURSE_PROJECT_2", "UNION_REIMBURSE_PROJECT_3", "UNION_REIMBURSE_PROJECT_4"]
.includes(this.formData.reimburseProject)
},
// 社团经费固定对应社团活动,复用文体活动的基本信息和发票表单。
availableReimburseProjects() {
const clubFund = this.formData.reimburseFundSource === "UNION_REIMBURSE_FUND_SOURCE_3"
return this.reimburseProjectList.filter(item =>
clubFund ? item.code === "UNION_REIMBURSE_PROJECT_5" : item.code !== "UNION_REIMBURSE_PROJECT_5")
},
// 按选中类型的业务编码控制字段,类型列表异步加载及旧申请回显后自动重新计算。
condolenceTypeCode() {
if (this.formData.reimburseProject !== "UNION_REIMBURSE_PROJECT_1") return ""
@@ -917,9 +951,11 @@ layout("/layouts/platform.html"){
const selectedType = this.typeOptions.find(item => item.id === typeId)
return selectedType ? selectedType.code : ""
},
// 仅系统管理员和校工会管理员可查看真实余额,其他账号统一显示余额遮罩。
// 社团经费按菜单授权查看年度剩余额度,其他经费沿用原余额可见范围。
canViewFundBalance() {
return this.$auth.hasRoleOr(["SYSADMIN", "SCHOOL_UNION_ADMIN"])
|| (this.formData.reimburseFundSource === "UNION_REIMBURSE_FUND_SOURCE_3"
&& this.$auth.hasPermission("unionReimburse.club"))
},
showInvoiceTab() {
return !!this.formData.reimburseProject
@@ -1549,6 +1585,27 @@ layout("/layouts/platform.html"){
this.rebuildInvoiceSummary()
})
},
// 手填金额按分比较票面总额;未上传发票时仍允许申请。
getReimburseAmountMessage() {
if (!this.isManualReimburseAmount) return ""
const value = String(this.formData.money == null ? "" : this.formData.money)
if (!value) return "请填写报销金额"
const cents = this.moneyToCents(value)
if (!/^\d+(\.\d{1,2})?$/.test(value) || cents === null || cents <= 0) {
return "报销金额必须为正数,且最多两位小数"
}
const invoices = this.formData.invoiceDetails || []
let invoiceCents = 0
for (let i = 0; i < invoices.length; i++) {
const amount = this.moneyToCents(invoices[i] && invoices[i].invoiceAmount)
if (amount === null || amount <= 0) return "请填写正确的发票金额"
invoiceCents += amount
}
if (invoices.length && cents > invoiceCents) {
return "报销金额不能超过发票总额(" + (invoiceCents / 100).toFixed(2) + "元)"
}
return ""
},
rebuildInvoiceSummary() {
let totalMoney = 0
let fileCount = 0
@@ -1573,10 +1630,18 @@ layout("/layouts/platform.html"){
})
totalMoney = paymentCents / 100
}
this.$set(this.formData, "money", totalMoney ? totalMoney.toFixed(2) : "0.00")
// 三类活动保留手填申请金额,发票及支付明细变化只更新张数。
if (!this.isManualReimburseAmount) {
this.$set(this.formData, "money", totalMoney ? totalMoney.toFixed(2) : "0.00")
}
this.$set(this.formData, "invoiceNumber", String(fileCount))
},
validateInvoiceDetailsBeforeSubmit() {
const amountMessage = this.getReimburseAmountMessage()
if (amountMessage) {
this.$message.warning(amountMessage)
return false
}
const invoiceDetails = this.formData.invoiceDetails || []
// 发票可不上传;已经添加的发票仍须填写完整并沿用验真流程。
for (let i = 0; i < invoiceDetails.length; i++) {
@@ -1904,6 +1969,7 @@ layout("/layouts/platform.html"){
},
// 根据报销项目获取对应的附件说明
getFileDescByProject(projectCode) {
if (projectCode === "UNION_REIMBURSE_PROJECT_5") return "社团活动附件说明"
if (!this.descOptions || !Array.isArray(this.descOptions)) {
return '';
}
@@ -1918,21 +1984,20 @@ layout("/layouts/platform.html"){
const project = this.descOptions.find(item => item.reimburseProject === projectCode);
return project ? project.projectTip : '';
},
// 新建申请默认优先选择分工会经费;若当前用户没有对应权限,则按页面可见权限回退到其它经费来源。
// 默认来源与角色允许的按钮一致,社团负责人默认进入社团活动申请。
getDefaultFundSource() {
if (this.$auth.hasPermission("unionReimburse.fgh")) {
return "UNION_REIMBURSE_FUND_SOURCE_2"
}
if (this.$auth.hasPermission("unionReimburse.xgh")) {
return "UNION_REIMBURSE_FUND_SOURCE_1"
}
if (this.$auth.hasPermission("unionReimburse.club")) {
return "UNION_REIMBURSE_FUND_SOURCE_3"
}
return ""
const option = this.budgetTypeOption.find(item => item.code === "UNION_REIMBURSE_FUND_SOURCE_2")
|| this.budgetTypeOption.find(item => item.code === "UNION_REIMBURSE_FUND_SOURCE_3")
|| (this.$auth.hasPermission("unionReimburse.xgh") && this.budgetTypeOption[0])
return option ? option.code : ""
},
selectFundSource(code) {
this.$set(this.formData, "reimburseFundSource", code)
if (code === "UNION_REIMBURSE_FUND_SOURCE_3") {
this.$set(this.formData, "reimburseProject", "UNION_REIMBURSE_PROJECT_5")
} else if (this.formData.reimburseProject === "UNION_REIMBURSE_PROJECT_5") {
this.$set(this.formData, "reimburseProject", "UNION_REIMBURSE_PROJECT_2")
}
if (code !== "UNION_REIMBURSE_FUND_SOURCE_3") {
this.$set(this.formData, "clubId", null)
this.$set(this.formData, "clubName", null)
@@ -1986,9 +2051,13 @@ layout("/layouts/platform.html"){
this.fundBalanceText = "查询中..."
this.$axios.post("/platform/unionReimburse/apply/getBudgetBalance", params)
.then(response => {
// 切换社团后忽略旧请求,避免显示上一社团的余额。
if (fundSource !== this.formData.reimburseFundSource
|| (params.clubId && params.clubId !== this.formData.clubId)) return
if (response.code === 0) {
this.$set(this.formData, "fundBalance", response.data || 0)
this.fundBalanceText = (response.data || 0).toString()
this.fundBalanceText = fundSource === "UNION_REIMBURSE_FUND_SOURCE_3"
? Number(response.data || 0).toFixed(2) : (response.data || 0).toString()
} else {
this.$set(this.formData, "fundBalance", null)
this.fundBalanceText = response.msg || "查询失败"
@@ -1996,6 +2065,8 @@ layout("/layouts/platform.html"){
}
})
.catch(error => {
if (fundSource !== this.formData.reimburseFundSource
|| (params.clubId && params.clubId !== this.formData.clubId)) return
console.error("经费余额查询失败:", error)
this.$set(this.formData, "fundBalance", null)
this.fundBalanceText = "查询失败"
@@ -2016,7 +2087,8 @@ layout("/layouts/platform.html"){
this.formData.hospitalizationEndTime
]);
}
if (this.$auth.hasRoleOr(["SCHOOL_OUTLAY_ADMIN", "SCHOOL_UNION_ADMIN"])) {
if (this.$auth.hasRoleOr(["SCHOOL_OUTLAY_ADMIN", "SCHOOL_UNION_ADMIN"])
&& !this.$auth.hasRoleOr(["BRANCH_UNION_CHAIRMAN", "BRANCH_UNION_ADMIN", "CLUB_MANAGER", "CLUB_PRESIDENT"])) {
this.budgetTypeOption.map(v => {
if (["UNION_REIMBURSE_FUND_SOURCE_1"].includes(v.code)) {
budgetTypeOption.push(v)
@@ -2030,7 +2102,7 @@ layout("/layouts/platform.html"){
}
})
}
if (this.$auth.hasRoleOr(["CLUB_MANAGER", "CLUB_PRESIDENT"])) {
if (this.$auth.hasPermission("unionReimburse.club")) {
this.budgetTypeOption.map(v => {
if (["UNION_REIMBURSE_FUND_SOURCE_3"].includes(v.code)) {
budgetTypeOption.push(v)
@@ -2039,11 +2111,22 @@ layout("/layouts/platform.html"){
}
this.budgetTypeOption = budgetTypeOption
}
// 社团经费按菜单权限过滤,默认来源与按钮可见范围保持一致。
this.budgetTypeOption = this.budgetTypeOption.filter(item =>
item.code !== "UNION_REIMBURSE_FUND_SOURCE_3"
|| this.$auth.hasPermission("unionReimburse.club"))
this.reimburseProjectList = await this.$businessTool.getDictOptions("UNION_REIMBURSE_PROJECT")
if (this.budgetTypeOption.some(item => item.code === "UNION_REIMBURSE_FUND_SOURCE_3")) {
const clubs = await this.$axios.post("/platform/unionReimburse/apply/listReimburseClubs")
this.clubOptions = clubs.data || []
}
if (this.bizId) {
this.$axios.post("/platform/unionReimburse/apply/info", {id: this.bizId}).then(async (res) => {
if (res.code === 0) {
this.formData = this.ensureFormData(res.data)
if (this.formData.reimburseFundSource === "UNION_REIMBURSE_FUND_SOURCE_3") {
this.$set(this.formData, "reimburseProject", "UNION_REIMBURSE_PROJECT_5")
}
this.appendCurrentPayerOption()
this.formData.paymentDetails.forEach(row => {
if (row.payer) {
@@ -2066,7 +2149,8 @@ layout("/layouts/platform.html"){
unionId: union?.id,
unionName: union?.name,
// reimburseType: "UNION_REIMBURSE_TYPE_1",
reimburseProject: 'UNION_REIMBURSE_PROJECT_1',
reimburseProject: this.getDefaultFundSource() === "UNION_REIMBURSE_FUND_SOURCE_3"
? "UNION_REIMBURSE_PROJECT_5" : "UNION_REIMBURSE_PROJECT_1",
reimburseFundSource: this.getDefaultFundSource(),
paymentWay: 'UNION_REIMBURSE_PAYMENT_WAY_2',
paymentDetails: [this.buildDefaultPaymentDetail({
@@ -2076,7 +2160,7 @@ layout("/layouts/platform.html"){
userId: id,
})
if (this.formData.reimburseFundSource) {
this.reimburseFundSourceChange()
this.selectFundSource(this.formData.reimburseFundSource)
}
}
}
@@ -2085,17 +2169,7 @@ layout("/layouts/platform.html"){
this.init()
this.queryFileDesc()
this.queryCondolenceType()
//社团查询
this.$businessTool.listClubByRole().then((res) => {
this.$set(this, "clubOptions", res || [])
if (res && res.length === 1 && !this.formData.clubId) {
this.$set(this.formData, "clubId", res[0].id)
this.$set(this.formData, "clubName", res[0].clubName)
}
if (this.formData.reimburseFundSource) {
this.reimburseFundSourceChange()
}
})
}
})
</script>
@@ -51,10 +51,10 @@ layout("/layouts/platform.html"){
</table-tool>
<el-table :data="tableData" @sort-change="pageOrder" style="width: 100%">
<el-table-column type="index" width="50px" label="序号" :index="indexMethod"></el-table-column>
<el-table-column prop="serialNo" label="流水号" width="120"></el-table-column>
<el-table-column prop="loginName" label="经办人工号" sortable="custom"></el-table-column>
<el-table-column prop="userName" label="姓名"></el-table-column>
<el-table-column prop="unionName" label="所属工会" sortable="custom"></el-table-column>
<el-table-column prop="unitName" label="所属单位" sortable="custom"></el-table-column>
<el-table-column prop="createTime" label="申请时间" sortable="custom">
<template slot-scope="{row}">
{{ formatCreateTime(row.createTime) }}
@@ -62,7 +62,7 @@ layout("/layouts/platform.html"){
</el-table-column>
<el-table-column prop="reimburseProject" label="报销项目" sortable="custom">
<template slot-scope="{row}">
<el-select v-if="isAdmin && row.reimburseProject !== 'UNION_REIMBURSE_PROJECT_1'"
<el-select v-if="isAdmin && row.reimburseProject !== 'UNION_REIMBURSE_PROJECT_1' && row.reimburseFundSource !== 'UNION_REIMBURSE_FUND_SOURCE_3'"
:value="row.reimburseProject"
size="mini"
style="width: 120px"
@@ -35,7 +35,7 @@ const unionReimburseInfo = {
<el-descriptions-item label="联系方式">
<span>{{ viewData.mobile }}</span>
</el-descriptions-item>
<el-descriptions-item></el-descriptions-item>
<el-descriptions-item v-if="viewData.reimburseFundSource !== 'UNION_REIMBURSE_FUND_SOURCE_3'"></el-descriptions-item>
<el-descriptions-item v-if="!viewData.reimburseFundSource"></el-descriptions-item>
<el-descriptions-item label="支付信息" :span="3">
<el-table :data="paymentInfoRows" border empty-text="暂无支付信息" style="width: 100%">
@@ -63,6 +63,7 @@ layout("/layouts/platform.html"){
<span v-else-if="row.reimburseProject === 'UNION_REIMBURSE_PROJECT_2'">文体活动</span>
<span v-else-if="row.reimburseProject === 'UNION_REIMBURSE_PROJECT_3'">日常活动</span>
<span v-else-if="row.reimburseProject === 'UNION_REIMBURSE_PROJECT_4'">专项活动</span>
<span v-else-if="row.reimburseProject === 'UNION_REIMBURSE_PROJECT_5'">社团活动</span>
<span v-else>{{ row.reimburseProject }}</span>
</template>
</el-table-column>
@@ -58,10 +58,10 @@ layout("/layouts/platform.html"){
<el-table-column v-if="!pageForm.approval" type="selection" width="55"
:reserve-selection="true" :selectable="(row) => row.stateId == 2"></el-table-column>
<el-table-column type="index" width="50px" label="序号" :index="indexMethod"></el-table-column>
<el-table-column prop="serialNo" label="流水号" width="120"></el-table-column>
<el-table-column prop="loginName" label="经办人工号" sortable="custom"></el-table-column>
<el-table-column prop="userName" label="姓名"></el-table-column>
<el-table-column prop="unionName" label="所属工会" sortable="custom"></el-table-column>
<el-table-column prop="unitName" label="所属单位" sortable="custom"></el-table-column>
<el-table-column prop="createTime" label="申请时间" sortable="custom">
<template slot-scope="{row}">
{{ formatCreateTime(row.createTime) }}
@@ -73,6 +73,7 @@ layout("/layouts/platform.html"){
<span v-else-if="row.reimburseProject === 'UNION_REIMBURSE_PROJECT_2'">文体活动</span>
<span v-else-if="row.reimburseProject === 'UNION_REIMBURSE_PROJECT_3'">日常活动</span>
<span v-else-if="row.reimburseProject === 'UNION_REIMBURSE_PROJECT_4'">专项活动</span>
<span v-else-if="row.reimburseProject === 'UNION_REIMBURSE_PROJECT_5'">社团活动</span>
<span v-else>{{ row.reimburseProject }}</span>
</template>
</el-table-column>
@@ -29,7 +29,7 @@ layout("/layouts/platform_h5.html"){
.club-apply-actions__agree .van-checkbox__icon { margin-top: 2px; }
.club-apply-actions__agree .van-checkbox__label { color: #65778e; font-size: 12px; line-height: 18px; }
.club-apply-actions__buttons { display: flex; gap: 10px; }
.club-apply-actions .van-button { flex: 1; height: 40px; border-radius: 8px; font-size: 14px; font-weight: 500; }
.club-apply-actions .van-button { flex: 1; height: 40px; border-radius: 20px; font-size: 14px; font-weight: 500; }
.club-apply-actions .van-button--info { background: var(--club-primary); border-color: var(--club-primary); }
.club-apply-actions .van-button--info.van-button--plain { color: var(--club-primary); background: #fff; }
</style>
@@ -41,7 +41,7 @@ layout("/layouts/platform_h5.html"){
<section class="club-apply-section">
<h2 class="club-apply-section__title">基本信息</h2>
<van-cell-group :border="false">
<van-field :rules="[{ required: true, message: '请选择社团' }]" v-model="formData.clubName" label="社团" placeholder="请选择社团" required is-link readonly name="clubName" @click="openClubPopup"></van-field>
<van-field :rules="[{ required: true, message: '请从社团列表选择社团后进入申请' }]" v-model="formData.clubName" label="社团" placeholder="从社团列表带入" required readonly name="clubName"></van-field>
<van-field label="姓名" :rules="[{ required: true }]" v-model="formData.userName" readonly required name="userName"></van-field>
<van-field label="工号" :rules="[{ required: true }]" v-model="formData.loginName" readonly required name="loginName"></van-field>
<van-field label="性别" :rules="[{ required: true }]" v-model="formData.sex" readonly required></van-field>
@@ -98,10 +98,6 @@ layout("/layouts/platform_h5.html"){
</div>
</div>
<van-popup v-model="showClubPopup" position="bottom" get-container="#app" @click-overlay="closeClubPopup">
<van-picker show-toolbar :columns="clubOptions.map((item) => item.clubName)" @confirm="onClubConfirm" @cancel="closeClubPopup"></van-picker>
</van-popup>
<van-dialog :value="confirmVisible" title="提示" :message="confirmMessage" show-cancel-button @confirm="confirmPendingAction" @cancel="closeConfirm"></van-dialog>
</div>
@@ -118,7 +114,6 @@ layout("/layouts/platform_h5.html"){
clubId: "", clubName: "", userName: "", loginName: "", sex: "", birthday: "", mobile: "", email: "", unionName: "", unitName: "", position: "", technicalTitle: "", education: "", academicDegree: "", sameTimeJoinOtherClubSituation: "", awardsExperience: "", avatar: "", signature: ""
},
clubOptions: [],
showClubPopup: false,
isAgree: false,
formLoading: false,
confirmVisible: false,
@@ -136,7 +131,6 @@ layout("/layouts/platform_h5.html"){
if (manager) {
manager.ensureRegistered('club-apply');
this.historyLayerUnsubscribe = manager.subscribe((layers) => {
this.$set(this, 'showClubPopup', layers.includes('club-apply-club-picker'));
this.$set(this, 'confirmVisible', layers.includes('club-apply-confirm'));
});
}
@@ -161,25 +155,6 @@ layout("/layouts/platform_h5.html"){
}
historyBack();
},
openClubPopup() {
const manager = window.h5HistoryLayerManager;
if (manager) {
manager.open('club-apply-club-picker');
return;
}
this.$set(this, 'showClubPopup', true);
},
closeClubPopup(afterClose) {
const manager = window.h5HistoryLayerManager;
if (manager) {
manager.close('club-apply-club-picker', afterClose);
return;
}
this.$set(this, 'showClubPopup', false);
if (afterClose) {
afterClose();
}
},
openConfirm(action) {
this.$set(this, 'pendingAction', action);
const manager = window.h5HistoryLayerManager;
@@ -271,19 +246,20 @@ layout("/layouts/platform_h5.html"){
}
});
},
onClubConfirm(value, index) {
const club = this.clubOptions[index];
if (club) {
this.$set(this.formData, 'clubId', club.id);
this.$set(this.formData, 'clubName', value);
this.checkApplyClub(club.id);
}
this.closeClubPopup();
},
listClub() {
this.$axios.post('/platform/club/common/listClub').then((res) => {
if (res.code === 0) {
this.$set(this, 'clubOptions', res.data || []);
// 从社团卡片进入新申请时预选社团,编辑已有申请仍使用原记录。
const clubId = GetQueryString('clubId');
if (!this.bizId && clubId) {
const club = this.clubOptions.find((item) => item.id === clubId);
if (club) {
this.$set(this.formData, 'clubId', club.id);
this.$set(this.formData, 'clubName', club.clubName);
this.checkApplyClub(club.id);
}
}
}
});
},
@@ -0,0 +1,349 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<style>
<!--#include('../common/clubUserJoin.css'){}#-->
.club-directory-page { min-height: 100vh; padding-bottom: calc(24px + env(safe-area-inset-bottom)); box-sizing: border-box; background: #f3f7fd; }
.club-directory-page .van-nav-bar { background: #fff; }
.club-directory-page .van-nav-bar__title { color: #1f2937; font-size: 16px; }
.club-directory-page .van-nav-bar .van-icon, .club-directory-page .van-nav-bar__text { color: #397fbd; }
.club-directory-content { padding: 12px 12px 0; }
.club-directory-hero { position: relative; height: 134px; overflow: hidden; border-radius: 10px; color: #fff; background: #247fe8; box-shadow: 0 8px 20px rgba(51, 117, 219, .18); }
.club-directory-hero__image { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; }
.club-directory-hero__copy { position: relative; z-index: 1; display: flex; width: 64%; height: 100%; padding-left: 16px; flex-direction: column; justify-content: center; box-sizing: border-box; background: linear-gradient(90deg, rgba(22, 112, 232, .98) 0%, rgba(22, 112, 232, .88) 52%, rgba(22, 112, 232, 0) 100%); }
.club-directory-hero__title { font-size: 25px; line-height: 34px; font-weight: 700; letter-spacing: 1px; text-shadow: 0 2px 6px rgba(17, 86, 182, .18); }
.club-directory-hero__subtitle { margin-top: 4px; font-size: 13px; line-height: 20px; letter-spacing: .5px; opacity: .95; }
.club-directory-sticky { padding: 10px 0 1px; background: #f3f7fd; }
.club-directory-page .van-sticky--fixed .club-directory-sticky { padding-right: 12px; padding-left: 12px; box-sizing: border-box; }
.club-directory-search { margin: 0 0 8px; padding: 0; overflow: hidden; border-radius: 12px; background: #fff; box-shadow: 0 5px 16px rgba(43, 73, 112, .1); }
.club-directory-search .van-search__content { height: 42px; padding-left: 12px; align-items: center; border-radius: 12px; background: #fff; }
.club-directory-search .van-field__control { color: #263548; font-size: 14px; }
.club-directory-search .van-field__control::placeholder { color: #a7b0bf; }
.club-directory-tabs { margin: 0 0 10px; overflow: hidden; border-radius: 10px; background: #fff; box-shadow: 0 5px 16px rgba(43, 73, 112, .08); }
.club-directory-tabs .van-tab { color: #66758a; font-size: 13px; }
.club-directory-tabs .van-tab--active { color: #1989fa; }
.club-directory-list { padding: 0; }
.club-directory-page .table-list-container { margin-top: 0; }
.club-directory-page .table-list-container .table-list-item { margin-bottom: 10px; padding: 0; border: 0; border-radius: 10px; background: #fff; box-shadow: 0 7px 20px rgba(55, 85, 126, .08); }
.club-directory-card { width: 100%; }
.club-directory-cover { width: 100%; height: 150px; overflow: hidden; background: #edf4fc; }
.club-directory-cover .van-image, .club-directory-detail-cover .van-image { display: block; width: 100%; height: 100%; }
.club-directory-image-empty { display: flex; width: 100%; height: 100%; flex-direction: column; row-gap: 8px; align-items: center; justify-content: center; background: #edf4fc; color: #8da1ba; font-size: 13px; }
.club-directory-image-empty .van-icon { font-size: 34px; }
.club-directory-info { min-width: 0; padding: 12px 14px 14px; }
.club-directory-name { margin: 0; color: #303846; font-size: 14px; font-weight: 400; line-height: 21px; overflow-wrap: anywhere; }
.club-directory-meta { margin: 4px 0 0; color: #98a2b2; font-size: 12px; line-height: 17px; }
.club-directory-tags { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 9px; }
.club-directory-info .club-directory-tags { margin-top: 10px; padding-top: 9px; border-top: 1px solid #edf1f6; }
.club-directory-tag { display: inline-flex; padding: 3px 8px; align-items: center; border-radius: 4px; color: #397de0; background: #edf5ff; font-size: 11px; line-height: 16px; }
.club-directory-tag-label { margin-right: 4px; color: #8aa7cf; }
.club-directory-page .table-list-container .table-list-item .item-actions { margin: 0 14px; padding: 10px 0 14px; border-top-color: #edf1f6; }
.club-directory-page .item-actions .van-button { height: 28px; padding: 0 12px; border-radius: 14px; font-size: 12px; }
.club-directory-page .item-actions .club-directory-card-button { height: 32px; padding: 0 14px; border: 0; border-radius: 16px; color: #397de0; background-color: #edf5ff; font-size: 13px; }
.club-directory-page .item-actions .club-directory-card-button i { margin-right: 5px; font-size: 13px; }
.club-directory-page .item-actions .club-directory-card-button--join { width: 108px; color: #fff; background-color: #1989fa; box-shadow: 0 5px 12px rgba(25, 137, 250, .18); }
.club-directory-page .item-actions .club-directory-card-button--exit { width: 108px; color: #fff; background-color: #ee0a24; box-shadow: 0 5px 12px rgba(238, 10, 36, .18); }
.club-directory-loading { padding: 50px 0; text-align: center; }
.club-directory-detail-cover { height: 230px; flex: none; background: #f2f7fc; }
.club-directory-page .club-user-join-detail__content { position: relative; margin-top: -16px; padding: 0 0 18px; }
.club-directory-detail-heading { padding: 16px 14px 12px; }
.club-directory-detail-heading .club-directory-name { margin-bottom: 6px; font-size: 18px; font-weight: 600; }
.club-directory-detail-meta { display: flex; align-items: center; gap: 5px; margin-top: 6px; color: #8b9bb0; font-size: 12px; line-height: 20px; }
.club-directory-detail-meta .van-icon { color: #1989fa; font-size: 14px; }
.club-directory-detail-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0 14px; padding: 16px 0; border-top: 1px solid #eef2f7; }
.club-directory-detail-stat { min-width: 0; padding: 0 8px; text-align: center; }
.club-directory-detail-stat + .club-directory-detail-stat { border-left: 1px solid #eef2f7; }
.club-directory-detail-stat span { display: block; margin-bottom: 6px; color: #8b9bb0; font-size: 12px; line-height: 18px; }
.club-directory-detail-stat strong { display: block; color: #40526a; font-size: 14px; font-weight: 400; line-height: 20px; overflow-wrap: anywhere; }
.club-directory-page .club-user-join-card { border-radius: 16px; }
.club-directory-page .club-user-join-info-card .van-cell__value { font-size: 13px; }
.club-directory-page .club-user-join-info-long-cell img { max-width: 100%; height: auto; }
.club-directory-material-preview { position: relative; height: 72px; overflow: hidden; }
.club-directory-material-preview.is-expanded { height: auto; overflow: visible; }
.club-directory-material-preview.is-truncated::after { position: absolute; right: 0; bottom: 0; padding-left: 18px; background: linear-gradient(90deg, transparent, #fff 18px); content: '…'; }
.club-directory-material-content { display: flow-root; overflow-wrap: anywhere; }
.club-directory-material-rich { font-size: 16px; line-height: 1.6; }
.club-directory-material-rich, .club-directory-material-rich * { max-width: 100% !important; box-sizing: border-box; }
.club-directory-material-rich p, .club-directory-material-rich h1, .club-directory-material-rich h2, .club-directory-material-rich h3, .club-directory-material-rich h4, .club-directory-material-rich h5, .club-directory-material-rich h6 { margin: 0; }
.club-directory-material-rich img, .club-directory-material-rich video { height: auto !important; }
.club-directory-material-rich table { width: 100% !important; table-layout: fixed; }
.club-directory-material-toggle { height: 24px; margin-top: 4px; text-align: right; }
.club-directory-material-toggle button { padding: 0; border: 0; background: transparent; color: #1989fa; font-size: 12px; line-height: 24px; cursor: pointer; }
.club-directory-detail-actions { display: flex; flex: none; gap: 12px; padding: 10px 15px calc(10px + env(safe-area-inset-bottom)); background: #fff; border-top: 1px solid #edf2f8; }
.club-directory-detail-actions .van-button { flex: 1; height: 40px; border-radius: 20px; }
.club-directory-detail-actions .van-button--info { border-color: #1989fa; background-color: #1989fa; }
.club-directory-detail-actions .van-button--info.van-button--plain { color: #1989fa; background-color: #fff; }
.club-directory-detail-actions .van-button--danger { color: #fff; border-color: #ee0a24; background-color: #ee0a24; }
.club-directory-detail-actions .van-button--danger i { margin-right: 5px; }
.club-directory-page .empty-state { padding: 0; }
.club-directory-empty { display: flex; min-height: 260px; padding: 26px 18px 32px; align-items: center; justify-content: center; flex-direction: column; box-sizing: border-box; text-align: center; }
.club-directory-empty img { display: block; width: 82%; max-width: 280px; height: auto; border-radius: 12px; object-fit: contain; }
.club-directory-empty__title { margin-top: 14px; color: #50627a; font-size: 15px; line-height: 22px; font-weight: 600; }
.club-directory-empty__hint { margin-top: 5px; color: #9aa7b8; font-size: 12px; line-height: 18px; }
.club-directory-page .van-list__finished-text, .club-directory-page .van-list__loading { color: #9aa7b8; font-size: 13px; line-height: 44px; }
</style>
<div id="app" v-cloak class="club-directory-page">
<van-nav-bar title="社团列表" left-text="返回" left-arrow fixed placeholder @click-left="onNavBack"></van-nav-bar>
<div class="club-directory-content">
<section class="club-directory-hero">
<img class="club-directory-hero__image" src="/assets/mobile/img/club/club-hero-v5.png" alt="社团入会插画">
<div class="club-directory-hero__copy">
<div class="club-directory-hero__title">社团入会</div>
<div class="club-directory-hero__subtitle">以兴趣相聚 · 让生活精彩</div>
</div>
</section>
<van-sticky :offset-top="46">
<div class="club-directory-sticky">
<van-search v-model="pageForm.clubName" class="club-directory-search" placeholder="请输入社团名称搜索" shape="round" clearable @search="doSearch" @clear="doSearch"></van-search>
<van-tabs v-model="activeTab" class="club-directory-tabs" color="#1989fa" @change="joinChange">
<van-tab :title="'可加入社团(' + noJoinCount + ')'" name="available"></van-tab>
<van-tab :title="'已加入社团(' + hasJoinCount + ')'" name="joined"></van-tab>
</van-tabs>
</div>
</van-sticky>
<main class="club-directory-list">
<table-list api="/platform/club/join/list/pageData" :page_form.sync="pageForm" ref="tableListRef" :show_loading_overlay="false" @ready="doSearch" @loading-change="onLoadingChange">
<template #header><div></div></template>
<template v-slot="{row}">
<div v-if="row" class="club-directory-card">
<div class="club-directory-cover">
<van-image v-if="row.thumbnail" :src="row.thumbnail" :alt="row.clubName + '缩略图'" fit="cover"><template #error><div class="club-directory-image-empty"><van-icon name="photo-o"></van-icon><span>暂无图片</span></div></template></van-image>
<div v-else class="club-directory-image-empty"><van-icon name="photo-o"></van-icon><span>暂无图片</span></div>
</div>
<div class="club-directory-info">
<h2 class="club-directory-name">{{ row.clubName }}</h2>
<p class="club-directory-meta">成立时间:{{ row.foundTime || '--' }}</p>
<div class="club-directory-tags">
<span class="club-directory-tag"><span class="club-directory-tag-label">会长</span>{{ row.clubLeader || '--' }}</span>
<span class="club-directory-tag"><span class="club-directory-tag-label">社团类型</span>{{ formatClubType(row.clubType) }}</span>
<span class="club-directory-tag"><span class="club-directory-tag-label">会费标准</span>{{ row.due == null || row.due === '' ? '--' : row.due + ' 元/年' }}</span>
</div>
</div>
</div>
</template>
<template #actions="{row}">
<template v-if="row">
<van-button class="club-directory-card-button" native-type="button" size="small" type="default" @click="onView(row)"><i class="fa fa-eye"></i>查看</van-button>
<van-button v-if="!pageForm.hasJoin" class="club-directory-card-button club-directory-card-button--join" native-type="button" size="small" type="default" :loading="formLoading" @click="onJoin(row)"><i class="fa fa-sign-in"></i>申请加入</van-button>
<van-button v-else class="club-directory-card-button club-directory-card-button--exit" native-type="button" size="small" type="danger" :disabled="formLoading" @click="onExit(row)"><i class="fa fa-sign-out"></i>申请退出</van-button>
</template>
</template>
<template #empty>
<div v-if="listLoading" class="club-directory-loading"><van-loading size="24px" vertical>加载中</van-loading></div>
<div v-else class="club-directory-empty"><img src="/assets/mobile/img/club/club-approval-empty.png" alt="社团空状态插画"><div class="club-directory-empty__title">暂无社团</div><div class="club-directory-empty__hint">当前筛选条件下暂时没有可展示的社团</div></div>
</template>
</table-list>
</main>
</div>
<van-popup v-model="clubDetailVisible" position="right" :style="{ width: '100%', height: '100%' }" get-container="#app" class="club-user-join-popup" :close-on-click-overlay="false" @opened="observeDetailMaterials">
<div class="club-user-join-detail">
<van-nav-bar title="社团详情" left-text="返回" left-arrow fixed @click-left="closeClubDetail"></van-nav-bar>
<div class="club-user-join-detail__scroll">
<div class="club-directory-detail-cover">
<van-image v-if="clubDetail.thumbnail" :src="clubDetail.thumbnail" :alt="clubDetail.clubName + '缩略图'" fit="cover"><template #error><span class="club-directory-image-empty">暂无图片</span></template></van-image>
<div v-else class="club-directory-image-empty">暂无图片</div>
</div>
<main class="club-user-join-detail__content">
<section class="club-user-join-card club-user-join-info-card">
<div class="club-directory-detail-heading">
<h2 class="club-directory-name">{{ clubDetail.clubName }}</h2>
<div class="club-directory-detail-meta">成立时间:{{ clubDetail.foundTime || '--' }}</span></div>
</div>
<div class="club-directory-detail-stats">
<div class="club-directory-detail-stat"><span>社团类型</span><strong>{{ formatClubType(clubDetail.clubType) }}</strong></div>
<div class="club-directory-detail-stat"><span>当前人数</span><strong>{{ clubDetail.currentPeopleNum || 0 }} 人</strong></div>
<div class="club-directory-detail-stat"><span>会费标准</span><strong>{{ clubDetail.due == null || clubDetail.due === '' ? '--' : clubDetail.due + ' 元/年' }}</strong></div>
</div>
</section>
<section class="club-user-join-card club-user-join-info-card">
<h3 class="club-user-join-card__title">人员及联系信息</h3>
<van-cell-group :border="false">
<van-cell title="会长">{{ clubDetail.clubLeader || '--' }}</van-cell>
<van-cell title="秘书长">{{ clubDetail.clubSecretary || '--' }}</van-cell>
<van-cell title="发起人">{{ clubDetail.sponsorName || '--' }}</van-cell>
<van-cell title="报名联系人">{{ clubDetail.concatPersonName || '--' }}</van-cell>
<van-cell title="联系人电话">{{ clubDetail.concatPersonMobile || '--' }}</van-cell>
<van-cell title="联系人邮箱">{{ clubDetail.concatPersonEmail || '--' }}</van-cell>
</van-cell-group>
</section>
<section class="club-user-join-card club-user-join-info-card">
<h3 class="club-user-join-card__title">社团信息</h3>
<van-cell-group :border="false">
<van-cell v-for="section in detailSections" :key="section.key" :title="section.title" class="club-user-join-info-long-cell">
<div class="club-directory-material-preview" :class="{ 'is-expanded': expandedMaterials[section.key], 'is-truncated': overflowingMaterials[section.key] && !expandedMaterials[section.key] }">
<div ref="detailMaterials" :data-material-key="section.key" class="club-directory-material-content">
<template v-if="section.files">
<file-preview v-if="clubDetail[section.key] && clubDetail[section.key].length" :files="clubDetail[section.key]" complete_result></file-preview><span v-else>暂无</span>
</template>
<div v-else-if="clubDetail[section.key]" class="club-directory-material-rich" v-html="clubDetail[section.key]" v-mobile-rich-text="clubDetail[section.key]"></div><span v-else>暂无</span>
</div>
</div>
<div class="club-directory-material-toggle">
<button v-if="overflowingMaterials[section.key]" type="button" :aria-expanded="!!expandedMaterials[section.key]" @click="$set(expandedMaterials, section.key, !expandedMaterials[section.key])">{{ expandedMaterials[section.key] ? '收起' : '展开' }}<van-icon :name="expandedMaterials[section.key] ? 'arrow-up' : 'arrow-down'"></van-icon></button>
</div>
</van-cell>
</van-cell-group>
</section>
</main>
</div>
<div class="club-directory-detail-actions">
<van-button native-type="button" type="info" plain @click="closeClubDetail">返回列表</van-button>
<van-button v-if="!pageForm.hasJoin" native-type="button" type="info" :loading="formLoading" @click="onJoin(clubDetail)">申请加入</van-button>
<van-button v-else native-type="button" type="danger" :disabled="formLoading" @click="onExit(clubDetail)"><i class="fa fa-sign-out"></i>申请退出</van-button>
</div>
</div>
</van-popup>
<van-dialog :value="confirmVisible" title="提示" :message="confirmMessage" show-cancel-button :confirm-button-loading="formLoading" @confirm="confirmExit" @cancel="closeConfirm"></van-dialog>
</div>
<script nonce="${cspNonce!}">
// 仅适配预览字号,保留原始富文本;先读取全部字号,避免父元素缩放后影响子元素的相对字号。
function adaptClubMobileRichText(element, binding) {
if (element._clubMobileRichText === binding.value) return;
element.style.removeProperty('font-size');
element.style.removeProperty('line-height');
const sizes = [element, ...element.querySelectorAll('*')].map((node) => {
const style = window.getComputedStyle(node);
const fontSize = parseFloat(style.fontSize) || 16;
const lineHeight = parseFloat(style.lineHeight) / fontSize || 1.6;
return {node, fontSize, lineHeight};
});
sizes.forEach(({node, fontSize, lineHeight}) => {
// 正文 16px 映射为 14px,大字号适度压缩层级,限制在移动端可读的 10~32px 范围。
const mobileSize = Math.min(32, Math.max(10, 14 * Math.pow(fontSize / 16, 0.75)));
node.style.setProperty('font-size', mobileSize.toFixed(2) + 'px', 'important');
node.style.setProperty('line-height', (mobileSize * Math.min(1.8, Math.max(1.3, lineHeight))).toFixed(2) + 'px', 'important');
});
element._clubMobileRichText = binding.value;
}
new Vue({
el: "#app",
store,
dicts: ['CLUB_REGISTER_TYPE'],
directives: {
'mobile-rich-text': {inserted: adaptClubMobileRichText, componentUpdated: adaptClubMobileRichText}
},
data() {
return {
pageForm: {pageNumber: 1, pageSize: 10, totalCount: 0, clubName: '', hasJoin: false, pageOrderName: 'clubCode', pageOrderBy: 'ascending'},
activeTab: 'available', hasJoinCount: 0, noJoinCount: 0, listLoading: true, formLoading: false,
detailSections: [{key: 'introduce', title: '社团介绍'}, {key: 'purpose', title: '社团宗旨'}, {key: 'rulesFile', title: '社团章程', files: true}, {key: 'yearPlanFile', title: '年度活动计划', files: true}],
expandedMaterials: {}, overflowingMaterials: {},
clubDetailVisible: false, clubDetail: {}, confirmVisible: false, currentRow: null, historyLayerUnsubscribe: null
}
},
computed: {
confirmMessage() { return this.currentRow ? '您确定要申请退出“' + this.currentRow.clubName + '”吗?' : '您确定要申请退出该社团吗?'; }
},
created() {
// 详情和退会确认分别入栈,手机返回时只关闭当前弹层。
const manager = window.h5HistoryLayerManager;
if (manager) {
manager.ensureRegistered('club-directory');
this.historyLayerUnsubscribe = manager.subscribe((layers) => {
this.clubDetailVisible = layers.includes('club-directory-detail');
this.confirmVisible = layers.includes('club-directory-confirm');
});
}
},
beforeDestroy() {
if (this.detailMaterialObserver) this.detailMaterialObserver.disconnect();
if (this.historyLayerUnsubscribe) this.historyLayerUnsubscribe();
const manager = window.h5HistoryLayerManager;
if (manager) manager.unregister('club-directory');
},
methods: {
observeDetailMaterials() {
if (this.detailMaterialObserver) this.detailMaterialObserver.disconnect();
this.$nextTick(() => {
// 富文本图片和附件异步渲染后高度会变化,按实际高度决定是否显示展开入口。
this.detailMaterialObserver = new ResizeObserver((entries) => {
entries.forEach((entry) => {
this.$set(this.overflowingMaterials, entry.target.dataset.materialKey, entry.target.scrollHeight > 72);
});
});
(this.$refs.detailMaterials || []).forEach((element) => this.detailMaterialObserver.observe(element));
});
},
formatClubType(value) {
const option = (this.dict.type.CLUB_REGISTER_TYPE || []).find((item) => String(item.code) === String(value));
return option ? option.name : '--';
},
onNavBack() { const manager = window.h5HistoryLayerManager; if (manager && manager.stack.length) { manager.close(); return; } historyBack(); },
onLoadingChange(state) { this.listLoading = state.loading; },
joinChange(name) { this.$set(this.pageForm, 'hasJoin', name === 'joined'); this.doSearch(); },
doSearch() {
this.$nextTick(() => {
this.$set(this.pageForm, 'pageNumber', 1);
this.$set(this.pageForm, 'totalCount', 0);
this.$refs.tableListRef.doSearch();
});
this.$axios.post('/platform/club/join/list/getCount').then((res) => {
if (res.code === 0) { this.hasJoinCount = res.data.hasJoinCount; this.noJoinCount = res.data.noJoinCount; }
});
},
onView(row) {
this.$axios.post('/platform/club/join/list/findOne', {id: row.id}).then((res) => {
if (res.code !== 0) { this.$toast(res.msg); return; }
this.clubDetail = res.data;
this.expandedMaterials = {};
this.overflowingMaterials = {};
const manager = window.h5HistoryLayerManager;
if (manager) { manager.open('club-directory-detail'); return; }
this.clubDetailVisible = true;
});
},
closeClubDetail() {
const manager = window.h5HistoryLayerManager;
if (manager) { manager.close('club-directory-detail'); return; }
this.clubDetailVisible = false;
},
onJoin(row) {
if (this.formLoading) return;
this.formLoading = true;
this.$axios.post('/platform/club/join/apply/checkApplyClub', {clubId: row.id}).then((res) => {
if (res.code === 0) this.goToPage('/platform/club/join/apply/h5?clubId=' + encodeURIComponent(row.id));
else this.$toast(res.msg);
}).finally(() => { this.formLoading = false; });
},
onExit(row) {
if (this.formLoading) return;
this.currentRow = row;
const manager = window.h5HistoryLayerManager;
if (manager) { manager.open('club-directory-confirm'); return; }
this.confirmVisible = true;
},
closeConfirm(afterClose) {
const manager = window.h5HistoryLayerManager;
if (manager) { manager.close('club-directory-confirm', afterClose); return; }
this.confirmVisible = false;
if (typeof afterClose === 'function') afterClose();
},
confirmExit() {
if (this.formLoading || !this.currentRow) return;
const row = this.currentRow;
this.formLoading = true;
this.closeConfirm(() => {
// 列表返回 clubId,详情返回社团 id;统一按退会接口要求传递 clubId。
this.$axios.post('/platform/club/join/apply/submit', {data: JSON.stringify({clubId: row.clubId || row.id}), mode: false}).then((res) => {
this.$toast(res.msg);
if (res.code === 0) this.goToPage('/platform/club/join/mine/h5');
}).finally(() => { this.formLoading = false; });
});
},
goToPage(url) {
const manager = window.h5HistoryLayerManager;
if (manager) { manager.closeAllAndNavigate(url); return; }
this.$pjaxReplace(url);
}
}
})
</script>
<!--#
}
#-->
@@ -70,6 +70,10 @@ layout("/layouts/platform_h5.html"){
<section class="club-user-join-card club-user-join-info-card">
<h3 class="club-user-join-card__title">社团基础信息</h3>
<van-cell-group :border="false">
<van-cell title="社团缩略图" class="club-user-join-info-long-cell">
<van-image v-if="clubDetail.thumbnail" :src="clubDetail.thumbnail" width="100%" height="180" fit="contain"><template #error>暂无图片</template></van-image>
<span v-else>暂无图片</span>
</van-cell>
<van-cell title="社团名称">{{ clubDetail.clubName || '--' }}</van-cell>
<van-cell title="成立时间">{{ clubDetail.foundTime || '--' }}</van-cell>
<van-cell title="社团编码">{{ clubDetail.clubCode || '--' }}</van-cell>
@@ -3,8 +3,52 @@ layout("/layouts/platform_h5.html"){
#-->
<style>
.health-checkup-cards .table-list-item .img-container {
border-radius: 10px;
overflow: hidden;
}
.health-checkup-cards .table-list-item .img-container img {
border-radius: 10px;
}
/* 仅调整体检卡片,避免窄屏下时间和标签被通用图片区域高度截断。 */
.health-checkup-cards .table-list-content {
max-height: none !important;
overflow: visible !important;
}
.health-checkup-card-title {
margin-bottom: 10px;
font-size: 15px;
line-height: 22px;
font-weight: 600;
color: #263548;
overflow-wrap: anywhere;
}
.health-checkup-card-details {
width: 100%;
min-width: 0;
}
.health-checkup-card-time {
display: flex;
gap: 6px;
margin-bottom: 6px;
color: #56657a;
font-size: 12px;
line-height: 18px;
}
.health-checkup-card-time > span:first-child {
flex-shrink: 0;
color: #98a3b3;
}
.health-checkup-card-time > span:last-child {
min-width: 0;
overflow-wrap: anywhere;
}
.health-checkup-card-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
</style>
<div id="app">
@@ -26,15 +70,27 @@ layout("/layouts/platform_h5.html"){
</van-tabs>
</van-sticky>
<table-list api="/platform/healthCheckup/h5/pageData" :page_form.sync="pageForm" ref="tableListRef"
<table-list class="health-checkup-cards" api="/platform/healthCheckup/h5/pageData" :page_form.sync="pageForm" ref="tableListRef"
@ready="doSearch" img="cover">
<template #header>
<div></div>
</template>
<template v-slot="{index,row}">
<table-column label="开始时间">
{{$moment(row.choiceTimeStart).format('MM/DD HH:mm')}}
</table-column>
<table-column label="结束时间">
{{$moment(row.choiceTimeEnd).format('MM/DD HH:mm')}}
</table-column>
<div class="health-checkup-card-details">
<div class="health-checkup-card-title">{{row.name}}</div>
<div class="health-checkup-card-time">
<span>开始时间</span>
<span>{{row.choiceTimeStart ? $moment(row.choiceTimeStart).format('YYYY-MM-DD HH:mm') : '—'}}</span>
</div>
<div class="health-checkup-card-time">
<span>结束时间</span>
<span>{{row.choiceTimeEnd ? $moment(row.choiceTimeEnd).format('YYYY-MM-DD HH:mm') : '—'}}</span>
</div>
<div class="health-checkup-card-tags">
<van-tag plain type="primary">{{row.healthCheckupType === 'jzg' ? '教职工' : row.healthCheckupType === 'js' ? '家属' : (row.healthCheckupType || '未设置对象类型')}}</van-tag>
<van-tag plain type="success">{{row.subjectCount}} 个体检套餐</van-tag>
</div>
</div>
</template>
<template #actions="{index,row}">
<template v-if="$moment().isBefore($moment(row.choiceTimeEnd))">
@@ -2,6 +2,48 @@
layout("/layouts/platform_h5.html"){
#-->
<style>
/* 仅调整体检卡片,避免窄屏下时间和标签被通用图片区域高度截断。 */
.health-checkup-cards .table-list-content {
max-height: none !important;
overflow: visible !important;
}
.health-checkup-card-title {
margin-bottom: 10px;
font-size: 15px;
line-height: 22px;
font-weight: 600;
color: #263548;
overflow-wrap: anywhere;
}
.health-checkup-card-details {
width: 100%;
min-width: 0;
}
.health-checkup-card-time {
display: flex;
gap: 6px;
margin-bottom: 6px;
color: #56657a;
font-size: 12px;
line-height: 18px;
}
.health-checkup-card-time > span:first-child {
flex-shrink: 0;
color: #98a3b3;
}
.health-checkup-card-time > span:last-child {
min-width: 0;
overflow-wrap: anywhere;
}
.health-checkup-card-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
</style>
<div id="app">
<van-nav-bar @click-left="historyBack" left-arrow left-text="返回" title="我的体检" placeholder fixed></van-nav-bar>
<van-sticky offset-top="46px">
@@ -13,14 +55,27 @@ layout("/layouts/platform_h5.html"){
</van-sticky>
<table-list api="/platform/healthCheckup/h5/mineData" :page_form.sync="pageForm" ref="tableListRef"
@ready="doSearch" title="healthCheckupName">
<table-list class="health-checkup-cards" api="/platform/healthCheckup/h5/mineData" :page_form.sync="pageForm" ref="tableListRef"
@ready="doSearch" img="cover">
<template #header>
<div></div>
</template>
<template v-slot="{index,row}">
<table-column label="项目名称">{{row.name}}</table-column>
<table-column label="开始时间">{{row.choiceTimeStart}}</table-column>
<table-column label="结束时间">{{row.choiceTimeEnd}}</table-column>
<table-column label="选择医院">{{row.optionName}}</table-column>
<table-column label="邮寄地址">{{row.receiveAddress}}</table-column>
<div class="health-checkup-card-details">
<div class="health-checkup-card-title">{{row.name}}</div>
<div class="health-checkup-card-time">
<span>开始时间</span>
<span>{{row.choiceTimeStart ? $moment(row.choiceTimeStart).format('YYYY-MM-DD HH:mm') : '—'}}</span>
</div>
<div class="health-checkup-card-time">
<span>结束时间</span>
<span>{{row.choiceTimeEnd ? $moment(row.choiceTimeEnd).format('YYYY-MM-DD HH:mm') : '—'}}</span>
</div>
<div class="health-checkup-card-tags">
<van-tag plain type="primary">{{row.healthCheckupType === 'jzg' ? '教职工' : row.healthCheckupType === 'js' ? '家属' : (row.healthCheckupType || '未设置对象类型')}}</van-tag>
<van-tag plain type="success">{{row.subjectCount}} 个体检套餐</van-tag>
</div>
</div>
</template>
<template #actions="{index,row}">
<div class="action-btn" @click="edit(row)" v-if="new Date().getTime() < new Date(row.choiceTimeEnd).getTime()">
@@ -337,7 +337,7 @@ layout("/layouts/platform_h5.html"){
</template>
</van-cell>
<van-popup position="bottom" round :close-on-click-overlay="true" v-model:show="showReimburseProjectPicker" class="union-half-popup">
<van-picker :columns="reimburseProjectColumns" @cancel="closePopup('showReimburseProjectPicker')"
<van-picker :columns="availableReimburseProjectColumns" @cancel="closePopup('showReimburseProjectPicker')"
@confirm="onReimburseProjectConfirm" show-toolbar></van-picker>
</van-popup>
<van-field
@@ -680,6 +680,12 @@ layout("/layouts/platform_h5.html"){
required
is-link
></van-field>
<van-field v-if="isManualReimburseAmount"
label="报销金额" name="money"
v-model="formData.money"
placeholder="请输入报销金额,最多两位小数"
:rules="[{ required: true, message: '请填写报销金额' }, { validator: () => !getReimburseAmountMessage(), message: getReimburseAmountMessage }]"
required></van-field>
<van-popup position="bottom" round :close-on-click-overlay="true" v-model:show="showActivityTimePicker" class="union-half-popup">
<van-datetime-picker type="date" title="选择活动时间" :min-date="minDate" :max-date="maxDate"
@cancel="closePopup('showActivityTimePicker')"
@@ -692,8 +698,9 @@ layout("/layouts/platform_h5.html"){
<section class="form-section">
<h2 class="form-section__title">附件信息</h2>
<van-cell-group :border="false">
<van-field class="more-text reimburse-upload-field" name="files" required
label="" :rules="[{ required: true, message: '请上传附件' }]">
<van-field class="more-text reimburse-upload-field" name="files"
:required="!['UNION_REIMBURSE_PROJECT_2', 'UNION_REIMBURSE_PROJECT_3', 'UNION_REIMBURSE_PROJECT_5'].includes(formData.reimburseProject)"
label="" :rules="['UNION_REIMBURSE_PROJECT_2', 'UNION_REIMBURSE_PROJECT_3', 'UNION_REIMBURSE_PROJECT_5'].includes(formData.reimburseProject) ? [] : [{ required: true, message: '请上传附件' }]">
<template #input>
<h5-file-upload
slot="input"
@@ -706,6 +713,11 @@ layout("/layouts/platform_h5.html"){
></h5-file-upload>
</template>
</van-field>
<van-cell v-if="formData.reimburseProject === 'UNION_REIMBURSE_PROJECT_5'">
<template #title>
<span style="color: #ee0a24;">社团活动附件说明</span>
</template>
</van-cell>
</van-cell-group>
</section>
<van-cell-group class="form-section">
@@ -762,10 +774,10 @@ layout("/layouts/platform_h5.html"){
</van-cell-group>
<van-cell-group class="form-section" v-if="formData.reimburseProject">
<div class="form-section__title">电子发票(选填)</div>
<div class="invoice-tip">无发票时按支付信息汇总报销金额;上传的发票仍须通过验真。</div>
<div class="invoice-tip">{{ isManualReimburseAmount ? '有发票时报销金额不得超过发票总额,无发票仍可提交;上传的发票仍须通过验真。' : '无发票时按支付信息汇总报销金额;上传的发票仍须通过验真。' }}</div>
<van-field label="报销金额"
v-model="formData.money"
placeholder="有发票按发票汇总,无发票按支付信息汇总"
:placeholder="isManualReimburseAmount ? '请在报销信息填写报销金额' : '有发票按发票汇总,无发票按支付信息汇总'"
readonly></van-field>
<van-field label="发票张数"
v-model="formData.invoiceNumber"
@@ -847,6 +859,11 @@ layout("/layouts/platform_h5.html"){
></h5-file-upload>
</template>
</van-field>
<van-cell v-if="formData.reimburseProject === 'UNION_REIMBURSE_PROJECT_5'">
<template #title>
<span style="color: #ee0a24;">社团活动附件说明</span>
</template>
</van-cell>
<van-field label="发票号码"
v-model="detailForm.invoiceNo" readonly
placeholder="请输入发票号码"
@@ -1136,6 +1153,10 @@ layout("/layouts/platform_h5.html"){
}
},
computed: {
isManualReimburseAmount() {
return ["UNION_REIMBURSE_PROJECT_2", "UNION_REIMBURSE_PROJECT_3", "UNION_REIMBURSE_PROJECT_4"]
.includes(this.formData.reimburseProject)
},
// 发票或支付明细变化时同步展示超额提示。
paymentAmountLimitMessage() {
return this.getPaymentAmountLimitMessage(this.formData.paymentDetails || [])
@@ -1147,9 +1168,17 @@ layout("/layouts/platform_h5.html"){
const selectedType = this.typeOptions.find(item => item.id === typeId)
return selectedType ? selectedType.code : ""
},
// 仅系统管理员和校工会管理员可查看真实余额,其他账号统一显示余额遮罩。
// 社团经费按菜单授权查看年度剩余额度,其他经费沿用原余额可见范围。
canViewFundBalance() {
return this.$auth.hasRoleOr(["SYSADMIN", "SCHOOL_UNION_ADMIN"])
|| (this.formData.reimburseFundSource === "UNION_REIMBURSE_FUND_SOURCE_3"
&& this.$auth.hasPermission("unionReimburse.club"))
},
// 社团经费仅可选择社团活动,其他经费不展示社团活动。
availableReimburseProjectColumns() {
const clubFund = this.formData.reimburseFundSource === "UNION_REIMBURSE_FUND_SOURCE_3"
return this.reimburseProjectColumns.filter(item =>
clubFund ? item.value === "UNION_REIMBURSE_PROJECT_5" : item.value !== "UNION_REIMBURSE_PROJECT_5")
},
paymentInfoRows() {
return this.formData.paymentDetails || []
@@ -1233,6 +1262,9 @@ layout("/layouts/platform_h5.html"){
},
"formData.reimburseProject"(newVal) {
this.handleReimburseProjectChange(newVal)
},
"formData.reimburseFundSource"() {
this.syncReimburseProject()
}
},
@@ -1852,6 +1884,27 @@ layout("/layouts/platform_h5.html"){
this.rebuildInvoiceSummary()
})
},
// 手填金额按分比较票面总额;未上传发票时仍允许申请。
getReimburseAmountMessage() {
if (!this.isManualReimburseAmount) return ""
const value = String(this.formData.money == null ? "" : this.formData.money)
if (!value) return "请填写报销金额"
const cents = this.moneyToCents(value)
if (!/^\d+(\.\d{1,2})?$/.test(value) || cents === null || cents <= 0) {
return "报销金额必须为正数,且最多两位小数"
}
const invoices = this.formData.invoiceDetails || []
let invoiceCents = 0
for (let i = 0; i < invoices.length; i++) {
const amount = this.moneyToCents(invoices[i] && invoices[i].invoiceAmount)
if (amount === null || amount <= 0) return "请填写正确的发票金额"
invoiceCents += amount
}
if (invoices.length && cents > invoiceCents) {
return "报销金额不能超过发票总额(" + (invoiceCents / 100).toFixed(2) + "元)"
}
return ""
},
rebuildInvoiceSummary() {
let totalMoney = 0
let fileCount = 0
@@ -1876,10 +1929,18 @@ layout("/layouts/platform_h5.html"){
})
totalMoney = paymentCents / 100
}
this.$set(this.formData, "money", totalMoney ? totalMoney.toFixed(2) : "0.00")
// 三类活动保留手填申请金额,发票及支付明细变化只更新张数。
if (!this.isManualReimburseAmount) {
this.$set(this.formData, "money", totalMoney ? totalMoney.toFixed(2) : "0.00")
}
this.$set(this.formData, "invoiceNumber", String(fileCount))
},
validateInvoiceDetailsBeforeSubmit() {
const amountMessage = this.getReimburseAmountMessage()
if (amountMessage) {
this.$toast.fail(amountMessage)
return false
}
const invoiceDetails = this.formData.invoiceDetails || []
// 发票可不上传;已经添加的发票仍须填写完整并沿用验真流程。
for (let i = 0; i < invoiceDetails.length; i++) {
@@ -2366,6 +2427,15 @@ layout("/layouts/platform_h5.html"){
}
this.closePopup("showPaymentWayPicker")
},
// 新建、切换来源及编辑回显都保持社团经费与社团活动一致。
syncReimburseProject() {
const clubFund = this.formData.reimburseFundSource === "UNION_REIMBURSE_FUND_SOURCE_3"
if (!clubFund && this.formData.reimburseProject !== "UNION_REIMBURSE_PROJECT_5") return
const projectCode = clubFund ? "UNION_REIMBURSE_PROJECT_5" : "UNION_REIMBURSE_PROJECT_2"
const project = this.reimburseProjectColumns.find(item => item.value === projectCode)
this.$set(this.formData, "reimburseProject", project ? project.value : "")
this.$set(this.formData, "reimburseProjectName", project ? project.text : "")
},
onReimburseProjectConfirm(o) {
this.$set(this.formData, "reimburseProjectName", o.text) // 显示名称
this.$set(this.formData, "reimburseProject", o.value) // 字典值
@@ -2441,16 +2511,10 @@ layout("/layouts/platform_h5.html"){
},
// 新建申请默认优先选择分工会经费;若当前用户没有对应权限,则按页面可见权限回退到其它经费来源。
getDefaultFundSource() {
if (this.$auth.hasPermission("unionReimburse.fgh")) {
return "UNION_REIMBURSE_FUND_SOURCE_2"
}
if (this.$auth.hasPermission("unionReimburse.xgh")) {
return "UNION_REIMBURSE_FUND_SOURCE_1"
}
if (this.$auth.hasPermission("unionReimburse.club")) {
return "UNION_REIMBURSE_FUND_SOURCE_3"
}
return ""
const option = this.reimburseFundSourceColumns.find(item => item.value === "UNION_REIMBURSE_FUND_SOURCE_2")
|| this.reimburseFundSourceColumns.find(item => item.value === "UNION_REIMBURSE_FUND_SOURCE_3")
|| this.reimburseFundSourceColumns[0]
return option ? option.value : ""
},
getReimburseFundSourceName(code) {
const matchedFundSource = this.reimburseFundSourceColumns.find(item => item.value === code)
@@ -2485,7 +2549,7 @@ layout("/layouts/platform_h5.html"){
this.$set(this, "reimburseProjectList", reimburseProjectData)
return this.$businessTool.getDictOptions("UNION_REIMBURSE_FUND_SOURCE")
}).then((reimburseFundSourceData) => {
this.$set(this, "reimburseFundSourceColumns", reimburseFundSourceData.map(item => ({value: item.code, text: item.name})))
this.$set(this, "reimburseFundSourceColumns", reimburseFundSourceData.map(item => ({value: item.code, text: item.code === "UNION_REIMBURSE_FUND_SOURCE_3" ? "协会经费" : item.name})))
this.filterReimburseFundSources()
})
},
@@ -2567,7 +2631,8 @@ layout("/layouts/platform_h5.html"){
// 查询社团
queryClubs() {
return this.$businessTool.listClubByRole().then((res) => {
return this.$axios.post("/platform/unionReimburse/apply/listReimburseClubs").then((response) => {
const res = response.data || []
this.$set(this, "clubOptions", res)
this.$set(this, "clubColumns", res.map(item => {
return { text: item.clubName, value: item.id }
@@ -2601,7 +2666,7 @@ layout("/layouts/platform_h5.html"){
url: "/platform/unionReimburse/apply/getUnionBalance"
},
"UNION_REIMBURSE_FUND_SOURCE_3": {
url: "/platform/unionReimburse/apply/getClubBudget"
url: "/platform/unionReimburse/apply/getBudgetBalance"
}
}
@@ -2630,14 +2695,20 @@ layout("/layouts/platform_h5.html"){
// 发送请求
this.$axios.post(apiInfo.url, params)
.then(response => {
// 切换来源或社团后,旧请求不得覆盖当前余额。
if (fundSource !== this.formData.reimburseFundSource
|| (params.clubId && params.clubId !== this.formData.clubId)) return
if (response.code === 0) {
// 显示经费余额(只显示数字,不显示"元")
this.$set(this.formData, "fundBalance", response.data || "0.00")
this.$set(this.formData, "fundBalance", fundSource === "UNION_REIMBURSE_FUND_SOURCE_3"
? Number(response.data || 0).toFixed(2) : (response.data || "0.00"))
} else {
this.$set(this.formData, "fundBalance", "查询失败")
}
})
.catch(error => {
if (fundSource !== this.formData.reimburseFundSource
|| (params.clubId && params.clubId !== this.formData.clubId)) return
console.error("经费余额查询失败:", error)
this.$set(this.formData, "fundBalance", "查询失败")
this.$toast.fail("经费余额查询失败")
@@ -2807,12 +2878,11 @@ layout("/layouts/platform_h5.html"){
this.$set(this.formData, "unionId", user.union.id)
this.$set(this.formData, "unionName", user.union.name)
const defaultFundSource = this.getDefaultFundSource()
this.$set(this.formData, "reimburseFundSource", defaultFundSource)
this.$set(this.formData, "reimburseFundSourceName", this.getReimburseFundSourceName(defaultFundSource))
this.$set(this.formData, "paymentDetails", [])
if (this.formData.reimburseFundSource) {
this.queryFundBalance()
}
this.onReimburseFundSourceConfirm({
value: defaultFundSource,
text: this.getReimburseFundSourceName(defaultFundSource)
})
}
}
},
@@ -54,6 +54,54 @@ layout("/layouts/platform_h5.html"){
.member-voluntary__paragraph + .member-voluntary__paragraph {
margin-top: 6px;
}
.member-h5-page.has-fixed-actions {
padding-bottom: calc(120px + env(safe-area-inset-bottom));
}
.member-form-actions {
flex-direction: column;
}
.member-action-buttons,
.member-notice-actions {
display: flex;
gap: 9px;
}
.member-notice-actions .van-button {
height: 42px;
border-radius: 14px;
font-size: 14px;
}
.member-notice-consent {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 5px;
color: #596779;
font-size: 12px;
line-height: 22px;
}
.member-notice-link {
padding: 0;
border: 0;
background: transparent;
color: #1989fa;
font-size: inherit;
}
.member-notice-popup {
box-sizing: border-box;
max-height: 80vh;
padding: 20px 16px calc(16px + env(safe-area-inset-bottom));
}
.member-notice-title {
margin: 0 0 12px;
text-align: center;
font-size: 18px;
color: #263548;
}
.member-notice-content {
max-height: 50vh;
overflow-y: auto;
margin-bottom: 18px;
}
</style>
<div id="app" v-cloak>
<div class="member-h5-page" :class="{'has-fixed-actions': showActionButtons}">
@@ -341,29 +389,36 @@ layout("/layouts/platform_h5.html"){
</van-cell-group>
</section>
<section class="form-section">
<h2 class="member-form-section__title">入会意愿</h2>
<van-cell-group :border="false">
<van-checkbox class="member-voluntary" icon-size="22px"
v-model="formData.isVoluntary" shape="square">
<div class="member-voluntary__paragraph">
<span :style="'color:' + (formData.isVoluntary ? '#1989fa' : '#F56C6C') ">
我自愿申请加入学校工会,并委托学校按规定代为扣缴工会会员会费。
</span>
</div>
<div class="member-voluntary__paragraph">
<span :style="'color:' + (formData.isVoluntary ? '#1989fa' : '#F56C6C') ">
我将严格遵守工会章程,认真执行工会决议,积极参与工会活动,主动融入“学校健康幸福家”建设,为营造温暖、和谐、奋进的校园氛围贡献力量,以实际行动助力学校各项事业发展。
</span>
</div>
</van-checkbox>
</van-cell-group>
</section>
<div v-if="showActionButtons" class="member-form-actions">
<van-button block plain class="member-save-button" :loading="formLoading && loadingAction === 'save'" :disabled="formLoading" @click.submit="onSave">保存草稿</van-button>
<van-button v-if="!taskId" block type="primary" class="member-submit-button" :loading="formLoading && loadingAction === 'submit'" :disabled="formLoading" @click.submit="onSubmit">提交申请</van-button>
<van-button v-else block type="primary" class="member-submit-button" :loading="formLoading && loadingAction === 'submitAgain'" :disabled="formLoading" @click.submit="onFinishTask">重新提交</van-button>
<div class="member-notice-consent">
<van-checkbox :value="hasReadNotice" :disabled="formLoading" shape="square" icon-size="18px"
aria-label="同意入会意愿" @input="onNoticeCheckChange"></van-checkbox>
<span>我已阅读并同意</span>
<button type="button" class="member-notice-link" :disabled="formLoading" @click="openMembershipNotice">《入会意愿》</button>
</div>
<div class="member-action-buttons">
<van-button block plain native-type="button" class="member-save-button" :loading="formLoading && loadingAction === 'save'" :disabled="formLoading" @click="onSave">保存草稿</van-button>
<van-button v-if="!taskId" block type="primary" native-type="button" class="member-submit-button" :loading="formLoading && loadingAction === 'submit'" :disabled="formLoading || !hasReadNotice" @click="onSubmit">提交申请</van-button>
<van-button v-else block type="primary" native-type="button" class="member-submit-button" :loading="formLoading && loadingAction === 'submitAgain'" :disabled="formLoading || !hasReadNotice" @click="onFinishTask">重新提交</van-button>
</div>
</div>
<van-popup :value="showMembershipNotice" position="bottom" round class="member-notice-popup"
:close-on-click-overlay="false" :close-on-popstate="false">
<h2 class="member-notice-title">入会意愿</h2>
<div class="member-voluntary member-notice-content">
<div class="member-voluntary__paragraph">
我自愿申请加入学校工会,并委托学校按规定代为扣缴工会会员会费。
</div>
<div class="member-voluntary__paragraph">
我将严格遵守工会章程,认真执行工会决议,积极参与工会活动,主动融入“学校健康幸福家”建设,为营造温暖、和谐、奋进的校园氛围贡献力量,以实际行动助力学校各项事业发展。
</div>
</div>
<div class="member-notice-actions">
<van-button block native-type="button" @click="cancelMembershipNotice">取消</van-button>
<van-button block native-type="button" type="primary" :disabled="noticeCountdown > 0"
@click="confirmMembershipNotice">{{noticeCountdown > 0 ? '我已知晓(' + noticeCountdown + '秒)' : '我已知晓'}}</van-button>
</div>
</van-popup>
</van-form>
</main>
</div>
@@ -377,12 +432,18 @@ layout("/layouts/platform_h5.html"){
return {
id: GetQueryString("bizId"),
taskId: GetQueryString("taskId"),
// 当前登录人已是会员时,不显示保存和提交按钮。
showActionButtons: true,
// 已入会或申请处理中仍显示操作栏,保存和提交限制由申请状态单独控制。
showActionButtons: false,
applicationBlockMessage: "",
pageLoading: true,
formLoading: false,
loadingAction: "",
historyLayerPageKey: "member-apply-submit",
showMembershipNotice: false,
noticeAcknowledged: false,
noticeCloseRequested: false,
noticeCountdown: 5,
noticeTimer: null,
formData: {
families: []
},
@@ -404,7 +465,51 @@ layout("/layouts/platform_h5.html"){
}
},
methods: {
openMembershipNotice() {
if (this.formLoading || this.showMembershipNotice) return
window.h5HistoryLayerManager.open("membership-notice")
},
onNoticeCheckChange(checked) {
if (checked) {
this.openMembershipNotice()
} else {
this.noticeAcknowledged = false
this.$set(this.formData, "isVoluntary", false)
}
},
// 每次打开都重新计时,只有本次阅读确认后才能勾选,旧草稿不能跳过确认。
startNoticeCountdown() {
this.clearNoticeTimer()
this.noticeAcknowledged = false
this.$set(this.formData, "isVoluntary", false)
this.noticeCountdown = 5
this.noticeTimer = setInterval(() => {
this.noticeCountdown = Math.max(0, this.noticeCountdown - 1)
if (this.noticeCountdown === 0) this.clearNoticeTimer()
}, 1000)
},
clearNoticeTimer() {
if (this.noticeTimer !== null) {
clearInterval(this.noticeTimer)
this.noticeTimer = null
}
},
confirmMembershipNotice() {
if (!this.showMembershipNotice || this.noticeCountdown > 0) return
this.noticeAcknowledged = true
this.$set(this.formData, "isVoluntary", true)
this.noticeCloseRequested = true
window.h5HistoryLayerManager.close("membership-notice")
},
cancelMembershipNotice() {
if (!this.showMembershipNotice) return
this.noticeAcknowledged = false
this.$set(this.formData, "isVoluntary", false)
this.noticeCloseRequested = true
window.h5HistoryLayerManager.close("membership-notice")
},
handlePageBack() {
if (this.showMembershipNotice) return
if (window.h5HistoryLayerManager.stack.length) {
window.h5HistoryLayerManager.close()
return
@@ -433,7 +538,7 @@ layout("/layouts/platform_h5.html"){
},
registerHistoryLayers() {
// 六个选择器共用页面级历史栈,手机返回键只关闭当前最上层选择器。
// 入会意愿仅允许通过底部按钮关闭;其他选择器沿用原返回栈行为。
window.h5HistoryLayerManager.register(this.historyLayerPageKey, (stack) => {
this.$set(this, "showNationPicker", stack.includes("nation-picker"))
this.$set(this, "showDatePicker", stack.includes("birthday-picker"))
@@ -441,6 +546,21 @@ layout("/layouts/platform_h5.html"){
this.$set(this, "showEducationPicker", stack.includes("education-picker"))
this.$set(this, "showAcademicDegreePicker", stack.includes("degree-picker"))
this.$set(this, "showMarriagePicker", stack.includes("marriage-picker"))
const noticeVisible = stack.includes("membership-notice")
// 系统返回时恢复当前弹层历史,保留阅读状态及倒计时,须由底部按钮关闭。
if (!noticeVisible && this.showMembershipNotice && !this.noticeCloseRequested) {
window.h5HistoryLayerManager.open("membership-notice")
return
}
if (noticeVisible && !this.showMembershipNotice) {
this.noticeCloseRequested = false
this.startNoticeCountdown()
} else if (!noticeVisible && this.showMembershipNotice) {
this.clearNoticeTimer()
this.noticeCloseRequested = false
if (!this.noticeAcknowledged) this.$set(this.formData, "isVoluntary", false)
}
this.$set(this, "showMembershipNotice", noticeVisible)
})
},
@@ -479,7 +599,11 @@ layout("/layouts/platform_h5.html"){
},
onSave() {
if (this.formLoading) return
if (this.formLoading || !this.showActionButtons) return
if (this.applicationBlockMessage) {
this.$dialog.alert({ title: "提示", message: this.applicationBlockMessage })
return
}
this.$dialog.confirm({
title: "提示",
message: "您确定要保存吗?",
@@ -504,7 +628,15 @@ layout("/layouts/platform_h5.html"){
},
onSubmit() {
if (this.formLoading) return
if (this.formLoading || !this.showActionButtons) return
if (this.applicationBlockMessage) {
this.$dialog.alert({ title: "提示", message: this.applicationBlockMessage })
return
}
if (!this.hasReadNotice) {
this.$toast.fail("请阅读并勾选入会意愿")
return
}
this.$refs.formRef.validate().then(() => {
if (!this.formData.personalData) {
this.$toast.fail("请填写个人学习及工作经历")
@@ -527,8 +659,7 @@ layout("/layouts/platform_h5.html"){
this.$toast.success(res.msg)
this.$pjaxReplace("/platform/member/apply/mine/h5")
} else {
this.$toast.fail("操作失败")
console.log(res.msg)
this.$dialog.alert({ title: "提示", message: res.msg || "操作失败" })
}
}).finally(() => {
this.formLoading = false
@@ -539,7 +670,15 @@ layout("/layouts/platform_h5.html"){
},
onFinishTask() {
if (this.formLoading) return
if (this.formLoading || !this.showActionButtons) return
if (this.applicationBlockMessage) {
this.$dialog.alert({ title: "提示", message: this.applicationBlockMessage })
return
}
if (!this.hasReadNotice) {
this.$toast.fail("请阅读并勾选入会意愿")
return
}
this.$refs.formRef.validate().then(() => {
if (!this.formData.personalData) {
this.$toast.fail("请填写个人学习及工作经历")
@@ -563,8 +702,7 @@ layout("/layouts/platform_h5.html"){
this.$toast.success(res.msg)
this.$pjaxReplace("/platform/member/apply/mine/h5")
} else {
this.$toast.fail("操作失败")
console.log(res.msg)
this.$dialog.alert({ title: "提示", message: res.msg || "操作失败" })
}
}).finally(() => {
this.formLoading = false
@@ -580,12 +718,21 @@ layout("/layouts/platform_h5.html"){
},
init() {
// 无参数获取当前登录人资料;有 id 时改查已有申请,接口均返回对应的用户/申请对象。
return this.$axios.post("/platform/member/apply/submit/getSelfUserInfo", {}, {h5SkeletonLoading: true})
// 使用服务端实时状态,已有申请携带任务信息以保留合法重新提交。
return this.$axios.post("/platform/member/apply/submit/getSelfUserInfo", {
id: this.id || null,
taskId: this.taskId || null
}, {h5SkeletonLoading: true})
.then((resp) => {
const user = resp.code === 0 && resp.data ? resp.data : this.$store.state.user
// 页面按钮是否展示,以当前登录人的会员状态为准。
this.showActionButtons = !(user && user.member)
if (resp.code !== 0 || !resp.data) {
return this.$dialog.alert({ title: "提示", message: resp.msg || "无法获取申请状态,请稍后重试" })
}
const user = resp.data
this.applicationBlockMessage = user.applicationBlockMessage || ""
this.showActionButtons = true
if (user.applicationBlockMessage) {
this.$dialog.alert({ title: "提示", message: user.applicationBlockMessage })
}
if (this.id) {
return this.$axios.post("/platform/member/apply/submit/findApplyById", { id: this.id }, {h5SkeletonLoading: true})
@@ -666,6 +813,10 @@ layout("/layouts/platform_h5.html"){
this.$set(this.formData, "campusName", "")
}
})
.catch(() => {
this.showActionButtons = false
this.$dialog.alert({ title: "提示", message: "无法获取申请状态,请稍后重试" })
})
},
toChinesNum(num) {
let changeNum = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九"]
@@ -708,6 +859,9 @@ layout("/layouts/platform_h5.html"){
}
},
computed: {
hasReadNotice() {
return this.noticeAcknowledged && this.formData.isVoluntary === true
},
nationColumns() {
if (this.dict && this.dict.type && this.dict.type.USER_NATION) {
@@ -749,6 +903,7 @@ layout("/layouts/platform_h5.html"){
})
},
beforeDestroy() {
this.clearNoticeTimer()
window.h5HistoryLayerManager.unregister(this.historyLayerPageKey)
}
})
@@ -37,7 +37,7 @@ const home = {
<div class="quick-entry-item"
v-for="item in quickEntries"
@click="menuClick(item)"
:key="item.id">
:key="item.id">
<div class="quick-entry-icon">
<img v-if="getQuickEntryIcon(item)" :src="getQuickEntryIcon(item)" alt="">
<van-icon v-else name="apps-o" size="36"></van-icon>
@@ -51,12 +51,12 @@ const home = {
<!--娲诲姩鍒楄〃-->
<div class="feature-panel">
<div
class="feature-card"
:class="[item.className, { 'feature-card--primary': index === 0 }]"
:style="{ backgroundImage: 'url(' + item.asset + ')' }"
v-for="(item, index) in featuredEntries"
:key="item.name"
@click="featureClick(item)">
class="feature-card"
:class="[item.className, { 'feature-card--primary': index === 0 }]"
:style="{ backgroundImage: 'url(' + item.asset + ')' }"
v-for="(item, index) in featuredEntries"
:key="item.name"
@click="featureClick(item)">
<div class="feature-card__text">
<div class="feature-card__title">{{ item.name }}</div>
<div class="feature-card__desc">{{ item.desc }}</div>
@@ -89,7 +89,6 @@ const home = {
<van-icon v-if="spreadActiveIndex === index" name="success" color="#1989fa"></van-icon>
</button>
</div>
<div class="union-news-viewport" ref="spreadViewport">
<div v-if="spreadLoading && !spreadDynamics.length" class="union-news-list union-news-list--skeleton">
<div class="union-news-item union-news-item--skeleton"
v-for="index in 2"
@@ -109,7 +108,7 @@ const home = {
</div>
</div>
</div>
<div class="union-news-list" v-else-if="spreadDynamics.length">
<div class="union-news-list" ref="spreadViewport" v-else-if="spreadDynamics.length">
<div class="union-news-item"
v-for="item in spreadDynamics"
:key="item.href || item.title"
@@ -150,7 +149,6 @@ const home = {
alt="暂无工会动态">
<span class="union-news-empty__text">暂无工会动态</span>
</div>
</div>
</div>
<div class="classroom-section">
@@ -230,8 +228,8 @@ const home = {
<van-tag class="act-item-wrapper-tag" v-if="$moment().unix() > $moment(item.endDate).unix()">
已结束
</van-tag>
<van-tag class="act-item-wrapper-tag" type="success"
<van-tag class="act-item-wrapper-tag" type="success"
v-else-if="$moment().unix() > $moment(item.startDate).unix() && $moment().unix() < $moment(item.endDate).unix()">
进行中
</van-tag>
@@ -239,7 +237,7 @@ const home = {
<van-tag class="act-item-wrapper-tag" type="success" v-else-if="$moment().unix() < $moment(item.startDate).unix()">
即将开始
</van-tag>
<div class="act-item-cover">
<van-image v-if="item.cover" width="120" height="84" :src="item.cover"></van-image>
</div>
@@ -575,12 +573,14 @@ const home = {
})
},
selectSpreadSection(index) {
// 切换栏目时复位列表滚动位置;固定视口避免不同数据量引发页面跳动。
// 切换栏目后回到横向卡片列表开头,等待新栏目列表渲染完成。
// 新闻样式锚定 home-page 根节点作用域,动态行无需等待 styleMixin 异步补属性。
const section = this.spreadSections[index] || {}
this.$set(this, "spreadActiveIndex", index)
this.$set(this, "spreadDynamics", section.value || [])
if (this.$refs.spreadViewport) this.$refs.spreadViewport.scrollTop = 0
this.$nextTick(() => {
if (this.$refs.spreadViewport) this.$refs.spreadViewport.scrollLeft = 0
})
this.loadSpreadImages()
},
loadSpreadImages() {
@@ -1570,7 +1570,10 @@ const home = {
.home-page/deep/ .union-news-list {
margin-top: 7px;
padding-bottom: 3px;
display: block;
display: flex;
gap: 10px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
@@ -1580,16 +1583,16 @@ const home = {
}
.home-page/deep/ .union-news-item--skeleton {
height: 96px;
min-height: 96px;
height: 100px;
min-height: 100px;
}
.home-page/deep/ .union-news-skeleton__cover {
width: 80px;
height: 48px;
margin-right: 10px;
width: 104px;
height: 78px;
margin-right: 12px;
flex-shrink: 0;
border-radius: 4px;
border-radius: 6px;
}
.home-page/deep/ .union-news-skeleton__content {
@@ -1608,15 +1611,15 @@ const home = {
.home-page/deep/ .union-news-skeleton__meta span:last-child { width: 28px; }
.home-page/deep/ .union-news-item {
width: 100%;
min-width: 0;
height: 96px;
padding: 12px 0;
width: calc(100% - 32px);
min-width: calc(100% - 32px);
min-height: 100px;
padding: 10px;
flex-shrink: 0;
display: flex;
align-items: center;
border: 0;
border-radius: 0;
border: 1px solid #edf1f6;
border-radius: 9px;
background-color: #fff;
box-sizing: border-box;
}
@@ -1630,22 +1633,14 @@ const home = {
}
.home-page/deep/ .union-news-item__cover {
width: 80px;
height: 60px;
margin-right: 10px;
width: 104px;
height: 78px;
margin-right: 12px;
flex-shrink: 0;
border-radius: 4px;
border-radius: 6px;
object-fit: cover;
}
.home-page/deep/ .union-news-viewport {
height: 288px;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
overflow-anchor: none;
}
.home-page/deep/ .union-news-item__placeholder {
display: flex;
align-items: center;
@@ -1655,17 +1650,6 @@ const home = {
font-size: 26px;
}
.home-page/deep/ .union-news-empty {
height: 100%;
box-sizing: border-box;
justify-content: center;
}
.home-page/deep/ .union-news-empty__image {
max-height: 200px;
object-fit: contain;
}
.home-page/deep/ .union-news-item__content {
min-width: 0;
flex: 1;