移植杭医版生日祝福

This commit is contained in:
2026-08-26 13:57:37 +08:00
parent 49c34cf226
commit 7509a9467c
22 changed files with 1654 additions and 31 deletions
@@ -1,12 +1,8 @@
package com.budwk.app.zhgh.dayofficework.birthdayWishes.task;
import cn.hutool.core.date.DateUtil;
import com.budwk.app.sys.models.Sys_msg;
import com.budwk.app.sys.services.SysMsgService;
import com.budwk.app.zhgh.staffmanage.birthday.service.UserBirthdayService;
import lombok.extern.slf4j.Slf4j;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json;
@@ -15,16 +11,12 @@ import org.quartz.JobDataMap;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import java.util.List;
@IocBean
@Slf4j
public class BirthdayWishesTask implements Job {
@Inject
protected SysMsgService sysMsgService;
@Inject
protected Dao dao;
private UserBirthdayService userBirthdayService;
@Override
@@ -34,28 +26,12 @@ public class BirthdayWishesTask implements Job {
log.info("=================================参数:{}", Json.toJson(dataMap));
String planName = dataMap.getString("name");
String today = DateUtil.today();
String title = planName + today;
String content = dataMap.getString("template");
Sys_msg msg = new Sys_msg();
msg.setTitle(planName + today);
msg.setType("user");
msg.setSendType("show");
msg.setSendAt(DateUtil.current());
msg.setNote(dataMap.getString("template"));
// 查询今天生日的会员用户
Sql sql = Sqls.create("SELECT loginname FROM sys_user WHERE member = 1 AND MONTH(birthday) = MONTH(CURRENT_DATE) AND DAY(birthday) = DAY(CURRENT_DATE)");
sql.setCallback(Sqls.callback.strList());
dao.execute(sql);
List<String> users = sql.getList(String.class);
// 转为数组
String[] userArray = users.toArray(String[]::new);
log.info("=================================查询今天生日会员用户:{}", users);
if(userArray.length > 0){
log.info("=================================发送消息:{}", msg);
sysMsgService.saveMsg(msg, userArray, true);
}
// 统一由生日服务查询当天生日会员、生成移动端链接并记录系统推送日志。
int receiverCount = userBirthdayService.sendTodayBirthdayMessages(title, content);
log.info("=================================当天生日通知进入发送队列人数:{}", receiverCount);
}
}
@@ -0,0 +1,142 @@
package com.budwk.app.zhgh.staffmanage.birthday.controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.result.Result;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.staffmanage.birthday.models.UserBirthdayConfig;
import com.budwk.app.zhgh.staffmanage.birthday.param.UserBirthdayPageForm;
import com.budwk.app.zhgh.staffmanage.birthday.param.UserBirthdaySendMsgForm;
import com.budwk.app.zhgh.staffmanage.birthday.service.UserBirthdayService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@IocBean
@At("/platform/staffManage/birthday/manage")
@Ok("json:full")
@Api(tags = "生日祝福管理")
public class UserBirthdayManageController {
@Inject
private UserBirthdayService userBirthdayService;
@At("")
@Ok("beetl:/platform/zhgh/staffmanage/birthday/manage/index.html")
@SaCheckPermission("staff.birthday.manage")
public void index() {
}
@At("/h5")
@Ok("beetl:/platform/zhghh5/staffmanage/birthday/index.html")
@SaCheckLogin
public void h5(String id, HttpServletRequest request) {
request.setAttribute("fileId", StrUtil.blankToDefault(id, ""));
request.setAttribute("userName", SecurityUtil.getUserUsername());
}
/**
* 查询生日人员。
*
* @param pageForm 分页、姓名/工号、工会、单位、人员状态及生日日期条件
* @return Result.data 为 Paginationlist 是人员数据,totalCount 是总数
*/
@At
@ApiOperation("分页查询生日人员")
@SaCheckPermission("staff.birthday.manage")
public Result pageData(UserBirthdayPageForm pageForm) {
return Result.success(userBirthdayService.pageData(pageForm));
}
/**
* 导出生日人员 Excel。
*
* @param pageForm 与列表查询一致的筛选条件
* @param response 返回 XSSF 格式的 xlsx 文件流
*/
@At
@Ok("void")
@ApiOperation("导出生日人员")
@SaCheckPermission("staff.birthday.manage")
public void doExport(UserBirthdayPageForm pageForm, HttpServletResponse response) {
userBirthdayService.exportXlsx(pageForm, response);
}
/**
* 按查询条件批量发送通知。
* form.title 为标题,form.content 为正文,其余字段为人员筛选条件;返回 data 为接收人数。
*/
@At
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("按查询条件发送生日通知")
@SaCheckPermission("staff.birthday.manage")
public Result sendMsgByQueryUsers(UserBirthdaySendMsgForm form) {
Result validation = validateMessage(form);
if (validation != null) {
return validation;
}
int count = userBirthdayService.sendByQuery(form, SecurityUtil.getUserId(), SecurityUtil.getUserUsername());
if (count == 0) {
return Result.error("当前条件下没有可发送的人员");
}
return Result.success("已提交发送,共" + count + "", count);
}
/**
* 向单个用户发送通知。
* form.userId 为接收人用户IDtitle、content 为消息内容;返回 data 为发送人数 1。
*/
@At
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("向单个用户发送生日通知")
@SaCheckPermission("staff.birthday.manage")
public Result sendMsgByUser(UserBirthdaySendMsgForm form) {
Result validation = validateMessage(form);
if (validation != null) {
return validation;
}
if (StrUtil.isBlank(form.getUserId())) {
return Result.error("未获取到消息接收人");
}
boolean sent = userBirthdayService.sendByUser(form, SecurityUtil.getUserId(), SecurityUtil.getUserUsername());
return sent ? Result.success("已提交发送", 1) : Result.error("未获取到消息接收人");
}
@At
@ApiOperation("获取生日页面配置")
@SaCheckLogin
public Result getConfig() {
return Result.success(userBirthdayService.getConfig());
}
/**
* 保存生日页面图片配置。
* config.picUrl 为背景图片文件IDconfig.birthdayUrl 为福利贺卡文件ID;返回保存后的配置对象。
*/
@At
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("保存生日页面配置")
@SaCheckPermission("staff.birthday.manage")
public Result saveOrModifyConfig(UserBirthdayConfig config) {
return Result.success("保存成功", userBirthdayService.saveConfig(config));
}
private Result validateMessage(UserBirthdaySendMsgForm form) {
if (form == null || StrUtil.isBlank(form.getTitle())) {
return Result.error("请输入发送标题");
}
if (StrUtil.isBlank(form.getContent())) {
return Result.error("请输入发送内容");
}
return null;
}
}
@@ -0,0 +1,41 @@
package com.budwk.app.zhgh.staffmanage.birthday.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.staffmanage.birthday.param.UserBirthdayMsgLogPageForm;
import com.budwk.app.zhgh.staffmanage.birthday.service.UserBirthdayMsgLogService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
@IocBean
@At("/platform/staffManage/birthday/msgLog")
@Ok("json:full")
@Api(tags = "生日消息记录")
public class UserBirthdayMsgLogController {
@Inject
private UserBirthdayMsgLogService userBirthdayMsgLogService;
@At("")
@Ok("beetl:/platform/zhgh/staffmanage/birthday/msglog/index.html")
@SaCheckPermission("staff.birthday.msgLog")
public void index() {
}
/**
* 查询生日消息记录。
*
* @param pageForm 分页、姓名/工号、工会和单位查询条件
* @return Result.data 为 Paginationlist 是发送记录,totalCount 是总数
*/
@At
@ApiOperation("分页查询生日消息记录")
@SaCheckPermission("staff.birthday.msgLog")
public Result pageData(UserBirthdayMsgLogPageForm pageForm) {
return Result.success(userBirthdayMsgLogService.pageData(pageForm));
}
}
@@ -0,0 +1,41 @@
package com.budwk.app.zhgh.staffmanage.birthday.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.entity.annotation.TableMeta;
import org.nutz.dao.interceptor.annotation.PrevInsert;
/**
* 生日祝福页面配置。
* picUrl、birthdayUrl 保存 sys_file 文件 ID,由移动端按文件 ID 获取图片。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Table("user_birthday_config")
@TableMeta("{'mysql-charset':'utf8mb4'}")
@Comment("生日祝福页面配置")
public class UserBirthdayConfig extends BaseModel {
@Name
@Comment("ID")
@PrevInsert(uu32 = true)
@ColDefine(type = ColType.VARCHAR, width = 32)
private String id;
@Column
@Comment("移动端背景图片文件ID")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String picUrl;
@Column
@Comment("生日福利贺卡图片文件ID")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String birthdayUrl;
}
@@ -0,0 +1,76 @@
package com.budwk.app.zhgh.staffmanage.birthday.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.entity.annotation.TableMeta;
import org.nutz.dao.interceptor.annotation.PrevInsert;
/**
* 生日祝福消息发送记录。
* 每个接收人保存一条记录,便于按人员、单位和工会追溯发送情况。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Table("user_birthday_msg_log")
@TableMeta("{'mysql-charset':'utf8mb4'}")
@Comment("生日祝福消息发送记录")
public class UserBirthdayMsgLog extends BaseModel {
@Name
@Comment("ID")
@PrevInsert(uu32 = true)
@ColDefine(type = ColType.VARCHAR, width = 32)
private String id;
@Column
@Comment("推送时间")
@ColDefine(type = ColType.VARCHAR, width = 50)
private String pushTime;
@Column
@Comment("消息标题")
@ColDefine(type = ColType.VARCHAR, width = 255)
private String msgTitle;
@Column
@Comment("消息内容")
@ColDefine(type = ColType.TEXT)
private String msgContent;
@Column
@Comment("接收人用户ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String receiveBy;
@Column
@Comment("接收人姓名")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String receiveName;
@Column
@Comment("推送人用户ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String pushBy;
@Column
@Comment("推送人姓名")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String pushByName;
@Column
@Comment("推送类型")
@ColDefine(type = ColType.VARCHAR, width = 50)
private String pushType;
@Column
@Comment("生日祝福页面链接")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String link;
}
@@ -0,0 +1,15 @@
package com.budwk.app.zhgh.staffmanage.birthday.param;
import com.budwk.app.zhgh.staffmanage.member.param.pageform.MemberInfoPageForm;
import io.swagger.annotations.ApiModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 生日消息记录分页查询参数,复用人员模块的工会、单位及数据范围条件。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@ApiModel("生日消息记录分页查询参数")
public class UserBirthdayMsgLogPageForm extends MemberInfoPageForm {
}
@@ -0,0 +1,65 @@
package com.budwk.app.zhgh.staffmanage.birthday.param;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.zhgh.staffmanage.member.param.pageform.MemberInfoPageForm;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.Cnd;
import org.nutz.dao.util.cri.Static;
/**
* 生日人员分页查询参数。
* startDate、endDate 接收 yyyy-MM-dd;生日比较只使用月日,允许查询跨年区间。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@ApiModel("生日人员分页查询参数")
public class UserBirthdayPageForm extends MemberInfoPageForm {
@ApiModelProperty("生日开始日期,格式 yyyy-MM-dd")
private String startDate;
@ApiModelProperty("生日结束日期,格式 yyyy-MM-dd")
private String endDate;
/**
* 将生日日期条件追加到已有人员查询条件。
* 未传日期时默认查询今天起十五天内的生日,跨年时自动使用 OR 条件。
*
* @param cnd 已包含人员范围和会员状态的查询条件
*/
public void buildBirthdaySearch(Cnd cnd) {
String start = normalizeMonthDay(startDate);
String end = normalizeMonthDay(endDate);
if (StrUtil.isAllBlank(start, end)) {
start = DateUtil.format(DateUtil.date(), "MM-dd");
end = DateUtil.format(DateUtil.offsetDay(DateUtil.date(), 15), "MM-dd");
}
if (StrUtil.isAllNotBlank(start, end)) {
if (start.compareTo(end) > 0) {
cnd.and(new Static(String.format(
"(DATE_FORMAT(u.birthday, '%%m-%%d') >= '%s' OR DATE_FORMAT(u.birthday, '%%m-%%d') <= '%s')",
start, end)));
} else {
cnd.and(new Static(String.format(
"DATE_FORMAT(u.birthday, '%%m-%%d') >= '%s' AND DATE_FORMAT(u.birthday, '%%m-%%d') <= '%s'",
start, end)));
}
} else if (StrUtil.isNotBlank(start)) {
cnd.and(new Static(String.format("DATE_FORMAT(u.birthday, '%%m-%%d') >= '%s'", start)));
} else {
cnd.and(new Static(String.format("DATE_FORMAT(u.birthday, '%%m-%%d') <= '%s'", end)));
}
}
private String normalizeMonthDay(String value) {
if (StrUtil.isBlank(value)) {
return null;
}
return DateUtil.format(DateUtil.parse(value), "MM-dd");
}
}
@@ -0,0 +1,25 @@
package com.budwk.app.zhgh.staffmanage.birthday.param;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 生日祝福发送参数。
* 查询条件用于批量确定接收人;userId 用于单人发送;title、content 为消息标题和正文。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@ApiModel("生日祝福发送参数")
public class UserBirthdaySendMsgForm extends UserBirthdayPageForm {
@ApiModelProperty("消息标题")
private String title;
@ApiModelProperty("消息正文")
private String content;
@ApiModelProperty("单人发送时的用户ID")
private String userId;
}
@@ -0,0 +1,36 @@
package com.budwk.app.zhgh.staffmanage.birthday.service;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.staffmanage.birthday.models.UserBirthdayMsgLog;
import com.budwk.app.zhgh.staffmanage.birthday.param.UserBirthdayMsgLogPageForm;
import java.util.List;
/**
* 生日祝福消息记录服务。
*/
public interface UserBirthdayMsgLogService extends BaseService<UserBirthdayMsgLog> {
/**
* 查询生日消息记录。
*
* @param pageForm 人员、工会、单位及分页条件
* @return 当前页发送记录及总数
*/
Pagination pageData(UserBirthdayMsgLogPageForm pageForm);
/**
* 为每个接收人写入一条发送记录。
*
* @param loginNames 接收人工号集合
* @param title 消息标题
* @param content 消息正文
* @param link 移动端生日祝福完整链接
* @param pushBy 推送人用户ID
* @param pushByName 推送人姓名
* @param pushType 手动推送或系统推送
*/
void insertLogs(List<String> loginNames, String title, String content, String link,
String pushBy, String pushByName, String pushType);
}
@@ -0,0 +1,74 @@
package com.budwk.app.zhgh.staffmanage.birthday.service;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.sys.models.Sys_user;
import com.budwk.app.zhgh.staffmanage.birthday.models.UserBirthdayConfig;
import com.budwk.app.zhgh.staffmanage.birthday.param.UserBirthdayPageForm;
import com.budwk.app.zhgh.staffmanage.birthday.param.UserBirthdaySendMsgForm;
import javax.servlet.http.HttpServletResponse;
/**
* 生日祝福业务服务。
*/
public interface UserBirthdayService extends BaseService<Sys_user> {
/**
* 分页查询生日会员。
*
* @param pageForm 人员、组织和生日日期查询条件
* @return 当前页人员列表及总数
*/
Pagination pageData(UserBirthdayPageForm pageForm);
/**
* 导出符合查询条件的生日会员。
*
* @param pageForm 查询条件
* @param response Excel 文件响应
*/
void exportXlsx(UserBirthdayPageForm pageForm, HttpServletResponse response);
/**
* 按当前查询条件批量发送生日通知。
*
* @param form 查询条件、标题和正文
* @param pushBy 推送人用户ID
* @param pushByName 推送人姓名
* @return 实际进入发送队列的接收人数
*/
int sendByQuery(UserBirthdaySendMsgForm form, String pushBy, String pushByName);
/**
* 向指定用户发送生日通知。
*
* @param form userId、标题和正文
* @param pushBy 推送人用户ID
* @param pushByName 推送人姓名
* @return 是否找到接收人并进入发送队列
*/
boolean sendByUser(UserBirthdaySendMsgForm form, String pushBy, String pushByName);
/**
* 向当天生日的会员发送系统生日通知,供 Quartz 任务调用。
*
* @param title 消息标题
* @param content 消息正文
* @return 实际进入发送队列的接收人数
*/
int sendTodayBirthdayMessages(String title, String content);
/**
* 获取最新生日页面配置;未配置时返回空配置对象。
*/
UserBirthdayConfig getConfig();
/**
* 新增或更新生日页面配置。
*
* @param config 背景图片和福利贺卡文件ID
* @return 保存后的配置
*/
UserBirthdayConfig saveConfig(UserBirthdayConfig config);
}
@@ -0,0 +1,85 @@
package com.budwk.app.zhgh.staffmanage.birthday.service.impl;
import cn.hutool.core.date.DateUtil;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.zhgh.staffmanage.birthday.models.UserBirthdayMsgLog;
import com.budwk.app.zhgh.staffmanage.birthday.param.UserBirthdayMsgLogPageForm;
import com.budwk.app.zhgh.staffmanage.birthday.service.UserBirthdayMsgLogService;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Lang;
import org.nutz.lang.random.R;
import org.nutz.lang.util.NutMap;
import java.util.List;
@IocBean(args = {"refer:dao"})
public class UserBirthdayMsgLogServiceImpl extends BaseServiceImpl<UserBirthdayMsgLog>
implements UserBirthdayMsgLogService {
public UserBirthdayMsgLogServiceImpl(Dao dao) {
super(dao);
}
@Override
public Pagination pageData(UserBirthdayMsgLogPageForm pageForm) {
Sql sql = Sqls.create("""
SELECT
log.*,
u.loginname AS loginname,
u.username AS username,
u.unitName AS unitName,
u.unionName AS unionName
FROM user_birthday_msg_log log
LEFT JOIN vw_user u ON u.id = log.receiveBy
$condition
""");
Cnd cnd = Cnd.NEW();
pageForm.buildSearch(cnd, "u.");
cnd.desc("log.pushTime");
sql.setCondition(cnd);
return listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void insertLogs(List<String> loginNames, String title, String content, String link,
String pushBy, String pushByName, String pushType) {
if (Lang.isEmpty(loginNames)) {
return;
}
// 只根据当前批次的工号读取接收人,日志中的用户ID和姓名保持发送当时快照。
Sql sql = Sqls.create("""
SELECT id, loginname, username
FROM vw_user
$condition
""");
sql.setCondition(Cnd.where("loginname", "in", loginNames));
List<NutMap> users = listMap(sql);
String pushTime = DateUtil.now();
List<UserBirthdayMsgLog> logs = users.stream().map(user -> {
UserBirthdayMsgLog log = new UserBirthdayMsgLog();
log.setId(R.UU32());
log.setReceiveBy(user.getString("id"));
log.setReceiveName(user.getString("username"));
log.setMsgTitle(title);
log.setMsgContent(content);
log.setPushBy(pushBy);
log.setPushByName(pushByName);
log.setPushType(pushType);
log.setPushTime(pushTime);
log.setLink(link);
return log;
}).toList();
if (!logs.isEmpty()) {
dao().fastInsert(logs);
}
}
}
@@ -0,0 +1,211 @@
package com.budwk.app.zhgh.staffmanage.birthday.service.impl;
import cn.afterturn.easypoi.excel.ExcelExportUtil;
import cn.afterturn.easypoi.excel.entity.ExportParams;
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.base.utils.CommonDownloadUtil;
import com.budwk.app.sys.models.Sys_msg;
import com.budwk.app.sys.models.Sys_user;
import com.budwk.app.sys.services.SysMsgService;
import com.budwk.app.web.commons.base.Globals;
import com.budwk.app.zhgh.staffmanage.birthday.models.UserBirthdayConfig;
import com.budwk.app.zhgh.staffmanage.birthday.param.UserBirthdayPageForm;
import com.budwk.app.zhgh.staffmanage.birthday.param.UserBirthdaySendMsgForm;
import com.budwk.app.zhgh.staffmanage.birthday.service.UserBirthdayMsgLogService;
import com.budwk.app.zhgh.staffmanage.birthday.service.UserBirthdayService;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Workbook;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Times;
import org.nutz.lang.util.NutMap;
import javax.servlet.http.HttpServletResponse;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@IocBean(args = {"refer:dao"})
public class UserBirthdayServiceImpl extends BaseServiceImpl<Sys_user> implements UserBirthdayService {
@Inject
private SysMsgService sysMsgService;
@Inject
private UserBirthdayMsgLogService userBirthdayMsgLogService;
public UserBirthdayServiceImpl(Dao dao) {
super(dao);
}
@Override
public Pagination pageData(UserBirthdayPageForm pageForm) {
return listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), buildBirthdaySql(pageForm));
}
@Override
public void exportXlsx(UserBirthdayPageForm pageForm, HttpServletResponse response) {
try {
List<NutMap> list = listMap(buildBirthdaySql(pageForm));
List<ExcelExportEntity> columns = new ArrayList<>();
columns.add(new ExcelExportEntity("工号", "loginname", 20));
columns.add(new ExcelExportEntity("姓名", "username", 20));
columns.add(new ExcelExportEntity("性别", "sex", 10));
columns.add(new ExcelExportEntity("联系方式", "mobile", 20));
columns.add(new ExcelExportEntity("出生日期", "birthday", 20));
columns.add(new ExcelExportEntity("生日倒计时(天)", "daysUntilBirthday", 20));
columns.add(new ExcelExportEntity("在职状态", "userState", 20));
columns.add(new ExcelExportEntity("人员类型", "personType", 20));
columns.add(new ExcelExportEntity("人员性质", "preparedBy", 20));
columns.add(new ExcelExportEntity("所属工会", "unionName", 25));
columns.add(new ExcelExportEntity("所属单位", "unitName", 25));
ExportParams exportParams = new ExportParams();
exportParams.setType(ExcelType.XSSF);
try (Workbook workbook = ExcelExportUtil.exportExcel(exportParams, columns, list)) {
CommonDownloadUtil.download("生日人员名单.xlsx", workbook, response);
}
} catch (Exception e) {
log.error("导出生日人员名单失败", e);
throw new RuntimeException("导出生日人员名单失败", e);
}
}
@Override
@Aop(TransAop.READ_COMMITTED)
public int sendByQuery(UserBirthdaySendMsgForm form, String pushBy, String pushByName) {
List<String> loginNames = listMap(buildBirthdaySql(form)).stream()
.map(user -> user.getString("loginname"))
.filter(StrUtil::isNotBlank)
.distinct()
.toList();
return sendMessages(loginNames, form.getTitle(), form.getContent(), pushBy, pushByName, "手动推送");
}
@Override
@Aop(TransAop.READ_COMMITTED)
public boolean sendByUser(UserBirthdaySendMsgForm form, String pushBy, String pushByName) {
Sys_user user = dao().fetch(Sys_user.class, form.getUserId());
if (user == null || StrUtil.isBlank(user.getLoginname())) {
return false;
}
sendMessages(List.of(user.getLoginname()), form.getTitle(), form.getContent(),
pushBy, pushByName, "手动推送");
return true;
}
@Override
@Aop(TransAop.READ_COMMITTED)
public int sendTodayBirthdayMessages(String title, String content) {
Sql sql = Sqls.create("""
SELECT loginname
FROM vw_user
WHERE member = 1
AND birthday IS NOT NULL
AND MONTH(birthday) = MONTH(CURRENT_DATE)
AND DAY(birthday) = DAY(CURRENT_DATE)
""");
sql.setCallback(Sqls.callback.strList());
dao().execute(sql);
List<String> loginNames = sql.getList(String.class);
return sendMessages(loginNames, title, content, "system", "系统推送", "系统推送");
}
@Override
public UserBirthdayConfig getConfig() {
UserBirthdayConfig config = dao().fetch(UserBirthdayConfig.class, Cnd.NEW().desc("updatedAt"));
return config == null ? new UserBirthdayConfig() : config;
}
@Override
@Aop(TransAop.READ_COMMITTED)
public UserBirthdayConfig saveConfig(UserBirthdayConfig config) {
dao().insertOrUpdate(config);
return config;
}
/**
* 构建生日人员公共查询。
* 查询参数包含姓名/工号、组织、人员状态和生日区间;返回字段供列表、导出和批量发送共用。
*/
private Sql buildBirthdaySql(UserBirthdayPageForm pageForm) {
Sql sql = Sqls.create("""
SELECT
u.id AS id,
u.loginname AS loginname,
u.username AS username,
u.sex AS sex,
u.birthday AS birthday,
u.mobile AS mobile,
u.unitName AS unitName,
u.unionName AS unionName,
u.userState AS userState,
u.personType AS personType,
u.preparedBy AS preparedBy,
DATEDIFF(
CASE
WHEN DATE_FORMAT(u.birthday, '%m-%d') >= DATE_FORMAT(NOW(), '%m-%d')
THEN CONCAT(YEAR(NOW()), '-', DATE_FORMAT(u.birthday, '%m-%d'))
ELSE CONCAT(YEAR(NOW()) + 1, '-', DATE_FORMAT(u.birthday, '%m-%d'))
END,
CURDATE()
) AS daysUntilBirthday
FROM vw_user u
$condition
""");
Cnd cnd = Cnd.NEW();
cnd.and("u.birthday", "is not", null);
cnd.and("u.member", "=", 1);
pageForm.buildSearch(cnd, "u.");
pageForm.buildBirthdaySearch(cnd);
cnd.asc("daysUntilBirthday");
sql.setCondition(cnd);
return sql;
}
/**
* 将生日消息写入当前项目消息中心并生成发送记录。
* Sys_msg.url 用于站内消息点击,正文中的完整链接供外部短信/微信渠道访问。
*/
private int sendMessages(List<String> loginNames, String title, String content,
String pushBy, String pushByName, String pushType) {
List<String> recipients = loginNames == null ? List.of() : loginNames.stream()
.filter(StrUtil::isNotBlank)
.distinct()
.toList();
if (recipients.isEmpty()) {
return 0;
}
UserBirthdayConfig config = getConfig();
String pagePath = "/platform/staffManage/birthday/manage/h5";
if (StrUtil.isNotBlank(config.getBirthdayUrl())) {
pagePath = pagePath + "?id=" + config.getBirthdayUrl();
}
String fullLink = Globals.AppDomain + pagePath;
Sys_msg message = new Sys_msg();
message.setTitle(title);
message.setNote(content + "\n" + fullLink);
message.setUrl(pagePath);
message.setType("user");
message.setSendType("show");
message.setSendAt(Times.getTS());
message.setCreatedBy(pushBy);
sysMsgService.saveMsg(message, recipients.toArray(String[]::new), true);
userBirthdayMsgLogService.insertLogs(recipients, title, content, fullLink,
pushBy, pushByName, pushType);
return recipients.size();
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,79 @@
/**
* H5 弹框浏览器历史栈管理器。
* 页面只注册关闭回调;本文件全局统一监听 popstate,避免每个页面重复绑定返回事件。
*/
;((global) => {
const callbacks = new Map()
const popupStack = []
let tokenSeed = 0
let stateSeed = 0
let pendingClear = null
const invokeClose = (entry) => {
const callback = callbacks.get(entry.token)
if (callback) callback(entry.popupKey)
}
global.addEventListener("popstate", (event) => {
if (pendingClear) {
event.stopImmediatePropagation()
const clear = pendingClear
pendingClear = null
clear.resolve()
return
}
if (!popupStack.length) return
// 有弹框历史时消费本次返回事件,阻止 PJAX 将页面直接切回上一个业务页面。
event.stopImmediatePropagation()
const entry = popupStack.pop()
invokeClose(entry)
}, true)
global.h5PopupHistory = {
register(closeCallback) {
tokenSeed += 1
const token = "h5-popup-page-" + tokenSeed
callbacks.set(token, closeCallback)
return token
},
unregister(token) {
callbacks.delete(token)
for (let index = popupStack.length - 1; index >= 0; index -= 1) {
if (popupStack[index].token === token) popupStack.splice(index, 1)
}
},
open(token, popupKey) {
if (!callbacks.has(token)) return
const current = popupStack[popupStack.length - 1]
if (current && current.token === token && current.popupKey === popupKey) return
stateSeed += 1
const entry = {token:token,popupKey:popupKey,stateId:stateSeed}
popupStack.push(entry)
const state = Object.assign({}, global.history.state || {}, {
__h5PopupHistory: {token:token,popupKey:popupKey,stateId:stateSeed}
})
global.history.pushState(state, global.document.title, global.location.href)
},
close(token, popupKey) {
const current = popupStack[popupStack.length - 1]
if (!current || current.token !== token || current.popupKey !== popupKey) return false
global.history.back()
return true
},
clear(token) {
const entries = popupStack.filter((entry) => entry.token === token)
if (!entries.length) return Promise.resolve()
entries.forEach((entry) => invokeClose(entry))
for (let index = popupStack.length - 1; index >= 0; index -= 1) {
if (popupStack[index].token === token) popupStack.splice(index, 1)
}
return new Promise((resolve) => {
pendingClear = {resolve:resolve}
global.history.go(-entries.length)
})
}
}
})(window)
File diff suppressed because one or more lines are too long
@@ -61,6 +61,7 @@
<script src="${base!}/assets/platform/js/mixin/styleMixin.js"></script>
<script src="${base!}/assets/platform/js/tool/businessTool.js"></script>
<script src="${base!}/assets/platform/js/util/h5AutoShowError.js"></script>
<script src="${base!}/assets/platform/js/util/h5PopupHistory.js"></script>
<!--富文本编辑器-->
<link rel="stylesheet" href="${base!}/assets/platform/plugins/wangEditor4/wangEditor.css"/>
@@ -0,0 +1,330 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<guava ref="guava">
<template>
<el-card shadow="never">
<search @search="searchBirthday">
<search-item label="姓名/工号">
<el-input v-model="pageForm.searchKeyword" clearable placeholder="请输入姓名或工号"></el-input>
</search-item>
<search-item label="所属工会">
<el-select v-model="pageForm.unionId" clearable filterable placeholder="请选择所属工会"
style="width:100%" @change="handleUnionChange" @clear="handleUnionChange">
<el-option v-for="item in unions" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
</search-item>
<search-item label="所属单位">
<el-select v-model="pageForm.unitId" clearable filterable placeholder="请选择所属单位" style="width:100%">
<el-option v-for="item in units" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
</search-item>
<search-item label="在职状态">
<dict-select v-model="queryForm.userStates" code="USER_STATE" multiple placeholder="请选择在职状态"></dict-select>
</search-item>
<search-item label="人员类型">
<dict-select v-model="queryForm.personTypes" code="USER_PERSON_TYPE" multiple placeholder="请选择人员类型"></dict-select>
</search-item>
<search-item label="人员性质">
<dict-select v-model="queryForm.preparedBys" code="PREPARED_BY" multiple placeholder="请选择人员性质"></dict-select>
</search-item>
<search-item label="生日日期">
<el-date-picker v-model="birthdayRange" type="daterange" value-format="yyyy-MM-dd"
range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期"
style="width:100%" @change="handleBirthdayRangeChange"></el-date-picker>
</search-item>
</search>
</el-card>
<el-card shadow="never" class="mt10">
<table-tool label="生日人员(默认展示未来15天,日期查询忽略年份)"
:columns.sync="tableColumns">
<el-button size="small" type="primary" @click="doExport">导出Excel</el-button>
<el-button size="small" type="primary" @click="openSendByQuery">发送通知</el-button>
<el-button size="small" type="primary" @click="openConfig">配置图片</el-button>
</table-tool>
<el-table ref="table" v-loading="tableLoading" :data="tableData" :size="tableSize"
row-key="id" style="width:100%" @sort-change="pageOrder">
<el-table-column :index="indexMethod" label="序号" type="index" width="70" align="center"></el-table-column>
<el-table-column v-for="column in tableColumns" v-if="column.visible !== false"
:key="column.prop" :prop="column.prop"
:label="column.label" :width="column.width" :fixed="column.fixed"
:sortable="column.sortable"
header-align="center" show-overflow-tooltip></el-table-column>
<el-table-column fixed="right" label="操作" width="210" align="center">
<template slot-scope="scope">
<el-button size="mini" type="primary" @click="openView(scope.row)">人员信息</el-button>
<el-button size="mini" type="primary" @click="openSendByUser(scope.row)">发送通知</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</template>
<template #view>
<member-info ref="memberInfoRef"></member-info>
</template>
</guava>
<el-dialog title="按查询条件发送生日通知" :visible.sync="sendQueryDialogVisible"
:close-on-click-modal="false" width="50%" @close="closeSendByQuery">
<el-form ref="sendQueryFormRef" :model="sendQueryForm" label-width="90px">
<el-form-item label="发送标题" prop="title"
:rules="[{required:true,message:'请输入发送标题',trigger:['blur','change']}]">
<el-input v-model="sendQueryForm.title" maxlength="255" placeholder="请输入发送标题"></el-input>
</el-form-item>
<el-form-item label="发送内容" prop="content"
:rules="[{required:true,message:'请输入发送内容',trigger:['blur','change']}]">
<el-input v-model="sendQueryForm.content" type="textarea" :rows="6" placeholder="请输入发送内容"></el-input>
</el-form-item>
</el-form>
<span slot="footer">
<el-button @click="closeSendByQuery">取消</el-button>
<el-button type="primary" :loading="formLoading" @click="sendByQuery">确定</el-button>
</span>
</el-dialog>
<el-dialog title="向单个用户发送生日通知" :visible.sync="sendUserDialogVisible"
:close-on-click-modal="false" width="50%" @close="closeSendByUser">
<el-form ref="sendUserFormRef" :model="sendUserForm" label-width="90px">
<el-form-item label="发送对象">
<el-input v-model="sendUserForm.userInfo" readonly></el-input>
</el-form-item>
<el-form-item label="发送标题" prop="title"
:rules="[{required:true,message:'请输入发送标题',trigger:['blur','change']}]">
<el-input v-model="sendUserForm.title" maxlength="255" placeholder="请输入发送标题"></el-input>
</el-form-item>
<el-form-item label="发送内容" prop="content"
:rules="[{required:true,message:'请输入发送内容',trigger:['blur','change']}]">
<el-input v-model="sendUserForm.content" type="textarea" :rows="6" placeholder="请输入发送内容"></el-input>
</el-form-item>
</el-form>
<span slot="footer">
<el-button @click="closeSendByUser">取消</el-button>
<el-button type="primary" :loading="formLoading" @click="sendByUser">确定</el-button>
</span>
</el-dialog>
<el-dialog title="生日祝福页面图片配置" :visible.sync="configDialogVisible"
:close-on-click-modal="false" width="50%" @close="closeConfig">
<el-form ref="configFormRef" :model="configForm" label-width="110px">
<el-form-item label="页面背景图片" prop="picUrl">
<file-upload :value.sync="configForm.picUrl" :upload_number="1" upload_mode="image"
accept=".jpg,.jpeg,.png" upload_result_category="interval"></file-upload>
<div class="text-muted">建议使用竖版生日主题图片。</div>
</el-form-item>
<el-form-item label="福利贺卡图片" prop="birthdayUrl">
<file-upload :value.sync="configForm.birthdayUrl" :upload_number="1" upload_mode="image"
accept=".jpg,.jpeg,.png" upload_result_category="interval"></file-upload>
<div class="text-muted">移动端点击“领取生日福利”后展示该图片。</div>
</el-form-item>
</el-form>
<span slot="footer">
<el-button @click="closeConfig">取消</el-button>
<el-button type="primary" :loading="formLoading" @click="saveConfig">保存</el-button>
</span>
</el-dialog>
</div>
<script nonce="${cspNonce!}">
<!--#include("/platform/zhgh/staffmanage/member/common/info/memberInfo.js"){}#-->
new Vue({
el: "#app",
store,
mixins: [initTableMixins],
data() {
return {
pageForm: {
searchKeyword: "",
unionId: "",
unitId: "",
userStates: "[]",
personTypes: "[]",
preparedBys: "[]",
startDate: "",
endDate: "",
pageNumber: 1,
pageSize: 10,
totalCount: 0,
pageOrderName: "",
pageOrderBy: ""
},
queryForm: {userStates:[],personTypes:[],preparedBys:[]},
birthdayRange: [],
unions: [],
units: [],
tableColumns: [
{prop:"loginname",label:"工号",sortable:true,width:"120"},
{prop:"username",label:"姓名",sortable:true,width:"110"},
{prop:"sex",label:"性别",width:"70"},
{prop:"mobile",label:"联系方式",width:"130"},
{prop:"birthday",label:"出生日期",sortable:true,width:"120"},
{prop:"daysUntilBirthday",label:"生日倒计时(天)",sortable:true,width:"150"},
{prop:"userState",label:"在职状态",width:"100"},
{prop:"personType",label:"人员类型",width:"130"},
{prop:"preparedBy",label:"人员性质",width:"130"},
{prop:"unionName",label:"所属工会",width:"180"},
{prop:"unitName",label:"所属单位",width:"180"}
],
sendQueryDialogVisible: false,
sendQueryForm: {title:"",content:""},
sendUserDialogVisible: false,
sendUserForm: {userId:"",userInfo:"",title:"",content:""},
configDialogVisible: false,
configForm: {id:"",picUrl:"",birthdayUrl:""}
}
},
components: {
"member-info": MEMBER_INFO
},
methods: {
initOrganizationOptions() {
if (this.$auth.hasRoleOr("SYSADMIN, SCHOOL_UNION_ADMIN, SCHOOL_UNION_MEMBER_ADMIN")) {
this.$businessTool.listUnion().then((data) => {
this.$set(this, "unions", data || [])
})
this.$businessTool.listUnit().then((data) => {
this.$set(this, "units", data || [])
})
} else {
const unionId = this.$store.state.user.union.id
this.$businessTool.listUnion(unionId).then((data) => {
this.$set(this, "unions", data || [])
})
this.$businessTool.listUnit(unionId).then((data) => {
this.$set(this, "units", data || [])
})
}
},
handleUnionChange() {
this.$set(this.pageForm, "unitId", "")
this.$set(this, "units", [])
if (this.pageForm.unionId) {
this.$businessTool.listUnit(this.pageForm.unionId).then((data) => {
this.$set(this, "units", data || [])
})
}
},
handleBirthdayRangeChange(value) {
this.$set(this.pageForm, "startDate", value && value.length ? value[0] : "")
this.$set(this.pageForm, "endDate", value && value.length ? value[1] : "")
this.searchBirthday()
},
searchBirthday() {
this.$set(this.pageForm, "userStates", JSON.stringify(this.queryForm.userStates || []))
this.$set(this.pageForm, "personTypes", JSON.stringify(this.queryForm.personTypes || []))
this.$set(this.pageForm, "preparedBys", JSON.stringify(this.queryForm.preparedBys || []))
this.doSearch()
},
requestParams() {
return clone(this.pageForm)
},
doExport() {
this.$downLoad(loc() + "/doExport", this.requestParams())
},
openView(row) {
this.$refs.guava.view()
this.$nextTick(() => {
this.$refs.memberInfoRef.onOpen(row.id)
})
},
openSendByQuery() {
this.$set(this, "sendQueryForm", Object.assign(this.requestParams(), {title:"",content:""}))
this.$set(this, "sendQueryDialogVisible", true)
},
closeSendByQuery() {
this.$set(this, "sendQueryDialogVisible", false)
this.$nextTick(() => {
if (this.$refs.sendQueryFormRef) this.$refs.sendQueryFormRef.clearValidate()
})
},
sendByQuery() {
this.$refs.sendQueryFormRef.validate((valid) => {
if (!valid) return
this.$confirm("确定按当前查询条件发送生日通知吗?", "提示", {type:"warning"}).then(() => {
this.$set(this, "formLoading", true)
this.$axios.post(loc() + "/sendMsgByQueryUsers", this.sendQueryForm).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg)
this.closeSendByQuery()
} else {
this.$message.warning(res.msg)
}
}).finally(() => {
this.$set(this, "formLoading", false)
})
}).catch(() => {})
})
},
openSendByUser(row) {
this.$set(this, "sendUserForm", {userId:row.id,userInfo:row.username + "" + row.loginname + "",title:"",content:""})
this.$set(this, "sendUserDialogVisible", true)
},
closeSendByUser() {
this.$set(this, "sendUserDialogVisible", false)
this.$nextTick(() => {
if (this.$refs.sendUserFormRef) this.$refs.sendUserFormRef.clearValidate()
})
},
sendByUser() {
this.$refs.sendUserFormRef.validate((valid) => {
if (!valid) return
this.$confirm("确定向" + this.sendUserForm.userInfo + "发送生日通知吗?", "提示", {type:"warning"}).then(() => {
this.$set(this, "formLoading", true)
this.$axios.post(loc() + "/sendMsgByUser", this.sendUserForm).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg)
this.closeSendByUser()
} else {
this.$message.warning(res.msg)
}
}).finally(() => {
this.$set(this, "formLoading", false)
})
}).catch(() => {})
})
},
openConfig() {
this.$set(this, "formLoading", true)
this.$axios.post(loc() + "/getConfig").then((res) => {
if (res.code === 0) {
this.$set(this, "configForm", Object.assign({id:"",picUrl:"",birthdayUrl:""}, res.data || {}))
this.$set(this, "configDialogVisible", true)
} else {
this.$message.warning(res.msg)
}
}).finally(() => {
this.$set(this, "formLoading", false)
})
},
closeConfig() {
this.$set(this, "configDialogVisible", false)
},
saveConfig() {
this.$confirm("确定保存生日祝福页面配置吗?", "提示", {type:"warning"}).then(() => {
this.$set(this, "formLoading", true)
this.$axios.post(loc() + "/saveOrModifyConfig", this.configForm).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg)
this.$set(this, "configForm", res.data)
this.closeConfig()
} else {
this.$message.warning(res.msg)
}
}).finally(() => {
this.$set(this, "formLoading", false)
})
}).catch(() => {})
}
},
created() {
this.initOrganizationOptions()
this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,112 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<guava ref="guava">
<template>
<el-card shadow="never">
<search @search="doSearch">
<search-item label="姓名/工号">
<el-input v-model="pageForm.searchKeyword" clearable placeholder="请输入姓名或工号"></el-input>
</search-item>
<search-item label="所属工会">
<el-select v-model="pageForm.unionId" clearable filterable placeholder="请选择所属工会"
style="width:100%" @change="handleUnionChange" @clear="handleUnionChange">
<el-option v-for="item in unions" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
</search-item>
<search-item label="所属单位">
<el-select v-model="pageForm.unitId" clearable filterable placeholder="请选择所属单位" style="width:100%">
<el-option v-for="item in units" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
</search-item>
</search>
</el-card>
<el-card shadow="never" class="mt10">
<table-tool label="生日消息记录" :columns.sync="tableColumns"></table-tool>
<el-table ref="table" v-loading="tableLoading" :data="tableData" :size="tableSize"
row-key="id" style="width:100%" @sort-change="pageOrder">
<el-table-column :index="indexMethod" label="序号" type="index" width="70" align="center"></el-table-column>
<el-table-column v-for="column in tableColumns" v-if="column.visible !== false"
:key="column.prop" :prop="column.prop"
:label="column.label" :width="column.width" :fixed="column.fixed"
:sortable="column.sortable"
header-align="center" show-overflow-tooltip></el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</template>
</guava>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
store,
mixins: [initTableMixins],
data() {
return {
pageForm: {
searchKeyword: "",
unionId: "",
unitId: "",
pageNumber: 1,
pageSize: 10,
totalCount: 0,
pageOrderName: "",
pageOrderBy: ""
},
unions: [],
units: [],
tableColumns: [
{prop:"loginname",label:"工号",sortable:true,width:"120"},
{prop:"username",label:"姓名",sortable:true,width:"110"},
{prop:"unionName",label:"所属工会",width:"180"},
{prop:"unitName",label:"所属单位",width:"180"},
{prop:"msgTitle",label:"消息标题",width:"220"},
{prop:"msgContent",label:"消息内容",width:"260"},
{prop:"pushByName",label:"推送人",width:"120"},
{prop:"pushTime",label:"推送时间",sortable:true,width:"170"},
{prop:"pushType",label:"推送类型"}
]
}
},
methods: {
initOrganizationOptions() {
if (this.$auth.hasRoleOr("SYSADMIN, SCHOOL_UNION_ADMIN, SCHOOL_UNION_MEMBER_ADMIN")) {
this.$businessTool.listUnion().then((data) => {
this.$set(this, "unions", data || [])
})
this.$businessTool.listUnit().then((data) => {
this.$set(this, "units", data || [])
})
} else {
const unionId = this.$store.state.user.union.id
this.$businessTool.listUnion(unionId).then((data) => {
this.$set(this, "unions", data || [])
})
this.$businessTool.listUnit(unionId).then((data) => {
this.$set(this, "units", data || [])
})
}
},
handleUnionChange() {
this.$set(this.pageForm, "unitId", "")
this.$set(this, "units", [])
if (this.pageForm.unionId) {
this.$businessTool.listUnit(this.pageForm.unionId).then((data) => {
this.$set(this, "units", data || [])
})
}
}
},
created() {
this.initOrganizationOptions()
this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,311 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<style scoped>
html, body, #app { margin: 0; width: 100%; height: 100%; overflow: hidden; }
.birthday-page { position: relative; width: 100%; height: 100vh; overflow: hidden; color: #7f1d1d; background: #e76f45; }
.birthday-background { position: absolute; inset: 0; background-position: center; background-size: cover; z-index: 0; }
.birthday-mask { position: absolute; inset: 0; z-index: 1; background: linear-gradient(180deg, rgba(255,255,255,.02), rgba(92,20,8,.16)); }
.brand { position: absolute; top: calc(12px + env(safe-area-inset-top)); left: 14px; z-index: 8; padding: 7px 12px;
border: 1px solid rgba(255,255,255,.68); border-radius: 16px; color: #fff; background: rgba(128,29,16,.2);
font-size: 13px; font-weight: 600; backdrop-filter: blur(6px); }
.member-tag { position: absolute; top: calc(12px + env(safe-area-inset-top)); right: 14px; z-index: 8; padding: 7px 12px;
border-radius: 16px; color: #8a321c; background: rgba(255,244,213,.9); font-size: 12px; font-weight: 600; }
.animation-zone { position: relative; z-index: 3; height: 52%; padding-top: calc(42px + env(safe-area-inset-top)); }
.birthday-font { position: absolute; top: 8%; left: 4%; width: 92%; height: 47%; }
.birthday-cake { position: absolute; left: 12%; bottom: -7%; width: 76%; height: 64%; }
.confetti { position: absolute; top: -12px; width: 7px; height: 12px; border-radius: 3px; opacity: 0;
animation: confetti-fall linear infinite; }
.confetti:nth-child(3n) { background: #ffe16b; }
.confetti:nth-child(3n+1) { background: #fff4dd; }
.confetti:nth-child(3n+2) { background: #e9473f; }
@keyframes confetti-fall { 0% { transform: translate3d(0,-20px,0) rotate(0); opacity: 0; }
12% { opacity: 1; } 100% { transform: translate3d(35px,62vh,0) rotate(540deg); opacity: 0; } }
.greeting-zone { position: relative; z-index: 4; height: 48%; display: flex; flex-direction: column; align-items: center;
justify-content: flex-start; padding: 0 24px calc(20px + env(safe-area-inset-bottom)); box-sizing: border-box; }
.greeting-card { width: 100%; box-sizing: border-box; padding: 20px 20px 16px; border: 1px solid rgba(164,60,26,.3);
border-radius: 18px; background: rgba(255,249,228,.92); box-shadow: 0 12px 30px rgba(109,29,10,.22);
text-align: center; animation: card-enter .7s ease-out both; }
@keyframes card-enter { from { opacity: 0; transform: translateY(28px); } to { opacity: 1; transform: translateY(0); } }
.greeting-title { margin-bottom: 10px; font-size: 18px; font-weight: 700; text-align: left; }
.greeting-content { font-size: 15px; line-height: 1.8; letter-spacing: 1px; }
.receive-button { width: 190px; margin-top: 18px; padding: 13px 20px; border: 0; border-radius: 25px; color: #fff;
background: linear-gradient(135deg, #d9362b, #9d1717); box-shadow: 0 8px 18px rgba(131,21,17,.34);
font-size: 16px; font-weight: 700; letter-spacing: 2px; }
.music-button { position: absolute; right: 14px; bottom: calc(14px + env(safe-area-inset-bottom)); z-index: 9;
width: 42px; height: 42px; border: 1px solid rgba(255,255,255,.7); border-radius: 50%; color: #fff;
background: rgba(121,27,15,.42); font-size: 20px; }
.music-button.playing { animation: music-rotate 3s linear infinite; }
@keyframes music-rotate { to { transform: rotate(360deg); } }
.music-consent-modal { position: fixed; inset: 0; z-index: 10040; display: flex; align-items: center; justify-content: center;
padding: calc(24px + env(safe-area-inset-top)) 24px calc(24px + env(safe-area-inset-bottom)); box-sizing: border-box;
background: rgba(61,13,8,.76); backdrop-filter: blur(7px); }
.music-consent-card { position: relative; width: 100%; max-width: 390px; padding: 70px 24px 26px; box-sizing: border-box;
border: 1px solid rgba(211,148,57,.72); border-radius: 28px; background: #fff8e8;
box-shadow: 0 22px 60px rgba(56,8,4,.48), inset 0 0 0 5px rgba(255,255,255,.44); text-align: center;
animation: music-dialog-enter .48s cubic-bezier(.2,.85,.32,1.16) both; }
@keyframes music-dialog-enter { from { opacity: 0; transform: translateY(24px) scale(.92); }
to { opacity: 1; transform: translateY(0) scale(1); } }
.music-consent-emblem { position: absolute; top: -45px; left: 50%; display: flex; align-items: center; justify-content: center;
width: 90px; height: 90px; border: 6px solid #fff8e8; border-radius: 50%; color: #fff;
background: #d9362b; box-shadow: 0 10px 24px rgba(144,29,20,.34); transform: translateX(-50%); }
.music-consent-emblem .fa { font-size: 36px; }
.music-consent-title { margin: 0; color: #a51f1a; font-size: 31px; line-height: 1.25; font-weight: 800;
letter-spacing: 2px; }
.music-consent-subtitle { margin: 14px 0 0; color: #8b4b2c; font-size: 16px; line-height: 1.7; }
.music-consent-divider { width: 70%; height: 1px; margin: 20px auto 18px; background: #e7b35b; opacity: .72; }
.music-consent-tip { margin-bottom: 18px; color: #9a5d3a; font-size: 13px; line-height: 1.6; }
.music-consent-button { display: flex; align-items: center; justify-content: center; width: 100%; height: 50px;
border-radius: 25px; font-size: 17px; font-weight: 700; letter-spacing: 1px; outline: none; }
.music-consent-button .fa { margin-right: 9px; font-size: 18px; }
.music-consent-button.primary { border: 0; color: #fff; background: #c92921;
box-shadow: 0 9px 20px rgba(174,35,27,.3); }
.music-consent-button.secondary { margin-top: 13px; border: 1px solid #d99a45; color: #9b542b; background: #fffaf0; }
.music-consent-button:active { transform: scale(.98); }
.music-consent-button:focus-visible, .music-button:focus-visible { outline: 3px solid rgba(255,225,107,.9); outline-offset: 3px; }
.coupon-modal { position: fixed; inset: 0; z-index: 10020; display: flex; align-items: center; justify-content: center;
padding: calc(22px + env(safe-area-inset-top)) 20px calc(22px + env(safe-area-inset-bottom));
box-sizing: border-box; background: rgba(43,10,7,.72); backdrop-filter: blur(5px); }
.coupon-panel { position: relative; width: 100%; max-width: 430px; max-height: 88vh; padding: 12px; box-sizing: border-box;
border-radius: 20px; background: #fff7e6; box-shadow: 0 18px 55px rgba(40,5,0,.45); overflow: auto; }
.coupon-close { position: absolute; top: 18px; right: 18px; z-index: 2; width: 34px; height: 34px; border: 0;
border-radius: 50%; color: #fff; background: rgba(87,17,13,.72); font-size: 24px; line-height: 34px; }
.coupon-image { display: block; width: 100%; border-radius: 14px; }
.coupon-empty { min-height: 390px; padding: 70px 28px 30px; box-sizing: border-box; border: 2px dashed #d59569;
border-radius: 14px; text-align: center; background: linear-gradient(155deg, #fff7df, #ffd9bd); }
.coupon-empty-icon { font-size: 74px; }
.coupon-empty-title { margin-top: 18px; color: #a21e19; font-size: 28px; font-weight: 800; }
.coupon-empty-text { margin-top: 24px; color: #7b3a2a; font-size: 15px; line-height: 1.8; }
.coupon-tip { padding: 10px 4px 2px; color: #7b3a2a; font-size: 12px; line-height: 1.6; text-align: center; }
.loading-cover { position: fixed; inset: 0; z-index: 10030; display: flex; align-items: center; justify-content: center;
color: #fff; background: rgba(76,20,10,.42); font-size: 14px; }
@media (max-width: 375px) {
.animation-zone { height: 48%; }
.greeting-zone { height: 52%; padding-left: 18px; padding-right: 18px; }
.greeting-card { padding: 16px; }
.greeting-content { font-size: 14px; line-height: 1.65; }
.receive-button { margin-top: 12px; }
.music-consent-card { padding: 64px 20px 22px; }
.music-consent-title { font-size: 27px; }
}
@media (prefers-reduced-motion: reduce) {
.music-consent-card, .greeting-card, .confetti, .music-button.playing { animation: none; }
}
</style>
<div id="app">
<div class="birthday-page">
<div class="birthday-background" :style="{backgroundImage:'url(' + config.backgroundUrl + ')'}"></div>
<div class="birthday-mask"></div>
<div class="brand">{{ appName }}</div>
<div class="member-tag">会员专享</div>
<div class="animation-zone">
<div ref="fontAnimation" class="birthday-font"></div>
<div ref="cakeAnimation" class="birthday-cake"></div>
<i v-for="item in confettiItems" :key="item.id" class="confetti" :style="item.style"></i>
</div>
<div class="greeting-zone">
<div class="greeting-card">
<div class="greeting-title">亲爱的 {{ userName }} 老师:</div>
<div class="greeting-content">
步履所至,皆有回响,心之所向,岁月成诗。<br>
在这个特别的日子里,校工会祝您:<br>
生日快乐,幸福安康!
</div>
</div>
<button class="receive-button" @click="openCoupon">领取生日福利</button>
</div>
<button type="button" class="music-button" :class="{playing:musicPlaying}"
:aria-label="musicPlaying ? '暂停音乐' : '播放音乐'" @click="toggleMusic">
<i class="fa fa-music" aria-hidden="true"></i>
</button>
<div v-if="showMusicPrompt" class="music-consent-modal">
<section class="music-consent-card" role="dialog" aria-modal="true" aria-labelledby="birthdayMusicTitle">
<div class="music-consent-emblem" aria-hidden="true">
<i class="fa fa-music"></i>
</div>
<h2 id="birthdayMusicTitle" class="music-consent-title">祝您生日快乐</h2>
<p class="music-consent-subtitle">愿美好与祝福伴随您的每一天</p>
<div class="music-consent-divider"></div>
<div class="music-consent-tip">开启音乐,沉浸感受生日祝福</div>
<button type="button" class="music-consent-button primary" @click="enableBirthdayMusic">
<i class="fa fa-music" aria-hidden="true"></i>
开启音乐
</button>
<button type="button" class="music-consent-button secondary" @click="declineBirthdayMusic">暂不开启</button>
</section>
</div>
<div v-if="showCoupon" class="coupon-modal" @click.self="closeCoupon">
<div class="coupon-panel">
<button class="coupon-close" @click="closeCoupon">×</button>
<img v-if="config.couponImageUrl" class="coupon-image" :src="config.couponImageUrl" alt="生日福利贺卡">
<div v-else class="coupon-empty">
<div class="coupon-empty-icon">🎂</div>
<div class="coupon-empty-title">生日快乐</div>
<div class="coupon-empty-text">校工会为您送上诚挚祝福。<br>福利贺卡尚未配置,请联系工作人员。</div>
</div>
<div class="coupon-tip">请按福利贺卡上的说明领取和使用生日福利</div>
</div>
</div>
<div v-if="pageLoading" class="loading-cover">生日祝福加载中...</div>
</div>
</div>
<script src="${base!}/assets/platform/plugins/lottie/lottie.min.js"></script>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
appName: "${AppName!}",
userName: "${userName!}",
pageLoading: false,
showMusicPrompt: false,
musicPromptResolved: false,
showCoupon: false,
musicPlaying: false,
popupHistoryToken: "",
fontAnimation: null,
cakeAnimation: null,
audio: null,
config: {
backgroundUrl: "${base!}/assets/mobile/img/birthday/custom-bg.png",
couponImageUrl: "",
musicUrl: "${base!}/assets/mobile/music/birthday/birthdaySong.mp3",
fontAnimationUrl: "${base!}/assets/mobile/lottie/birthday/happyBirthday.json",
cakeAnimationUrl: "${base!}/assets/mobile/lottie/birthday/lihua.json"
},
confettiItems: Array.from({length:28}, (value, index) => ({
id:index,
style:{
left:(index * 37 % 100) + "%",
animationDelay:((index * 13 % 30) / 10) + "s",
animationDuration:(3 + (index % 5) * .45) + "s"
}
}))
}
},
methods: {
resolveFileUrl(value) {
if (!value) return ""
if (value.indexOf("http://") === 0 || value.indexOf("https://") === 0 || value.indexOf("/") === 0) return value
return "/platform/sys/file/download?id=" + encodeURIComponent(value)
},
loadConfig() {
this.$set(this, "pageLoading", true)
this.$axios.post("/platform/staffManage/birthday/manage/getConfig").then((res) => {
if (res.code !== 0 || !res.data) return
if (res.data.picUrl) this.$set(this.config, "backgroundUrl", this.resolveFileUrl(res.data.picUrl))
const linkFileId = "${fileId!}"
const couponFile = linkFileId || res.data.birthdayUrl
this.$set(this.config, "couponImageUrl", this.resolveFileUrl(couponFile))
}).finally(() => {
this.$set(this, "pageLoading", false)
})
},
loadAnimations() {
if (window.lottie && this.$refs.fontAnimation) {
this.$set(this, "fontAnimation", window.lottie.loadAnimation({
container:this.$refs.fontAnimation,
renderer:"svg",
loop:true,
autoplay:true,
path:this.config.fontAnimationUrl
}))
}
if (window.lottie && this.$refs.cakeAnimation) {
this.$set(this, "cakeAnimation", window.lottie.loadAnimation({
container:this.$refs.cakeAnimation,
renderer:"svg",
loop:true,
autoplay:true,
path:this.config.cakeAnimationUrl
}))
}
},
openCoupon() {
if (this.showCoupon) return
this.$set(this, "showCoupon", true)
window.h5PopupHistory.open(this.popupHistoryToken, "birthdayCoupon")
},
closeCoupon() {
const historyHandled = window.h5PopupHistory.close(this.popupHistoryToken, "birthdayCoupon")
if (!historyHandled) this.closeCouponState()
},
closeCouponState() {
this.$set(this, "showCoupon", false)
},
openMusicPrompt() {
if (this.showMusicPrompt || this.musicPromptResolved) return
this.$set(this, "showMusicPrompt", true)
window.h5PopupHistory.open(this.popupHistoryToken, "birthdayMusicPrompt")
},
closeMusicPrompt() {
if (!this.showMusicPrompt) return
// 先同步关闭界面,再回退弹框历史,避免部分 WebView 延迟 popstate 导致弹窗无法关闭。
this.closeMusicPromptState()
window.h5PopupHistory.close(this.popupHistoryToken, "birthdayMusicPrompt")
},
closeMusicPromptState() {
this.$set(this, "showMusicPrompt", false)
this.$set(this, "musicPromptResolved", true)
},
enableBirthdayMusic() {
if (!this.showMusicPrompt) return
this.closeMusicPrompt()
this.playMusic()
},
declineBirthdayMusic() {
if (!this.showMusicPrompt) return
this.closeMusicPrompt()
},
playMusic() {
if (!this.audio) this.$set(this, "audio", new Audio(this.config.musicUrl))
this.audio.loop = true
this.audio.volume = .3
this.audio.play().then(() => {
this.$set(this, "musicPlaying", true)
}).catch(() => {
this.$set(this, "musicPlaying", false)
})
},
toggleMusic() {
if (this.musicPlaying && this.audio) {
this.audio.pause()
this.$set(this, "musicPlaying", false)
return
}
this.playMusic()
}
},
mounted() {
this.$set(this, "popupHistoryToken", window.h5PopupHistory.register((popupKey) => {
if (popupKey === "birthdayCoupon") this.closeCouponState()
if (popupKey === "birthdayMusicPrompt") this.closeMusicPromptState()
}))
this.loadConfig()
this.loadAnimations()
this.$nextTick(() => {
this.openMusicPrompt()
})
},
beforeDestroy() {
window.h5PopupHistory.unregister(this.popupHistoryToken)
if (this.fontAnimation) this.fontAnimation.destroy()
if (this.cakeAnimation) this.cakeAnimation.destroy()
if (this.audio) {
this.audio.pause()
this.audio.src = ""
}
}
})
</script>
<!--#
}
#-->