Compare commits

8 Commits
Author SHA1 Message Date
c-zhangr1 876a0a6a6e change 2026-11-06 13:54:48 +08:00
c-zhouhf1 aae28a6b4b 华住会更新人员整改 2026-10-20 12:09:27 +08:00
c-zhouhf1 17fc9c7c83 华住会更新人员整改 2026-10-15 09:23:53 +08:00
c-zhouhf1 22a0e8f383 commit 2026-10-12 20:38:40 +08:00
c-zhouhf1 f58a3b2caf commit 2026-10-12 20:37:51 +08:00
c-chenxs1 fb5525bc86 feat: 补充脚本 2026-09-24 14:54:37 +08:00
c-chenxs1 34f37010ab feat: 整改文档(补充生日模板配置) 2026-09-24 11:50:14 +08:00
c-chenxs1 29e871bd51 feat: 整改文档(还差生日模板配置) 2026-09-24 11:30:38 +08:00
254 changed files with 45169 additions and 11177 deletions
+29
View File
@@ -0,0 +1,29 @@
# Maven
/target/
/dependency-reduced-pom.xml
# IDE
/.idea/
/.vscode/
*.iml
*.ipr
*.iws
/.classpath
/.project
/.settings/
# Logs and temporary files
/logs/
/temp/
*.log
*.tmp
hs_err_pid*
replay_pid*
# Operating system
.DS_Store
Thumbs.db
Desktop.ini
src/main/resources/application.yaml
src/main/resources/application-dev.yaml
+277
View File
@@ -0,0 +1,277 @@
-- v3 教职工社团数据迁移至 v4。
-- 仅迁移可明确映射的数据;不伪造审核人、审核意见或没有来源的业务字段。
-- 执行要求:指定 zhgh_swufe 为默认库,且 mysql 客户端禁止使用 --force。
USE zhgh_swufe;
SET @migration_time = UNIX_TIMESTAMP() * 1000;
SET @backup_suffix = '20260813_v3_club';
SET @create_backup = (
SELECT CASE WHEN COUNT(*) = 0 THEN 1 ELSE 0 END
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'bak_sys_club_20260813_v3_club'
);
-- 本批次专属备份:保留清理前的业务表、社团角色和对应流程全链路。
CREATE TABLE IF NOT EXISTS bak_sys_club_20260813_v3_club AS SELECT * FROM sys_club WHERE 1 = 0;
INSERT INTO bak_sys_club_20260813_v3_club SELECT * FROM sys_club WHERE @create_backup = 1;
CREATE TABLE IF NOT EXISTS bak_club_user_20260813_v3_club AS SELECT * FROM club_user WHERE 1 = 0;
INSERT INTO bak_club_user_20260813_v3_club SELECT * FROM club_user WHERE @create_backup = 1;
CREATE TABLE IF NOT EXISTS bak_club_user_apply_20260813_v3_club AS SELECT * FROM club_user_apply WHERE 1 = 0;
INSERT INTO bak_club_user_apply_20260813_v3_club SELECT * FROM club_user_apply WHERE @create_backup = 1;
CREATE TABLE IF NOT EXISTS bak_club_pay_record_20260813_v3_club AS SELECT * FROM club_pay_record WHERE 1 = 0;
INSERT INTO bak_club_pay_record_20260813_v3_club SELECT * FROM club_pay_record WHERE @create_backup = 1;
CREATE TABLE IF NOT EXISTS bak_sys_club_sponsor_20260813_v3_club AS SELECT * FROM sys_club_sponsor WHERE 1 = 0;
INSERT INTO bak_sys_club_sponsor_20260813_v3_club SELECT * FROM sys_club_sponsor WHERE @create_backup = 1;
CREATE TABLE IF NOT EXISTS bak_sys_club_evaluate_20260813_v3_club AS SELECT * FROM sys_club_evaluate WHERE 1 = 0;
INSERT INTO bak_sys_club_evaluate_20260813_v3_club SELECT * FROM sys_club_evaluate WHERE @create_backup = 1;
CREATE TABLE IF NOT EXISTS bak_sys_club_examine_register_20260813_v3_club AS SELECT * FROM sys_club_examine_register WHERE 1 = 0;
INSERT INTO bak_sys_club_examine_register_20260813_v3_club SELECT * FROM sys_club_examine_register WHERE @create_backup = 1;
CREATE TABLE IF NOT EXISTS bak_sys_club_examine_register_detailed_20260813_v3_club AS SELECT * FROM sys_club_examine_register_detailed WHERE 1 = 0;
INSERT INTO bak_sys_club_examine_register_detailed_20260813_v3_club SELECT * FROM sys_club_examine_register_detailed WHERE @create_backup = 1;
CREATE TABLE IF NOT EXISTS bak_sys_club_manager_20260813_v3_club AS SELECT * FROM sys_club_manager WHERE 1 = 0;
INSERT INTO bak_sys_club_manager_20260813_v3_club SELECT * FROM sys_club_manager WHERE @create_backup = 1;
CREATE TABLE IF NOT EXISTS bak_sys_club_refresh_20260813_v3_club AS SELECT * FROM sys_club_refresh WHERE 1 = 0;
INSERT INTO bak_sys_club_refresh_20260813_v3_club SELECT * FROM sys_club_refresh WHERE @create_backup = 1;
CREATE TABLE IF NOT EXISTS bak_sys_club_rule_20260813_v3_club AS SELECT * FROM sys_club_rule WHERE 1 = 0;
INSERT INTO bak_sys_club_rule_20260813_v3_club SELECT * FROM sys_club_rule WHERE @create_backup = 1;
CREATE TABLE IF NOT EXISTS bak_sys_club_user_20260813_v3_club AS SELECT * FROM sys_club_user WHERE 1 = 0;
INSERT INTO bak_sys_club_user_20260813_v3_club SELECT * FROM sys_club_user WHERE @create_backup = 1;
CREATE TABLE IF NOT EXISTS bak_sys_club_user_back_history_20260813_v3_club AS SELECT * FROM sys_club_user_back_history WHERE 1 = 0;
INSERT INTO bak_sys_club_user_back_history_20260813_v3_club SELECT * FROM sys_club_user_back_history WHERE @create_backup = 1;
CREATE TABLE IF NOT EXISTS bak_sys_club_files_20260813_v3_club AS SELECT * FROM sys_club_files WHERE 1 = 0;
INSERT INTO bak_sys_club_files_20260813_v3_club SELECT * FROM sys_club_files WHERE @create_backup = 1;
CREATE TABLE IF NOT EXISTS bak_sys_user_role_club_20260813_v3_club AS SELECT * FROM sys_user_role WHERE 1 = 0;
INSERT INTO bak_sys_user_role_club_20260813_v3_club SELECT * FROM sys_user_role WHERE NULLIF(TRIM(clubId), '') IS NOT NULL AND @create_backup = 1;
CREATE TABLE IF NOT EXISTS bak_wf_process_instance_club_20260813_v3_club AS SELECT i.* FROM wf_process_instance i WHERE 1 = 0;
INSERT INTO bak_wf_process_instance_club_20260813_v3_club
SELECT i.* FROM wf_process_instance i JOIN wf_process_define d ON d.id = i.processDefineId
WHERE d.name IN ('XHZC','XHRH','XHTH','XHPY','XHNS','XHBGLS','XHHJBG','XHZCXD') AND @create_backup = 1;
CREATE TABLE IF NOT EXISTS bak_wf_process_task_club_20260813_v3_club AS SELECT * FROM wf_process_task WHERE 1 = 0;
INSERT INTO bak_wf_process_task_club_20260813_v3_club
SELECT t.* FROM wf_process_task t JOIN bak_wf_process_instance_club_20260813_v3_club i ON i.id = t.processInstanceId WHERE @create_backup = 1;
CREATE TABLE IF NOT EXISTS bak_wf_process_task_actor_club_20260813_v3_club AS SELECT * FROM wf_process_task_actor WHERE 1 = 0;
INSERT INTO bak_wf_process_task_actor_club_20260813_v3_club
SELECT a.* FROM wf_process_task_actor a JOIN bak_wf_process_task_club_20260813_v3_club t ON t.id = a.processTaskId WHERE @create_backup = 1;
CREATE TABLE IF NOT EXISTS bak_wf_process_cc_instance_club_20260813_v3_club AS SELECT * FROM wf_process_cc_instance WHERE 1 = 0;
INSERT INTO bak_wf_process_cc_instance_club_20260813_v3_club
SELECT c.* FROM wf_process_cc_instance c JOIN bak_wf_process_instance_club_20260813_v3_club i ON i.id = c.processInstanceId WHERE @create_backup = 1;
CREATE TABLE IF NOT EXISTS bak_outlay_manage_club_refs_20260813_v3_club AS
SELECT o.* FROM outlay_manage_club o JOIN sys_club c ON c.id = o.clubId WHERE @create_backup = 1;
CREATE TABLE IF NOT EXISTS bak_article_club_refs_20260813_v3_club AS
SELECT a.* FROM article a JOIN sys_club c ON c.id = a.clubId WHERE @create_backup = 1;
DROP TEMPORARY TABLE IF EXISTS tmp_club_guard;
CREATE TEMPORARY TABLE tmp_club_guard (name VARCHAR(64), actual BIGINT, expected BIGINT, bad INT AS (actual <> expected) STORED) ENGINE=InnoDB;
INSERT INTO tmp_club_guard(name, actual, expected) SELECT 'source_club_count', COUNT(*), 12 FROM union_swufe.sys_club;
INSERT INTO tmp_club_guard(name, actual, expected) SELECT 'source_valid_member_count', COUNT(*), 14 FROM union_swufe.sys_club_user WHERE clubid IS NOT NULL AND status = 5;
INSERT INTO tmp_club_guard(name, actual, expected) SELECT 'source_sponsor_count', COUNT(*), 2 FROM union_swufe.sys_club_sponsor;
INSERT INTO tmp_club_guard(name, actual, expected)
SELECT 'source_users_missing_v4', COUNT(*), 0
FROM union_swufe.sys_club_user s LEFT JOIN sys_user u
ON CONVERT(u.id USING utf8mb4) = CONVERT(s.userid USING utf8mb4) COLLATE utf8mb4_0900_ai_ci
WHERE s.clubid IS NOT NULL AND s.status = 5 AND u.id IS NULL;
INSERT INTO tmp_club_guard(name, actual, expected)
SELECT 'source_club_contact_missing_v4', COUNT(*), 0
FROM union_swufe.sys_club s LEFT JOIN sys_user u
ON CONVERT(u.id USING utf8mb4) = CONVERT(s.concatPerson USING utf8mb4) COLLATE utf8mb4_0900_ai_ci
WHERE NULLIF(TRIM(s.concatPerson), '') IS NOT NULL AND u.id IS NULL;
DROP TEMPORARY TABLE IF EXISTS tmp_club_guard_fail;
CREATE TEMPORARY TABLE tmp_club_guard_fail(id INT NOT NULL) ENGINE=InnoDB;
INSERT INTO tmp_club_guard_fail SELECT NULL FROM tmp_club_guard WHERE bad = 1 LIMIT 1;
START TRANSACTION;
-- 先删除流程子表,再删除社团业务表,避免残留无主任务和角色关系。
DELETE a FROM wf_process_task_actor a
JOIN wf_process_task t ON t.id = a.processTaskId
JOIN wf_process_instance i ON i.id = t.processInstanceId
JOIN wf_process_define d ON d.id = i.processDefineId
WHERE d.name IN ('XHZC','XHRH','XHTH','XHPY','XHNS','XHBGLS','XHHJBG','XHZCXD');
DELETE c FROM wf_process_cc_instance c
JOIN wf_process_instance i ON i.id = c.processInstanceId
JOIN wf_process_define d ON d.id = i.processDefineId
WHERE d.name IN ('XHZC','XHRH','XHTH','XHPY','XHNS','XHBGLS','XHHJBG','XHZCXD');
DELETE t FROM wf_process_task t
JOIN wf_process_instance i ON i.id = t.processInstanceId
JOIN wf_process_define d ON d.id = i.processDefineId
WHERE d.name IN ('XHZC','XHRH','XHTH','XHPY','XHNS','XHBGLS','XHHJBG','XHZCXD');
DELETE i FROM wf_process_instance i
JOIN wf_process_define d ON d.id = i.processDefineId
WHERE d.name IN ('XHZC','XHRH','XHTH','XHPY','XHNS','XHBGLS','XHHJBG','XHZCXD');
DELETE FROM sys_user_role WHERE NULLIF(TRIM(clubId), '') IS NOT NULL;
DELETE FROM club_pay_record;
DELETE FROM club_user_apply;
DELETE FROM club_user;
DELETE FROM sys_club_evaluate;
DELETE FROM sys_club_examine_register_detailed;
DELETE FROM sys_club_examine_register;
DELETE FROM sys_club_manager;
DELETE FROM sys_club_refresh;
DELETE FROM sys_club_rule;
DELETE FROM sys_club_sponsor;
DELETE FROM sys_club_user_back_history;
DELETE FROM sys_club_user;
DELETE FROM sys_club_files;
DELETE FROM sys_club;
-- v3 社团主数据:字段不对应的值保持为空,不做兜底拼装。
INSERT INTO sys_club(
id, clubName, clubCode, clubType, concatPerson, concatPersonMobile, concatPersonEmail,
introduce, purpose, createTime, foundTime, dismiss, due, files, establishReport,
rulesFile, manageFile, yearPlanFile, userId, QQGroupCode, wechatGroupCode,
replaceReport, createdBy, createdAt, updatedBy, updatedAt, delFlag,
afterRulesFile, schoolReplyFile, state, ruleState, reportState
)
SELECT
s.id, s.name, s.code,
CASE s.type WHEN 'tyjj' THEN '体育竞技' WHEN 'whys' THEN '文化艺术' ELSE NULL END,
s.concatPerson, s.concatPersonMobile, s.concatPersonEmail,
s.stjs, s.stzc, s.create_time, s.foundTime, COALESCE(s.isjs, 0), s.dues_standard,
NULL, NULL, NULL, NULL, NULL, s.userid,
CAST(s.QQGroupCode AS CHAR), CAST(s.wechatGroupCode AS CHAR), NULL,
s.userid,
CASE WHEN STR_TO_DATE(s.create_time, '%Y-%m-%d %H:%i:%s') IS NULL THEN NULL ELSE UNIX_TIMESTAMP(STR_TO_DATE(s.create_time, '%Y-%m-%d %H:%i:%s')) * 1000 END,
s.userid,
CASE WHEN STR_TO_DATE(s.create_time, '%Y-%m-%d %H:%i:%s') IS NULL THEN NULL ELSE UNIX_TIMESTAMP(STR_TO_DATE(s.create_time, '%Y-%m-%d %H:%i:%s')) * 1000 END,
0, NULL, NULL, CAST(s.state AS CHAR), s.ruleState, NULL
FROM union_swufe.sys_club s;
INSERT INTO sys_club_sponsor(id, clubId, sponsorId, createdBy, createdAt, updatedBy, updatedAt, delFlag)
SELECT id, clubId, sponsorId, sponsorId, @migration_time, sponsorId, @migration_time, 0
FROM union_swufe.sys_club_sponsor;
-- 正式成员仅迁移 clubId 明确且 status=5 的14条;2条无社团ID和1条待审核记录不强迁。
INSERT INTO club_user(id, clubId, userId, roleCode, clubPosition, email, avatar,
sameTimeJoinOtherClubSituation, awardsExperience,
createdBy, createdAt, updatedBy, updatedAt, delFlag)
SELECT s.id, s.clubid, s.userid,
JSON_ARRAY(CASE s.sf
WHEN 1 THEN 'CLUB_PRESIDENT'
WHEN 2 THEN 'CLUB_MEMBER'
WHEN 3 THEN 'CLUB_VICE_PRESIDENT'
WHEN 4 THEN 'CLUB_SECRETARY'
WHEN 5 THEN 'CLUB_VICE_SECRETARY'
END),
s.position, NULL, NULL, NULL, NULL,
s.userid,
CASE WHEN STR_TO_DATE(s.join_time, '%Y-%m-%d %H:%i:%s') IS NULL THEN @migration_time ELSE UNIX_TIMESTAMP(STR_TO_DATE(s.join_time, '%Y-%m-%d %H:%i:%s')) * 1000 END,
s.userid, @migration_time, 0
FROM union_swufe.sys_club_user s
WHERE s.clubid IS NOT NULL AND s.status = 5 AND s.sf IN (1,2,3,4,5);
-- 保留旧兼容表可明确对应的字段,供现有统计和旧查询使用。
INSERT INTO sys_club_user(
id, clubId, userId, joinTime, roleCode, payed, giveMoney, status, exitStatus,
state, changeRoleCode, roleCodeChangeTime, auditList, exitAuditList, isNormal,
changeTime, position, mode, email, avatar, sameTimeJoinOtherClubSituation,
awardsExperience, createdBy, createdAt, updatedBy, updatedAt, delFlag
)
SELECT s.id, s.clubid, s.userid, s.join_time,
CASE s.sf WHEN 1 THEN 'CLUB_PRESIDENT' WHEN 2 THEN 'CLUB_MEMBER'
WHEN 3 THEN 'CLUB_VICE_PRESIDENT' WHEN 4 THEN 'CLUB_SECRETARY'
WHEN 5 THEN 'CLUB_VICE_SECRETARY' END,
s.isjf, s.giveMoney, s.status, NULL, s.state,
CASE s.changeSf WHEN 1 THEN 'CLUB_PRESIDENT' WHEN 2 THEN 'CLUB_MEMBER'
WHEN 3 THEN 'CLUB_VICE_PRESIDENT' WHEN 4 THEN 'CLUB_SECRETARY'
WHEN 5 THEN 'CLUB_VICE_SECRETARY' END,
s.sfChangeTime, s.auditList, NULL, s.isNormal, s.changeTime, s.position,
TRUE, NULL, NULL, NULL, NULL, s.userid,
CASE WHEN STR_TO_DATE(s.join_time, '%Y-%m-%d %H:%i:%s') IS NULL THEN @migration_time ELSE UNIX_TIMESTAMP(STR_TO_DATE(s.join_time, '%Y-%m-%d %H:%i:%s')) * 1000 END,
s.userid, @migration_time, 0
FROM union_swufe.sys_club_user s
WHERE s.clubid IS NOT NULL AND s.status = 5 AND s.sf IN (1,2,3,4,5);
-- 社团角色必须按 v4 角色编码重新取 roleId,禁止复制 v3 角色ID。
INSERT INTO sys_user_role(roleId, userId, clubId)
SELECT r.id, s.userid, s.clubid
FROM union_swufe.sys_club_user s
JOIN sys_role r ON r.code = CASE s.sf
WHEN 1 THEN 'CLUB_PRESIDENT' WHEN 2 THEN 'CLUB_MEMBER'
WHEN 3 THEN 'CLUB_VICE_PRESIDENT' WHEN 4 THEN 'CLUB_SECRETARY'
WHEN 5 THEN 'CLUB_VICE_SECRETARY' END
WHERE s.clubid IS NOT NULL AND s.status = 5 AND s.sf IN (1,2,3,4,5);
-- 旧系统没有 v4 流程表。12个社团均为930且审核ID为空,只创建已完成注册实例,
-- 用 legacyMigration/directPassNote 明确标记迁移事实,不生成虚假的审核任务、审核人和意见。
SET @xhzc_define_id = (SELECT id FROM wf_process_define WHERE name = 'XHZC' AND state = 1 AND delFlag = 0 ORDER BY version DESC, id DESC LIMIT 1);
INSERT INTO wf_process_instance(
parentId, processDefineId, state, parentNodeName, businessNo, operator, expireTime,
variable, createdBy, createdAt, updatedBy, updatedAt, delFlag
)
SELECT NULL, @xhzc_define_id, 20, NULL, s.id, s.userid, NULL,
JSON_OBJECT(
'f_data', JSON_OBJECT('id', s.id, 'clubName', s.name, 'clubCode', s.code,
'clubType', CASE s.type WHEN 'tyjj' THEN '体育竞技' WHEN 'whys' THEN '文化艺术' ELSE NULL END,
'concatPerson', s.concatPerson, 'foundTime', s.foundTime, 'createTime', s.create_time),
'initiator', s.userid,
'initiatorName', COALESCE(u.username, ''),
'initiatorAccount', COALESCE(u.loginname, ''),
'initiatorUnitId', COALESCE(u.unitId, ''),
'initiatorUnitName', COALESCE(unit.name, ''),
'submitType', 0,
'legacyMigration', TRUE,
'legacySource', 'union_swufe',
'legacyState', s.state,
'legacyProcess', '社团注册',
'directPass', TRUE,
'directPassNote', 'v3社团状态为930且审核ID为空,仅迁移为已完成实例,未伪造审核任务和审核意见'
),
s.userid,
CASE WHEN STR_TO_DATE(s.create_time, '%Y-%m-%d %H:%i:%s') IS NULL THEN @migration_time ELSE UNIX_TIMESTAMP(STR_TO_DATE(s.create_time, '%Y-%m-%d %H:%i:%s')) * 1000 END,
s.userid, @migration_time, 0
FROM union_swufe.sys_club s
LEFT JOIN sys_user u ON CONVERT(u.id USING utf8mb4) = CONVERT(s.userid USING utf8mb4) COLLATE utf8mb4_0900_ai_ci
LEFT JOIN sys_unit unit ON unit.id = u.unitId
WHERE s.state = 930;
-- 原 v4 社团ID被经费/文章引用。仅同名可明确匹配的3个社团转换到v3 ID;
-- 其余无同名目标的数据保留业务记录,但将clubId置空,避免形成错误社团关联。
UPDATE outlay_manage_club o
JOIN bak_sys_club_20260813_v3_club oldc ON oldc.id = o.clubId
LEFT JOIN union_swufe.sys_club newc
ON CONVERT(newc.name USING utf8mb4) = CONVERT(oldc.clubName USING utf8mb4) COLLATE utf8mb4_0900_ai_ci
SET o.clubId = newc.id;
UPDATE article a
JOIN bak_sys_club_20260813_v3_club oldc ON oldc.id = a.clubId
LEFT JOIN union_swufe.sys_club newc
ON CONVERT(newc.name USING utf8mb4) = CONVERT(oldc.clubName USING utf8mb4) COLLATE utf8mb4_0900_ai_ci
SET a.clubId = newc.id;
-- 原库中少量clubId本来就无法关联旧社团主表;迁移后统一置空,禁止误连到新社团。
UPDATE outlay_manage_club o
LEFT JOIN sys_club c ON c.id = o.clubId
SET o.clubId = NULL
WHERE o.clubId IS NOT NULL AND c.id IS NULL;
UPDATE article a
LEFT JOIN sys_club c ON c.id = a.clubId
SET a.clubId = NULL
WHERE a.clubId IS NOT NULL AND c.id IS NULL;
DROP TEMPORARY TABLE IF EXISTS tmp_club_final_guard;
CREATE TEMPORARY TABLE tmp_club_final_guard(name VARCHAR(64), actual BIGINT, expected BIGINT, bad INT AS (actual <> expected) STORED) ENGINE=InnoDB;
INSERT INTO tmp_club_final_guard(name, actual, expected) SELECT 'target_club_count', COUNT(*), 12 FROM sys_club;
INSERT INTO tmp_club_final_guard(name, actual, expected) SELECT 'target_member_count', COUNT(*), 14 FROM club_user;
INSERT INTO tmp_club_final_guard(name, actual, expected) SELECT 'target_legacy_member_count', COUNT(*), 14 FROM sys_club_user;
INSERT INTO tmp_club_final_guard(name, actual, expected) SELECT 'target_sponsor_count', COUNT(*), 2 FROM sys_club_sponsor;
INSERT INTO tmp_club_final_guard(name, actual, expected) SELECT 'target_club_role_count', COUNT(*), 14 FROM sys_user_role WHERE NULLIF(TRIM(clubId), '') IS NOT NULL;
INSERT INTO tmp_club_final_guard(name, actual, expected)
SELECT 'target_register_instance_count', COUNT(*), 12
FROM wf_process_instance i JOIN wf_process_define d ON d.id = i.processDefineId
WHERE d.name = 'XHZC' AND i.state = 20;
INSERT INTO tmp_club_final_guard(name, actual, expected)
SELECT 'orphan_member_club', COUNT(*), 0 FROM club_user u LEFT JOIN sys_club c ON c.id = u.clubId WHERE c.id IS NULL;
INSERT INTO tmp_club_final_guard(name, actual, expected)
SELECT 'orphan_role_club', COUNT(*), 0 FROM sys_user_role r LEFT JOIN sys_club c ON c.id = r.clubId WHERE NULLIF(TRIM(r.clubId), '') IS NOT NULL AND c.id IS NULL;
INSERT INTO tmp_club_final_guard(name, actual, expected)
SELECT 'duplicate_member', COUNT(*), 0 FROM (SELECT clubId,userId,COUNT(*) cnt FROM club_user GROUP BY clubId,userId HAVING COUNT(*) > 1) d;
DROP TEMPORARY TABLE IF EXISTS tmp_club_final_guard_fail;
CREATE TEMPORARY TABLE tmp_club_final_guard_fail(id INT NOT NULL) ENGINE=InnoDB;
INSERT INTO tmp_club_final_guard_fail SELECT NULL FROM tmp_club_final_guard WHERE bad = 1 LIMIT 1;
COMMIT;
SELECT * FROM tmp_club_guard ORDER BY name;
SELECT * FROM tmp_club_final_guard ORDER BY name;
@@ -85,6 +85,9 @@ public class MainLauncher {
init_task(); init_task();
init_auth(); init_auth();
ioc.get(Globals.class); ioc.get(Globals.class);
// Globals 会清空并恢复系统任务,必须等它完成后再恢复华住未结束批次;新批次由按钮提交时创建调度。
log.info("[华住同步][步骤=全局任务初始化完成] 开始检查待恢复批次");
ioc.get(com.budwk.app.zhgh.huazhu.service.impl.HuaZhuEmployeeServiceImpl.class).restorePendingSyncTask();
ioc.get(FlowEngine.class); ioc.get(FlowEngine.class);
ioc.get(GlobalMessageSendService.class); ioc.get(GlobalMessageSendService.class);
@@ -6,6 +6,8 @@ package com.budwk.app.base.constant;
public class RedisConstant { public class RedisConstant {
//项目统一前缀 //项目统一前缀
public final static String PLATFORM_REDIS_PREFIX = "budwk5mini:"; public final static String PLATFORM_REDIS_PREFIX = "budwk5mini:";
/** 与原华住认证服务一致,缓存 HuaZhuAuthTokenResultDTO 的 JSON。 */
public static final String REDIS_KEY_HUAZHU_AUTH_TOKEN = PLATFORM_REDIS_PREFIX + "hz:token:";
public final static String PLATFORM_REDIS_WKCACHE_PREFIX = PLATFORM_REDIS_PREFIX + "wkcache:"; public final static String PLATFORM_REDIS_WKCACHE_PREFIX = PLATFORM_REDIS_PREFIX + "wkcache:";
//聊天室 //聊天室
@@ -30,6 +30,15 @@ public class EnumUtil {
// Field[] fields = enumClass.getDeclaredFields(); // Field[] fields = enumClass.getDeclaredFields();
Field[] fields = ReflectUtil.getFields(enumClass); Field[] fields = ReflectUtil.getFields(enumClass);
for (Field field : fields) { for (Field field : fields) {
if (field.getDeclaringClass() == Enum.class) {
Enum<?> value = (Enum<?>) enumConstant;
if ("name".equals(field.getName())) {
map.put(field.getName(), value.name());
} else if ("ordinal".equals(field.getName())) {
map.put(field.getName(), value.ordinal());
}
continue;
}
try { try {
field.setAccessible(true); // 使私有字段可访问 field.setAccessible(true); // 使私有字段可访问
map.put(field.getName(), field.get(enumConstant)); // 获取字段值并添加到map中 map.put(field.getName(), field.get(enumConstant)); // 获取字段值并添加到map中
@@ -128,6 +128,17 @@ public class SysHomeController {
public void index() { public void index() {
} }
/**
* 兼容旧手机端首页地址,无需请求参数。
* 未登录时沿用现有登录拦截;登录后重定向到标准 H5 首页,使底部导航按首页地址正常显示。
* 方法返回 void,无响应数据字段,由重定向视图跳转到 /platform/h5/home。
*/
@At(value = "/mobile/index", top = true)
@Ok(">>:/platform/h5/home")
@SaCheckLogin
public void mobileIndex() {
}
@At(value = "/platform/h5", top = true) @At(value = "/platform/h5", top = true)
@Ok("re") @Ok("re")
@SaCheckLogin @SaCheckLogin
@@ -84,7 +84,8 @@ public class SysDataUnitPullServiceImpl extends BaseServiceImpl<Sys_unit> implem
} }
for (Sys_unit unit : units) { for (Sys_unit unit : units) {
unit.setParentId("0"); // 当前系统学校根单位 ID 为 1,同步的二级单位统一挂在该节点下。
unit.setParentId("1");
unit.setUnitLevel(2); unit.setUnitLevel(2);
unit.setUnitTypeCode(1); unit.setUnitTypeCode(1);
if (unitIds.contains(unit.getId())) { if (unitIds.contains(unit.getId())) {
@@ -92,7 +93,7 @@ public class SysDataUnitPullServiceImpl extends BaseServiceImpl<Sys_unit> implem
} else { } else {
Map<String, Object> beanMap = BeanUtil.beanToMap(unit); Map<String, Object> beanMap = BeanUtil.beanToMap(unit);
beanMap.remove("child"); beanMap.remove("child");
sysUnitService.save(unit, "0"); sysUnitService.save(unit, "1");
} }
} }
@@ -69,7 +69,7 @@ public class SysUserServiceImpl extends BaseServiceImpl<Sys_user> implements Sys
* sys_user_role 仅用于确认用户具有目标角色,组织范围统一取 sys_user.unitId, * sys_user_role 仅用于确认用户具有目标角色,组织范围统一取 sys_user.unitId,
* 避免依赖未维护的 sys_user_role.unitId 字段导致流程无人可分派。 * 避免依赖未维护的 sys_user_role.unitId 字段导致流程无人可分派。
* *
* @param unitId 单位 ID * @param unitId 单位 ID
* @param roleCode 角色编码 * @param roleCode 角色编码
* @return 对应单位下有效且具有目标角色的用户 ID * @return 对应单位下有效且具有目标角色的用户 ID
*/ */
@@ -104,7 +104,7 @@ public class SysUserServiceImpl extends BaseServiceImpl<Sys_user> implements Sys
* 角色用户通过 sys_user.unitId 关联 sys_unit,再以 sys_unit.unionId 确定 * 角色用户通过 sys_user.unitId 关联 sys_unit,再以 sys_unit.unionId 确定
* 二级党组织范围,不读取未维护的 sys_user_role.unionId 字段。 * 二级党组织范围,不读取未维护的 sys_user_role.unionId 字段。
* *
* @param unionId 分工会 ID * @param unionId 分工会 ID
* @param roleCode 角色编码 * @param roleCode 角色编码
* @return 对应二级党组织下有效且具有目标角色的用户 ID * @return 对应二级党组织下有效且具有目标角色的用户 ID
*/ */
@@ -381,9 +381,9 @@ public class SysUserServiceImpl extends BaseServiceImpl<Sys_user> implements Sys
String decodePwd = Base64Decoder.decodeStr(passowrd); String decodePwd = Base64Decoder.decodeStr(passowrd);
String hashedPassword = PwdUtil.getPassword(decodePwd, user.getSalt()); String hashedPassword = PwdUtil.getPassword(decodePwd, user.getSalt());
if (!Strings.sNull(hashedPassword).equalsIgnoreCase(user.getPassword())) { if (!Strings.sNull(hashedPassword).equalsIgnoreCase(user.getPassword())) {
// if (Globals.sso) { if (Globals.sso) {
throw new BaseException("用户名或者密码不正确"); throw new BaseException("用户名或者密码不正确");
// } }
} }
user = this.fetchLinks(user, "unit"); user = this.fetchLinks(user, "unit");
if (Lang.isNotEmpty(user.getUnit()) && StrUtil.isNotBlank(user.getUnit().getUnionId())) { if (Lang.isNotEmpty(user.getUnit()) && StrUtil.isNotBlank(user.getUnit().getUnionId())) {
@@ -21,11 +21,13 @@ import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityStatis
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd; import org.nutz.dao.Cnd;
import org.nutz.dao.Dao; import org.nutz.dao.Dao;
import org.nutz.dao.Sqls; import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql; import org.nutz.dao.sql.Sql;
import org.nutz.dao.util.cri.Static; import org.nutz.dao.util.cri.Static;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json; import org.nutz.json.Json;
@@ -370,11 +372,27 @@ public class TrainSignUpApplyController {
} }
} }
/**
* 接收本次报名信息并校验手机号必填及11位格式,PC 与 H5 共用此入口。
*
* @param trainSignUpUser 报名参数:activityId 为活动ID,courseId 为课程ID,mobile 为必填的11位大陆手机号;
* 限额课程通过 activityCourseId 指定时段,mobileColumnsValue 为动态字段及其填写值列表
* @return Result,code 为结果状态码,msg 为手机号校验或报名处理的结果说明
*/
@At @At
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("活动报名") @ApiOperation("活动报名")
@SaCheckPermission(value = {"trainSignUp.apply", "h5.trainSignUp.apply"}, mode = SaMode.OR) @SaCheckPermission(value = {"trainSignUp.apply", "h5.trainSignUp.apply"}, mode = SaMode.OR)
@SLog(tag = "品牌活动-活动报名", msg = "活动报名") @SLog(tag = "品牌活动-活动报名", msg = "活动报名")
public Result doSignUp(TrainSignUpUser trainSignUpUser) { public Result doSignUp(TrainSignUpUser trainSignUpUser) {
// 在进入报名处理前拦截缺失、空串及纯空白手机号,避免绕过页面必填校验。
if (trainSignUpUser == null || StrUtil.isBlank(trainSignUpUser.getMobile())) {
return Result.error("请填写手机号");
}
// 与 PC、H5 使用同一规则,仅接受1开头、第二位为3至9的11位数字。
if (!trainSignUpUser.getMobile().matches("^1[3-9][0-9]{9}$")) {
return Result.error("请输入正确的11位手机号");
}
try { try {
lock.lock(); lock.lock();
boolean courseByUser = trainSignUpActivityService.isSignCourseByUser(trainSignUpUser.getCourseId(), SecurityUtil.getUserId()); boolean courseByUser = trainSignUpActivityService.isSignCourseByUser(trainSignUpUser.getCourseId(), SecurityUtil.getUserId());
@@ -22,7 +22,6 @@ import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityStatis
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.poi.hssf.usermodel.HSSFWorkbook;
import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.nutz.dao.Chain; import org.nutz.dao.Chain;
@@ -188,6 +187,12 @@ public class TrainSignUpActivityStatisticsController {
return Result.success(); return Result.success();
} }
/**
* 导出指定活动的报名人员,按课程分别生成工作表,包含手机号等基础信息及该课程的动态报名字段。
*
* @param activityId 要导出报名人员的活动ID
* @param response 接收Excel下载内容的HTTP响应;方法无返回值,输出.xlsx格式的报名人员名单
*/
@At @At
@Ok("void") @Ok("void")
@ApiOperation("导出签到名单") @ApiOperation("导出签到名单")
@@ -237,9 +242,10 @@ public class TrainSignUpActivityStatisticsController {
for (TrainSignUpCourse c : courseList) { for (TrainSignUpCourse c : courseList) {
String k = c.getCourseName(); String k = c.getCourseName();
List<NutMap> v = userList.stream().filter(x -> StrUtil.isNotBlank(x.getString("courseName")) && x.getString("courseName").equals(k)).collect(Collectors.toList()); List<NutMap> v = userList.stream().filter(x -> StrUtil.isNotBlank(x.getString("courseName")) && x.getString("courseName").equals(k)).collect(Collectors.toList());
ExportParams userExportParams = new ExportParams(); ExportParams exportParams = new ExportParams();
userExportParams.setSheetName(k); // 工作表参数与工作簿统一使用XSSF,确保导出的.xlsx文件格式一致。
userExportParams.setType(ExcelType.HSSF); exportParams.setType(ExcelType.XSSF);
exportParams.setSheetName(k);
List<ExcelExportEntity> currentEntities = new ArrayList<>(); List<ExcelExportEntity> currentEntities = new ArrayList<>();
currentEntities.addAll(excelCommonExportEntity); currentEntities.addAll(excelCommonExportEntity);
@@ -285,20 +291,19 @@ public class TrainSignUpActivityStatisticsController {
Map<String, Object> userExportMap = new HashMap<>(); Map<String, Object> userExportMap = new HashMap<>();
userExportMap.put("name", k); userExportMap.put("name", k);
userExportParams.setType(ExcelType.HSSF); userExportMap.put("title", exportParams);
userExportMap.put("title", userExportParams);
userExportMap.put("entity", currentEntities); userExportMap.put("entity", currentEntities);
userExportMap.put("data", v); userExportMap.put("data", v);
sheetsList.add(userExportMap); sheetsList.add(userExportMap);
} }
Workbook workbook = new HSSFWorkbook(); Workbook workbook = new XSSFWorkbook();
for (Map<String, Object> map : sheetsList) { for (Map<String, Object> map : sheetsList) {
ExcelExportService service = new ExcelExportService(); ExcelExportService service = new ExcelExportService();
service.createSheetForMap(workbook, (ExportParams) map.get("title"), (List<ExcelExportEntity>) map.get("entity"), (Collection<?>) map.get("data")); service.createSheetForMap(workbook, (ExportParams) map.get("title"), (List<ExcelExportEntity>) map.get("entity"), (Collection<?>) map.get("data"));
} }
CommonDownloadUtil.download(activity.getActivityName() + "报名人员名单" + ".xls", workbook, response); CommonDownloadUtil.download(activity.getActivityName() + "报名人员名单" + ".xlsx", workbook, response);
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
} }
@@ -1,61 +1,26 @@
package com.budwk.app.zhgh.dayofficework.birthdayWishes.task; package com.budwk.app.zhgh.dayofficework.birthdayWishes.task;
import cn.hutool.core.date.DateUtil; import com.budwk.app.zhgh.staffmanage.birthday.service.UserBirthdayService;
import com.budwk.app.sys.models.Sys_msg;
import com.budwk.app.sys.services.SysMsgService;
import lombok.extern.slf4j.Slf4j; 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.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json;
import org.quartz.Job; import org.quartz.Job;
import org.quartz.JobDataMap;
import org.quartz.JobExecutionContext; import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException; import org.quartz.JobExecutionException;
import java.util.List;
@IocBean @IocBean
@Slf4j @Slf4j
public class BirthdayWishesTask implements Job { public class BirthdayWishesTask implements Job {
@Inject @Inject
protected SysMsgService sysMsgService; private UserBirthdayService userBirthdayService;
@Inject
protected Dao dao;
@Override @Override
public void execute(JobExecutionContext context) throws JobExecutionException { public void execute(JobExecutionContext context) throws JobExecutionException {
log.info("=================================执行计划任务:{}", context.getJobDetail().getKey().getName()); log.info("=================================执行计划任务:{}", context.getJobDetail().getKey().getName());
JobDataMap dataMap = context.getJobDetail().getJobDataMap(); // 统一由生日服务查询当天生日会员、生成移动端链接并记录系统推送日志。
log.info("=================================参数:{}", Json.toJson(dataMap)); int receiverCount = userBirthdayService.sendTodayBirthdayMessages();
log.info("=================================当天生日通知进入发送队列人数:{}", receiverCount);
String planName = dataMap.getString("name");
String today = DateUtil.today();
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);
}
} }
} }
@@ -557,6 +557,12 @@ public class ProposalExportServiceImpl extends BaseServiceImpl<ProposalInfo> imp
return ExcelExportUtil.exportExcel(exportParams, exportEntities, list); return ExcelExportUtil.exportExcel(exportParams, exportEntities, list);
} }
/**
* 按综合导出查询条件将提案逐份生成为 DOCX,再完整打包为 ZIP。
*
* @param pageForm 综合导出页面的筛选参数,用于筛选提案名称、编号、单位等条件
* @param response HTTP 响应,成功时直接输出 ZIP 二进制,包内每个 DOCX 对应一条提案
*/
@Override @Override
public void exportProposalStatisticsAsZip(ProposalQueryComprehensiveParam pageForm, HttpServletResponse response) { public void exportProposalStatisticsAsZip(ProposalQueryComprehensiveParam pageForm, HttpServletResponse response) {
Sql sql = Sqls.create(""" Sql sql = Sqls.create("""
@@ -574,32 +580,38 @@ public class ProposalExportServiceImpl extends BaseServiceImpl<ProposalInfo> imp
"""); """);
Cnd cnd = Cnd.NEW(); Cnd cnd = Cnd.NEW();
ProposalQueryComprehensiveParam.buildSearch(cnd, pageForm); ProposalQueryComprehensiveParam.buildSearch(cnd, pageForm);
// 同一提案人可有多届代表记录,关联查询后按提案 ID 去重,避免重复生成同一提案。
cnd.groupBy("info.id");
sql.setCondition(cnd); sql.setCondition(cnd);
List<NutMap> list = listMap(sql); List<NutMap> list = listMap(sql);
try { try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
ByteArrayOutputStream bos = new ByteArrayOutputStream(); LinkedHashSet<String> zipEntryNames = new LinkedHashSet<>();
ZipOutputStream zipOutputStream = new ZipOutputStream(bos); try (ZipOutputStream zipOutputStream = new ZipOutputStream(bos)) {
for (NutMap proposalRow : list) { for (NutMap proposalRow : list) {
try (ByteArrayOutputStream docxByteArrayOutputStream = new ByteArrayOutputStream()) { try (ByteArrayOutputStream docxByteArrayOutputStream = new ByteArrayOutputStream()) {
exportProposalAsDocx(proposalRow.getString("id"), docxByteArrayOutputStream); exportProposalAsDocx(proposalRow.getString("id"), docxByteArrayOutputStream);
ZipEntry zipEntry = new ZipEntry(proposalRow.getString("code") + "-" + sanitizeZipFileName(proposalRow.getString("name")) + ".docx"); String baseEntryName = proposalRow.getString("code") + "-" + sanitizeZipFileName(proposalRow.getString("name"));
zipOutputStream.putNextEntry(zipEntry); String entryName = baseEntryName + ".docx";
docxByteArrayOutputStream.writeTo(zipOutputStream); int sameNameIndex = 2;
zipOutputStream.closeEntry(); // 不同提案可能同名,或清理特殊字符后重名;按最终文件名检查,保留全部提案。
} catch (IOException e) { while (!zipEntryNames.add(entryName)) {
// 处理单个文件生成失败的情况 entryName = baseEntryName + "-" + sameNameIndex++ + ".docx";
throw new RuntimeException("zip entry write failed, proposalId=" + proposalRow.getString("id"), e); }
zipOutputStream.putNextEntry(new ZipEntry(entryName));
docxByteArrayOutputStream.writeTo(zipOutputStream);
zipOutputStream.closeEntry();
} catch (Exception e) {
throw new RuntimeException("zip entry write failed, proposalId=" + proposalRow.getString("id"), e);
}
} }
} }
zipOutputStream.finish(); // 压缩流成功关闭、ZIP 目录完整写入后再下载,任一提案失败时不输出部分压缩包。
zipOutputStream.close();
CommonDownloadUtil.download("提案汇总压缩包.zip", bos.toByteArray(), response); CommonDownloadUtil.download("提案汇总压缩包.zip", bos.toByteArray(), response);
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); log.error("批量导出提案表 ZIP 失败", e);
throw new BaseException("批量导出提案表失败,请联系管理员");
} }
} }
@@ -4,16 +4,19 @@ import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.result.Result; import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.huazhu.param.HuaZhuEmployeePageForm; import com.budwk.app.zhgh.huazhu.param.HuaZhuEmployeePageForm;
import com.budwk.app.zhgh.huazhu.param.HuaZhuSyncPageForm;
import com.budwk.app.zhgh.huazhu.service.HuaZhuEmployeeService; import com.budwk.app.zhgh.huazhu.service.HuaZhuEmployeeService;
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.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param; import org.nutz.mvc.annotation.Param;
import org.nutz.lang.util.NutMap;
import javax.validation.Valid; import javax.validation.Valid;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections;
/** /**
* PC 端华住员工同步管理。 * PC 端华住员工同步管理。
@@ -35,18 +38,78 @@ public class HuaZhuManageController {
@At @At
@SaCheckPermission("huazhu.manage") @SaCheckPermission("huazhu.manage")
public Result pageData(@Valid HuaZhuEmployeePageForm pageForm) { public Result pageData(@Valid HuaZhuEmployeePageForm pageForm) {
return Result.success(huaZhuEmployeeService.pageUsers(pageForm)); try {
return Result.success(huaZhuEmployeeService.pageUsers(pageForm));
} catch (Exception e) {
return Result.error("读取待推送员工失败,请稍后重试");
}
} }
/**
* @param userIds 页面勾选员工的本项目用户主键数组,至少传入一个主键
* @return Result,data 为已创建批次,含 id、total、status、submittedAt;失败原因在 msg
*/
@At @At
@Aop(TransAop.READ_COMMITTED)
@SaCheckPermission("huazhu.manage") @SaCheckPermission("huazhu.manage")
public Result syncUsers(@Param("userIds") String[] userIds) { public Result syncUsers(@Param("userIds") String[] userIds) {
if (userIds == null || userIds.length == 0) { if (userIds == null || userIds.length == 0) {
return Result.error("请先选择需要同步的员工"); return Result.error("请先选择需要同步的员工");
} }
// 写入异常交给统一异常处理器;不在事务内部吞掉异常,保证批次与人员一起回滚。
return Result.success(huaZhuEmployeeService.syncUsers(Arrays.asList(userIds))); return Result.success(huaZhuEmployeeService.syncUsers(Arrays.asList(userIds)));
} }
/**
* 无需请求参数;只读取规则说明,供页面确认使用,不推送人员。
* @return Result,data.description 为包含实际单位名称的中文规则;单位缺失时返回错误提示
*/
@At
@SaCheckPermission("huazhu.manage")
public Result syncRuleDescription() {
try {
return Result.success(NutMap.NEW().addv("description", huaZhuEmployeeService.getSyncRuleDescription()));
} catch (IllegalStateException e) {
return Result.error(e.getMessage());
} catch (Exception e) {
return Result.error("读取同步规则失败,请稍后重试");
}
}
/**
* 无需请求参数;由服务端按固定业务规则筛选全部待同步人员。
* @return Result,data 为已创建批次,含 id、total、status、submittedAt;失败原因在 msg;无人需要推送时返回中文错误
*/
@At
@Aop(TransAop.READ_COMMITTED)
@SaCheckPermission("huazhu.manage")
public Result syncRuleUsers() {
return Result.success(huaZhuEmployeeService.syncRuleUsers());
}
/** taskId 为可选批次主键;data 含 task、counts、tasks、running,不触发推送。 */
@At
@SaCheckPermission("huazhu.manage")
public Result syncSummary(@Param("taskId") String taskId) {
try {
return Result.success(huaZhuEmployeeService.syncSummary(taskId));
} catch (Exception e) {
return Result.error("读取推送进度失败,请稍后重试");
}
}
/** pageForm 含分页及可选批次、状态、工号/姓名;data.list 为逐人回执,totalCount 为总条数。 */
@At
@SaCheckPermission("huazhu.manage")
public Result recordPageData(@Valid HuaZhuSyncPageForm pageForm) {
try {
return Result.success(huaZhuEmployeeService.pageRecords(pageForm));
} catch (Exception e) {
return Result.error("读取推送记录失败,请稍后重试");
}
}
/** userId 为本项目员工主键;data 为华住实时状态,msg 为可直接显示的中文错误。 */
@At @At
@SaCheckPermission("huazhu.manage") @SaCheckPermission("huazhu.manage")
public Result queryStatus(@Param("userId") String userId) { public Result queryStatus(@Param("userId") String userId) {
@@ -0,0 +1,18 @@
package com.budwk.app.zhgh.huazhu.dto;
import com.alibaba.fastjson.annotation.JSONField;
import lombok.Data;
/** 沿用原系统的认证响应对象及字段映射,同时用于 Redis 缓存序列化。 */
@Data
public class HuaZhuAuthTokenResultDTO {
/** 华住返回的裸 Token,调用接口时再拼接 Bearer 前缀。 */
@JSONField(name = "access_token")
private String accessToken;
@JSONField(name = "token_type")
private String tokenType;
/** 华住返回的有效时间,单位秒;保持原 DTO 的字符串类型。 */
@JSONField(name = "expires_in")
private String expiresIn;
private String scope;
}
@@ -0,0 +1,43 @@
package com.budwk.app.zhgh.huazhu.job;
import com.budwk.app.zhgh.huazhu.service.HuaZhuEmployeeService;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.log.Log;
import org.nutz.log.Logs;
import org.quartz.DisallowConcurrentExecution;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
/** 固定后台入口;同一 Job 串行运行整批人员,页面离开不会中断执行。 */
@IocBean
@DisallowConcurrentExecution
public class HuaZhuSyncJob implements Job {
private static final Log log = Logs.get();
@Inject
private HuaZhuEmployeeService huaZhuEmployeeService;
/** context 为 Quartz 触发上下文;仅调用业务服务,不在 Job 中编排人员或开启整批事务。 */
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
String fireId = context == null ? "-" : context.getFireInstanceId();
long started = System.nanoTime();
log.infof("[华住同步][步骤=调度触发] 开始,fireInstanceId=%s", fireId);
if (huaZhuEmployeeService == null) {
// 工厂回退为普通实例会丢失 IOC 注入,此日志明确区别于触发器没有触发。
log.error("[华住同步][步骤=检查服务注入] 失败,Job 未获得华住服务,请检查 Quartz 的 IOC JobFactory 配置");
throw new JobExecutionException("华住后台服务未注入", false);
}
try {
huaZhuEmployeeService.runPendingTask();
} catch (Exception e) {
log.errorf("[华住同步][步骤=后台调用] 异常,fireInstanceId=%s,类型=%s,栈=%s", fireId,
e.getClass().getName(), java.util.Arrays.toString(e.getStackTrace()));
// 不携带可能含认证信息的原异常消息,不立即重发推送。
throw new JobExecutionException("华住后台调用异常,请查看阶段日志", false);
} finally {
log.infof("[华住同步][步骤=调度结束] fireInstanceId=%s,耗时=%dms", fireId, (System.nanoTime() - started) / 1_000_000L);
}
}
}
@@ -0,0 +1,38 @@
package com.budwk.app.zhgh.huazhu.models;
import lombok.Data;
import org.nutz.dao.entity.annotation.*;
/** 每次推送的人员快照与回执;不保存认证 token、卡密码或请求正文。 */
@Data
@Table("huazhu_sync_record")
@TableMeta("{'mysql-charset':'utf8mb4','mysql-engine':'InnoDB'}")
// 同批次及同一员工均可存在多条相同状态的记录;这两个索引仅用于查询,不能限制唯一性。
@TableIndexes({@Index(name = "IDX_HUAZHU_TASK_STATUS", fields = {"taskId", "status"}, unique = false),
@Index(name = "IDX_HUAZHU_USER_STATUS", fields = {"userId", "status"}, unique = false)})
public class HuaZhuSyncRecord {
@Name
@Column
@ColDefine(type = ColType.VARCHAR, width = 32)
private String id;
@Column
@ColDefine(type = ColType.VARCHAR, width = 32)
private String taskId;
@Column
@ColDefine(type = ColType.VARCHAR, width = 32)
private String userId;
@Column
private String loginName;
@Column
private String userName;
/** PENDING 待推送、SENDING 发送中、SUCCESS 推送成功、EXISTS 华住已存在、FAILED 明确失败、UNKNOWN 待核实。 */
@Column
private String status;
@Column
@ColDefine(type = ColType.TEXT)
private String message;
@Column
private Long submittedAt;
@Column
private Long finishedAt;
}
@@ -0,0 +1,34 @@
package com.budwk.app.zhgh.huazhu.models;
import lombok.Data;
import org.nutz.dao.entity.annotation.*;
/** 华住推送批次。activeSlot 唯一约束防止管理员重复提交;后台按当前单实例 Quartz 配置串行执行。 */
@Data
@Table("huazhu_sync_task")
@TableMeta("{'mysql-charset':'utf8mb4','mysql-engine':'InnoDB'}")
@TableIndexes({@Index(name = "UK_HUAZHU_ACTIVE_SLOT", fields = {"activeSlot"}, unique = true)})
public class HuaZhuSyncTask {
@Name
@Column
@ColDefine(type = ColType.VARCHAR, width = 32)
private String id;
@Column
@ColDefine(type = ColType.VARCHAR, width = 32)
private String activeSlot;
/** SELECTED 为勾选同步,RULE 为规则同步。 */
@Column
private String source;
/** RUNNING 为已提交待后台处理或处理中,COMPLETED 为遍历结束,FAILED 为批次异常中止。 */
@Column
private String status;
@Column
private Integer total;
@Column
private Long submittedAt;
@Column
private Long finishedAt;
/** 兼容旧表保留的租约字段;现由固定 Quartz Job 串行执行,不再按逐人租约领取。 */
@Column
private Long leaseUntil;
}
@@ -0,0 +1,13 @@
package com.budwk.app.zhgh.huazhu.param;
import com.budwk.app.base.param.PageForm;
import lombok.Data;
import lombok.EqualsAndHashCode;
/** 推送记录分页:taskId 为空查全部批次,status 为空查全部状态,searchKeyword 匹配工号或姓名。 */
@Data
@EqualsAndHashCode(callSuper = true)
public class HuaZhuSyncPageForm extends PageForm {
private String taskId;
private String status;
}
@@ -0,0 +1,9 @@
package com.budwk.app.zhgh.huazhu.service;
import com.budwk.app.zhgh.huazhu.dto.HuaZhuAuthTokenResultDTO;
/** 华住认证,保留原系统 getToken 接口。 */
public interface HuaZhuAuthService {
/** 无参数;优先读取缓存,否则请求认证。返回 DTO,accessToken 为裸 Token,失败抛出异常。 */
HuaZhuAuthTokenResultDTO getToken();
}
@@ -3,7 +3,9 @@ package com.budwk.app.zhgh.huazhu.service;
import com.budwk.app.base.page.Pagination; import com.budwk.app.base.page.Pagination;
import com.budwk.app.zhgh.huazhu.param.HuaZhuEmployeePageForm; import com.budwk.app.zhgh.huazhu.param.HuaZhuEmployeePageForm;
import com.budwk.app.zhgh.huazhu.vo.HuaZhuEmployeeStatus; import com.budwk.app.zhgh.huazhu.vo.HuaZhuEmployeeStatus;
import com.budwk.app.zhgh.huazhu.vo.HuaZhuSyncResult; import com.budwk.app.zhgh.huazhu.models.HuaZhuSyncTask;
import com.budwk.app.zhgh.huazhu.models.HuaZhuSyncRecord;
import com.budwk.app.zhgh.huazhu.param.HuaZhuSyncPageForm;
import org.nutz.lang.util.NutMap; import org.nutz.lang.util.NutMap;
import java.util.List; import java.util.List;
@@ -25,9 +27,47 @@ public interface HuaZhuEmployeeService {
* 将选中的本项目员工同步到华住。 * 将选中的本项目员工同步到华住。
* *
* @param userIds 本项目用户主键列表 * @param userIds 本项目用户主键列表
* @return 每位员工的同步结果 * @return 已创建批次,含 id、total、status 和 submittedAt;实际推送在后台进行
*/ */
List<HuaZhuSyncResult> syncUsers(List<String> userIds); HuaZhuSyncTask syncUsers(List<String> userIds);
/**
* 获取规则同步的中文说明,无需传参;单位名称从当前单位表读取,缺失时拒绝继续同步。
*
* @return 包含在岗条件、限定单位名称及非自聘条件的说明文字
*/
String getSyncRuleDescription();
/**
* 同步全部符合规则且未删除的员工,无需传参,不受页面搜索、分页或勾选范围限制。
*
* @return 已创建批次;无人需要推送时抛出中文业务异常
*/
HuaZhuSyncTask syncRuleUsers();
/** 无参数、无返回值;后台顺序推送一个批次,明确认证失败时刷新 Token 并重试当前人员一次,仍失败则停止批次。 */
void runPendingTask();
/** 无参数;返回活动批次或 null,将上次中断的 SENDING 转待核实,禁止自动重发。 */
HuaZhuSyncTask prepareBatch();
/** recordId 为记录主键、taskId 为批次主键;返回是否将待推送记录成功标为发送中。 */
boolean markSending(String recordId, String taskId);
/** record 为发送结果;单独事务保存,状态不匹配或保存失败时抛出异常。 */
void finishRecord(HuaZhuSyncRecord record);
/** taskId 为完成批次主键;确认无待处理人员后结束批次,无返回值。 */
void completeBatch(String taskId);
/** taskId 为异常批次、reason 为中文原因;保留已完成结果,结束剩余记录及批次。 */
void failBatch(String taskId, String reason);
/** taskId 可为空,返回最近/指定批次、状态计数、最近批次及后台是否运行。 */
NutMap syncSummary(String taskId);
/** pageForm 提供分页及可选批次、状态、关键词;返回每人的持久化推送记录。 */
Pagination<HuaZhuSyncRecord> pageRecords(HuaZhuSyncPageForm pageForm);
/** /**
* 查询指定本项目员工在华住的实时状态。 * 查询指定本项目员工在华住的实时状态。
@@ -0,0 +1,78 @@
package com.budwk.app.zhgh.huazhu.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONUtil;
import com.budwk.app.base.constant.RedisConstant;
import com.budwk.app.zhgh.huazhu.constant.HuaZhuConstant;
import com.budwk.app.zhgh.huazhu.dto.HuaZhuAuthTokenResultDTO;
import com.budwk.app.zhgh.huazhu.service.HuaZhuAuthService;
import org.apache.commons.lang3.StringUtils;
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.integration.jedis.RedisService;
import org.nutz.log.Log;
import org.nutz.log.Logs;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/** 原认证方法仅增加阶段日志和响应关闭,认证参数、DTO 转换及缓存内容保持原写法。 */
@IocBean
public class HuaZhuAuthServiceImpl implements HuaZhuAuthService {
private static final Log log = Logs.get();
@Inject
private RedisService redisService;
/** 无参数;返回原 DTO。缓存 58 分钟,认证请求 20 秒超时,日志不输出认证头或 Token。 */
@Override
@Aop(TransAop.READ_COMMITTED)
public HuaZhuAuthTokenResultDTO getToken() {
String step = "读取Token缓存";
long started = System.nanoTime();
try {
log.info("[华住认证][步骤=读取Token缓存] 开始");
String token = redisService.get(RedisConstant.REDIS_KEY_HUAZHU_AUTH_TOKEN);
if (StringUtils.isNotBlank(token)) {
step = "缓存转Token对象";
log.info("[华住认证][步骤=缓存转Token对象] 开始,命中缓存");
return JSONUtil.toBean(token, HuaZhuAuthTokenResultDTO.class);
}
step = "构建Basic认证请求";
log.info("[华住认证][步骤=构建Basic认证请求] 开始");
String url = String.format("%s%s", HuaZhuConstant.OAUTH2_URL, "/oauth/token?scope=ALL&grant_type=client_credentials");
String sign = StringUtils.join("Basic ", Base64.getEncoder().encodeToString(String.format("%s:%s", HuaZhuConstant.CLIENT_ID, HuaZhuConstant.CLIENT_SECRET).getBytes(StandardCharsets.UTF_8)));
step = "请求华住Token";
log.info("[华住认证][步骤=请求华住Token] 开始,超时=20000ms");
try (HttpResponse response = HttpRequest.post(url).header("Authorization", sign).timeout(20000).execute()) {
log.infof("[华住认证][步骤=请求华住Token] 收到响应,HTTP=%d", response.getStatus());
if (response.isOk()) {
step = "读取并转换认证响应";
log.info("[华住认证][步骤=读取并转换认证响应] 开始");
String body = response.body();
HuaZhuAuthTokenResultDTO tokenResultDTO = JSONUtil.toBean(body, HuaZhuAuthTokenResultDTO.class);
String newToken = tokenResultDTO.getAccessToken();
if (StrUtil.isBlank(newToken)) {
throw new RuntimeException("请求华住认证接口失败,返回的Token为空");
}
step = "缓存Token对象";
log.info("[华住认证][步骤=缓存Token对象] 开始,有效期=3480秒");
redisService.setex(RedisConstant.REDIS_KEY_HUAZHU_AUTH_TOKEN, 60 * 58, JSONUtil.toJsonStr(tokenResultDTO));
return tokenResultDTO;
} else {
throw new RuntimeException("请求华住认证接口失败,状态码:" + response.getStatus());
}
}
} catch (RuntimeException e) {
// 认证异常原文可能包含响应正文,只记录类型和位置,业务层另行返回受控中文原因。
log.errorf("[华住认证][步骤=%s] 失败,异常类型=%s,位置=%s", step, e.getClass().getName(),
e.getStackTrace().length == 0 ? "无" : e.getStackTrace()[0]);
throw e;
} finally {
log.infof("[华住认证][步骤=%s] 调用结束,耗时=%dms", step, (System.nanoTime() - started) / 1_000_000L);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -81,10 +81,10 @@ public class CondolenceApplyController {
} }
/** /**
* 保存慰问申报截止日期,服务层会再次校验当前用户必须为系统管理员。 * 保存慰问申报截止日期,服务层会再次校验当前用户必须为系统管理员或校工会管理员。
* *
* @param endDate 截止日期,格式为 yyyy-MM-dd * @param endDate 截止日期,格式为 yyyy-MM-dd
* @return 保存成功结果 * @return Result,保存成功时返回成功状态和“设置成功”提示
*/ */
@At @At
@ApiOperation("保存慰问申报截止日期") @ApiOperation("保存慰问申报截止日期")
@@ -22,7 +22,7 @@ public interface CondolenceService extends BaseService<Condolence> {
String getApplyEndDate(); String getApplyEndDate();
/** /**
* 保存慰问申报截止日期,仅允许系统管理员操作。 * 保存慰问申报截止日期,仅允许系统管理员或校工会管理员操作。
* *
* @param endDate 截止日期,格式为 yyyy-MM-dd * @param endDate 截止日期,格式为 yyyy-MM-dd
*/ */
@@ -90,15 +90,15 @@ public class CondolenceServiceImpl extends BaseServiceImpl<Condolence> implement
} }
/** /**
* 保存慰问申报截止日期。参数必须是 yyyy-MM-dd,且仅 SYSADMIN 可以修改。 * 保存慰问申报截止日期。参数必须是 yyyy-MM-dd,仅系统管理员或校工会管理员可以修改。
* *
* @param endDate 截止日期,格式为 yyyy-MM-dd * @param endDate 截止日期,格式为 yyyy-MM-dd
*/ */
@Override @Override
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
public void saveApplyEndDate(String endDate) { public void saveApplyEndDate(String endDate) {
if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name())) { if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) {
throw new BaseException("仅系统管理员可以设置慰问申报时间"); throw new BaseException("仅系统管理员或校工会管理员可以设置慰问申报时间");
} }
parseApplyEndDate(endDate); parseApplyEndDate(endDate);
Sys_config config = sysConfigService.fetch(APPLY_END_DATE_CONFIG_KEY); Sys_config config = sysConfigService.fetch(APPLY_END_DATE_CONFIG_KEY);
@@ -1,11 +1,14 @@
package com.budwk.app.zhgh.staffbenefit.difficultSubsidy.controller.process; package com.budwk.app.zhgh.staffbenefit.difficultSubsidy.controller.process;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import com.budwk.app.base.result.Result; import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.staffbenefit.difficultSubsidy.models.DifficultSubsidyApply; import com.budwk.app.zhgh.staffbenefit.difficultSubsidy.models.DifficultSubsidyApply;
import com.budwk.app.zhgh.staffbenefit.difficultSubsidy.service.DifficultSubsidyService; import com.budwk.app.zhgh.staffbenefit.difficultSubsidy.service.DifficultSubsidyService;
import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param; import org.nutz.mvc.annotation.Param;
@@ -36,9 +39,32 @@ public class DifficultSubsidyApplyController {
public void form() { public void form() {
} }
/**
* 无请求参数;返回 Result,data 为 yyyy-MM-dd 截止日期,空字符串表示未设置。
* 审核修改复用本表单,因此允许申请或校工会审核权限读取。
*/
@At
@SaCheckPermission(value = {"difficultSubsidy.apply", "difficultSubsidy.schoolAudit"}, mode = SaMode.OR)
public Result getApplyEndDate() {
return Result.success().addData(difficultSubsidyService.getApplyEndDate());
}
/**
* @param endDate yyyy-MM-dd 格式的申报截止日期;允许设置的角色由 service 统一校验
* @return Result 成功状态,不附加业务数据
*/
@At
@SaCheckPermission("difficultSubsidy.apply")
@Aop(TransAop.READ_COMMITTED)
public Result saveApplyEndDate(@Param("endDate") String endDate) {
difficultSubsidyService.saveApplyEndDate(endDate);
return Result.success();
}
@At @At
@Ok("json") @Ok("json")
@SaCheckPermission("difficultSubsidy.apply") @SaCheckPermission("difficultSubsidy.apply")
@Aop(TransAop.READ_COMMITTED)
public Result save(@Param("data") DifficultSubsidyApply apply) { public Result save(@Param("data") DifficultSubsidyApply apply) {
return Result.success(difficultSubsidyService.saveDraft(apply)); return Result.success(difficultSubsidyService.saveDraft(apply));
} }
@@ -46,6 +72,7 @@ public class DifficultSubsidyApplyController {
@At @At
@Ok("json") @Ok("json")
@SaCheckPermission("difficultSubsidy.apply") @SaCheckPermission("difficultSubsidy.apply")
@Aop(TransAop.READ_COMMITTED)
public Result submit(@Param("data") DifficultSubsidyApply apply) { public Result submit(@Param("data") DifficultSubsidyApply apply) {
return Result.success(difficultSubsidyService.submit(apply)); return Result.success(difficultSubsidyService.submit(apply));
} }
@@ -15,6 +15,23 @@ import java.util.List;
*/ */
public interface DifficultSubsidyService extends BaseService<DifficultSubsidyApply> { public interface DifficultSubsidyService extends BaseService<DifficultSubsidyApply> {
/**
* @return 系统参数 DifficultSubsidyApplyEndDate 的 yyyy-MM-dd 日期,未配置时返回空字符串
*/
String getApplyEndDate();
/**
* 仅系统管理员、校工会管理员可设置特困补助申报截止日期。
* @param endDate yyyy-MM-dd 格式的截止日期,当天起停止新增申报
*/
void saveApplyEndDate(String endDate);
/**
* 校验新申请是否在申报期内,数据库中已存在的申请可继续修改和重新提交。
* @param id 申请记录ID;空值或数据库中不存在的ID均按新申请校验
*/
void checkApplyDeadline(String id);
DifficultSubsidyApply saveDraft(DifficultSubsidyApply apply); DifficultSubsidyApply saveDraft(DifficultSubsidyApply apply);
DifficultSubsidyApply submit(DifficultSubsidyApply apply); DifficultSubsidyApply submit(DifficultSubsidyApply apply);
@@ -13,6 +13,7 @@ import com.budwk.app.base.utils.SysOfficeTemplateUtil;
import com.budwk.app.base.page.Pagination; import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.base.constant.RoleConstant; import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.exception.BaseException;
import com.budwk.app.flow.constant.FlowConst; import com.budwk.app.flow.constant.FlowConst;
import com.budwk.app.flow.engine.FlowEngine; import com.budwk.app.flow.engine.FlowEngine;
import com.budwk.app.flow.entity.ProcessDefine; import com.budwk.app.flow.entity.ProcessDefine;
@@ -26,6 +27,8 @@ import com.budwk.app.flow.enums.ProcessTaskStateEnum;
import com.budwk.app.flow.service.FlowCommonService; import com.budwk.app.flow.service.FlowCommonService;
import com.budwk.app.flow.vo.ProcessTaskVO; import com.budwk.app.flow.vo.ProcessTaskVO;
import com.budwk.app.sys.services.SysFileService; import com.budwk.app.sys.services.SysFileService;
import com.budwk.app.sys.services.SysConfigService;
import com.budwk.app.sys.models.Sys_config;
import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.web.commons.auth.utils.AuthUtil; import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.zhgh.staffbenefit.difficultSubsidy.constants.DifficultSubsidyState; import com.budwk.app.zhgh.staffbenefit.difficultSubsidy.constants.DifficultSubsidyState;
@@ -56,6 +59,8 @@ import org.nutz.lang.util.NutMap;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Date; import java.util.Date;
@@ -78,6 +83,12 @@ public class DifficultSubsidyServiceImpl extends BaseServiceImpl<DifficultSubsid
private static final String PROCESS_KEY = "TKBZ"; private static final String PROCESS_KEY = "TKBZ";
private static final String SCHOOL_AUDIT_TASK_NAME = "校工会审核"; private static final String SCHOOL_AUDIT_TASK_NAME = "校工会审核";
private static final String START_TASK_NAME = "startTask"; private static final String START_TASK_NAME = "startTask";
// 沿用现有角色表中的特困补助审批员编码,供全校代申请范围判断使用。
private static final String SCHOOL_AUDIT_ROLE_CODE = "sp04";
private static final String APPLY_END_DATE_CONFIG_KEY = "DifficultSubsidyApplyEndDate";
@Inject
private SysConfigService sysConfigService;
@Inject @Inject
private FlowEngine flowEngine; private FlowEngine flowEngine;
@@ -95,9 +106,84 @@ public class DifficultSubsidyServiceImpl extends BaseServiceImpl<DifficultSubsid
super(dao); super(dao);
} }
/** 直接读取系统参数,保证管理员保存后下一次申请校验立即生效。 */
@Override
public String getApplyEndDate() {
Sys_config config = sysConfigService.fetch(APPLY_END_DATE_CONFIG_KEY);
return config == null ? "" : StrUtil.blankToDefault(config.getConfigValue(), "");
}
/**
* 保存申报时间配置,页面隐藏入口之外还需在服务端校验管理角色。
* @param endDate yyyy-MM-dd 格式的截止日期,截止当天即停止新增申报
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public void saveApplyEndDate(String endDate) {
if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) {
throw new BaseException("仅系统管理员和校工会管理员可以设置特困补助申报时间");
}
parseApplyEndDate(endDate);
Sys_config config = sysConfigService.fetch(APPLY_END_DATE_CONFIG_KEY);
if (config == null) {
config = new Sys_config();
config.setConfigKey(APPLY_END_DATE_CONFIG_KEY);
config.setConfigValue(endDate);
config.setNote("特困补助申请截止日期(yyyy-MM-dd)");
sysConfigService.insert(config);
} else {
config.setConfigValue(endDate);
sysConfigService.update(config);
}
}
/**
* 截止时间仅限制新申请;已有草稿和退回记录可继续办理,不信任客户端随意传入的ID。
* @param id 申请ID;只有数据库中真实存在的记录才免除新申请截止校验
*/
@Override
public void checkApplyDeadline(String id) {
if (Strings.isNotBlank(id) && dao().fetch(DifficultSubsidyApply.class, id) != null) {
return;
}
String endDate = getApplyEndDate();
if (Strings.isBlank(endDate)) {
return;
}
// 与慰问申报保持一致:截止日期当天已关闭,不按当天 23:59:59 计算。
if (!LocalDate.now().isBefore(parseApplyEndDate(endDate))) {
throw new BaseException("本次申报已截止请关注下次申报");
}
}
/** 严格校验配置格式及真实日历日期,避免错误配置被当作开放申报。 */
private LocalDate parseApplyEndDate(String endDate) {
if (Strings.isBlank(endDate)) {
throw new BaseException("申报截止日期不能为空");
}
try {
if (!endDate.matches("\\d{4}-\\d{2}-\\d{2}")) {
throw new DateTimeParseException("Invalid date format", endDate, 0);
}
return LocalDate.parse(endDate);
} catch (DateTimeParseException e) {
throw new BaseException("申报截止日期格式不正确,应为yyyy-MM-dd");
}
}
/**
* 保存本人或当前账号有权代填的特困补助申请,受助人身份和组织信息以人员资料为准。
*
* @param apply 页面表单;userId 为受助人用户ID,未选择时按当前登录人处理,其余为业务填写内容
* @return 保存后的申请实体,applyUserId 为填报人,userId 为实际受助人
*/
@Override @Override
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
public DifficultSubsidyApply saveDraft(DifficultSubsidyApply apply) { public DifficultSubsidyApply saveDraft(DifficultSubsidyApply apply) {
if (apply == null) {
throw new IllegalArgumentException("申请表单不能为空");
}
checkApplyDeadline(apply.getId());
fillApplicantSnapshot(apply); fillApplicantSnapshot(apply);
normalizeFormCollections(apply); normalizeFormCollections(apply);
if (apply.getApplyDate() == null) { if (apply.getApplyDate() == null) {
@@ -110,21 +196,18 @@ public class DifficultSubsidyServiceImpl extends BaseServiceImpl<DifficultSubsid
return apply; return apply;
} }
/**
* 按代申请范围搜索受助人,审批员和校级管理员可搜索全校人员,分工会管理角色限本工会。
*
* @param keyword 姓名或工号关键字,空值时返回当前可选范围内的前十人
* @return 最多十条人员信息,包含id、loginname、username、birthday、mobile、sex及单位、工会ID和名称
*/
@Override @Override
public List<NutMap> listRecipients(String keyword) { public List<NutMap> listRecipients(String keyword) {
Sql sql = Sqls.create("SELECT id, loginname, username, birthday, mobile, sex, " Sql sql = Sqls.create("SELECT id, loginname, username, birthday, mobile, sex, "
+ "unitName, unionName, unionId, unitId FROM vw_user $condition"); + "unitName, unionName, unionId, unitId FROM vw_user $condition");
Cnd cnd = Cnd.NEW(); Cnd cnd = Cnd.NEW();
appendRecipientScope(cnd);
// 校工会管理员可选择全校人员,分工会管理员仅可选择本工会人员,普通用户仅可选择本人。
if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) {
if (AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_CHAIRMAN.name(),
RoleConstant.BRANCH_UNION_ADMIN.name(), RoleConstant.BRANCH_UNION_OPERATOR.name())) {
cnd.and("unionId", "=", SecurityUtil.getUnionId());
} else {
cnd.and("id", "=", SecurityUtil.getUserId());
}
}
if (Strings.isNotBlank(keyword)) { if (Strings.isNotBlank(keyword)) {
SqlExpressionGroup keywordGroup = new SqlExpressionGroup(); SqlExpressionGroup keywordGroup = new SqlExpressionGroup();
keywordGroup.orLike("username", keyword); keywordGroup.orLike("username", keyword);
@@ -725,23 +808,58 @@ public class DifficultSubsidyServiceImpl extends BaseServiceImpl<DifficultSubsid
} }
/** /**
* 补全申请人快照。受助人的姓名、工号、单位等数据来自页面选择结果,不更新 sys_user。 * 将同一套代申请范围用于人员搜索和保存校验,避免只限制下拉选项而允许越权提交受助人ID。
*
* @param cnd 人员查询条件;审批员和校级管理员不追加限制,分工会管理角色限本工会,其他账号限本人
*/
private void appendRecipientScope(Cnd cnd) {
if (AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name(),
SCHOOL_AUDIT_ROLE_CODE)) {
return;
}
if (AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_CHAIRMAN.name(),
RoleConstant.BRANCH_UNION_ADMIN.name(), RoleConstant.BRANCH_UNION_OPERATOR.name())) {
// 未配置工会时仅允许本人,不能把空工会下的其他人员纳入代申请范围。
String unionId = SecurityUtil.getUnionId();
if (Strings.isNotBlank(unionId)) {
cnd.and("unionId", "=", unionId);
return;
}
}
cnd.and("id", "=", SecurityUtil.getUserId());
}
/**
* 记录当前填报人,并核实受助人确实在该账号可选择的范围内;跨部门代填时保留受助人真实组织。
*
* @param apply 页面表单,userId为空时默认本人;姓名、工号、性别、单位和工会字段由查询结果覆盖
*/ */
private void fillApplicantSnapshot(DifficultSubsidyApply apply) { private void fillApplicantSnapshot(DifficultSubsidyApply apply) {
if (apply == null) {
throw new IllegalArgumentException("申请表单不能为空");
}
String userId = Strings.isBlank(apply.getUserId()) ? SecurityUtil.getUserId() : apply.getUserId();
Sql sql = Sqls.create("SELECT id, loginname, username, sex, unitId, unitName, unionId, unionName "
+ "FROM vw_user $condition");
Cnd cnd = Cnd.where("id", "=", userId);
appendRecipientScope(cnd);
sql.setCondition(cnd);
List<NutMap> recipients = listMap(sql);
if (recipients.isEmpty()) {
throw new IllegalArgumentException("受助人不存在或不在当前账号可代申请的范围内");
}
NutMap recipient = recipients.get(0);
apply.setApplyUserId(SecurityUtil.getUserId()); apply.setApplyUserId(SecurityUtil.getUserId());
apply.setApplyUserName(SecurityUtil.getUserUsername()); apply.setApplyUserName(SecurityUtil.getUserUsername());
apply.setApplyLoginName(SecurityUtil.getUserLoginname()); apply.setApplyLoginName(SecurityUtil.getUserLoginname());
if (Strings.isBlank(apply.getUserId())) { apply.setUserId(recipient.getString("id"));
apply.setUserId(apply.getApplyUserId()); apply.setUserName(recipient.getString("username"));
apply.setUserName(apply.getApplyUserName()); apply.setLoginName(recipient.getString("loginname"));
apply.setLoginName(apply.getApplyLoginName()); apply.setSex(recipient.getString("sex"));
} apply.setUnitId(recipient.getString("unitId"));
if (Strings.isBlank(apply.getUnitId())) { apply.setUnitName(recipient.getString("unitName"));
apply.setUnitId(SecurityUtil.getUnitId()); apply.setUnionId(recipient.getString("unionId"));
} apply.setUnionName(recipient.getString("unionName"));
if (Strings.isBlank(apply.getUnionId())) {
apply.setUnionId(SecurityUtil.getUnionId());
}
} }
private void normalizeFormCollections(DifficultSubsidyApply apply) { private void normalizeFormCollections(DifficultSubsidyApply apply) {
@@ -0,0 +1,71 @@
package com.budwk.app.zhgh.staffmanage.birthday.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.staffmanage.birthday.models.UserBirthdayConfig;
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;
/**
* 生日祝福配置管理。
* 配置内容包含页面图片、默认查询天数、自动发送开关和发送文案。
*/
@IocBean
@At("/platform/staffManage/birthday/config")
@Ok("json:full")
@Api(tags = "生日祝福配置")
public class UserBirthdayConfigController {
@Inject
private UserBirthdayService userBirthdayService;
@At("")
@Ok("beetl:/platform/zhgh/staffmanage/birthday/config/index.html")
@SaCheckPermission("staff.birthday.config")
public void index() {
}
/**
* 获取当前生日祝福配置。
*
* @return Result.data 为 UserBirthdayConfig,包含图片文件ID、查询天数、自动发送开关、标题和内容
*/
@At
@ApiOperation("获取生日祝福配置")
@SaCheckPermission("staff.birthday.config")
public Result getConfig() {
return Result.success(userBirthdayService.getConfig());
}
/**
* 保存生日祝福配置。
* config.picUrl、birthdayUrl 为图片文件ID;birthdayDays 为正整数;
* autoSendEnabled 为自动发送开关;sendTitle、sendContent 为默认发送文案。
*
* @return Result.data 为保存后的 UserBirthdayConfig
*/
@At
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("保存生日祝福配置")
@SaCheckPermission("staff.birthday.config")
public Result saveConfig(UserBirthdayConfig config) {
if (config == null || config.getBirthdayDays() == null || config.getBirthdayDays() <= 0) {
return Result.error("默认查询天数必须为正整数");
}
if (StrUtil.isBlank(config.getSendTitle())) {
return Result.error("请输入发送标题");
}
if (StrUtil.isBlank(config.getSendContent())) {
return Result.error("请输入发送内容");
}
return Result.success("保存成功", userBirthdayService.saveConfig(config));
}
}
@@ -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 为 Pagination,list 是人员数据,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 为接收人用户ID,title、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 为背景图片文件ID,config.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 为 Pagination,list 是发送记录,totalCount 是总数
*/
@At
@ApiOperation("分页查询生日消息记录")
@SaCheckPermission("staff.birthday.msgLog")
public Result pageData(UserBirthdayMsgLogPageForm pageForm) {
return Result.success(userBirthdayMsgLogService.pageData(pageForm));
}
}
@@ -0,0 +1,61 @@
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;
@Column
@Comment("默认查询未来生日天数")
@ColDefine(type = ColType.INT)
private Integer birthdayDays;
@Column
@Comment("是否自动发送生日祝福")
@ColDefine(type = ColType.BOOLEAN)
private Boolean autoSendEnabled;
@Column
@Comment("默认发送标题")
@ColDefine(type = ColType.VARCHAR, width = 255)
private String sendTitle;
@Column
@Comment("默认发送内容")
@ColDefine(type = ColType.TEXT)
private String sendContent;
}
@@ -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,66 @@
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, Integer birthdayDays) {
String start = normalizeMonthDay(startDate);
String end = normalizeMonthDay(endDate);
if (StrUtil.isAllBlank(start, end)) {
int days = birthdayDays == null || birthdayDays <= 0 ? 15 : birthdayDays;
start = DateUtil.format(DateUtil.date(), "MM-dd");
end = DateUtil.format(DateUtil.offsetDay(DateUtil.date(), days), "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,72 @@
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 任务调用。
*
* @return 实际进入发送队列的接收人数
*/
int sendTodayBirthdayMessages();
/**
* 获取最新生日页面配置;未配置时返回默认配置。
*/
UserBirthdayConfig getConfig();
/**
* 新增或更新生日页面配置。
*
* @param config 图片、查询天数、自动发送开关及默认消息内容
* @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,287 @@
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.impl.PropertiesProxy;
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 org.nutz.mvc.Mvcs;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@IocBean(args = {"refer:dao"})
public class UserBirthdayServiceImpl extends BaseServiceImpl<Sys_user> implements UserBirthdayService {
private static final int DEFAULT_BIRTHDAY_DAYS = 15;
private static final String DEFAULT_SEND_TITLE = "请点击查收您的生日福利";
private static final String DEFAULT_SEND_CONTENT = "校工会祝您:生日快乐!幸福安康";
@Inject
private PropertiesProxy conf;
@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() {
UserBirthdayConfig config = getConfig();
if (!Boolean.TRUE.equals(config.getAutoSendEnabled())) {
log.info("生日祝福自动发送已关闭");
return 0;
}
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, config.getSendTitle(), config.getSendContent(),
"system", "系统推送", "系统推送");
}
@Override
public UserBirthdayConfig getConfig() {
UserBirthdayConfig config = dao().fetch(UserBirthdayConfig.class, Cnd.NEW().desc("updatedAt"));
return fillConfigDefaults(config == null ? new UserBirthdayConfig() : config);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public UserBirthdayConfig saveConfig(UserBirthdayConfig config) {
UserBirthdayConfig current = dao().fetch(UserBirthdayConfig.class, Cnd.NEW().desc("updatedAt"));
if (current != null) {
config.setId(current.getId());
// 兼容原图片配置入口,未提交的新配置字段保留已有值。
if (config.getBirthdayDays() == null) {
config.setBirthdayDays(current.getBirthdayDays());
}
if (config.getAutoSendEnabled() == null) {
config.setAutoSendEnabled(current.getAutoSendEnabled());
}
if (config.getSendTitle() == null) {
config.setSendTitle(current.getSendTitle());
}
if (config.getSendContent() == null) {
config.setSendContent(current.getSendContent());
}
}
fillConfigDefaults(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, getConfig().getBirthdayDays());
cnd.asc("daysUntilBirthday");
sql.setCondition(cnd);
return sql;
}
private UserBirthdayConfig fillConfigDefaults(UserBirthdayConfig config) {
if (config.getBirthdayDays() == null || config.getBirthdayDays() <= 0) {
config.setBirthdayDays(DEFAULT_BIRTHDAY_DAYS);
}
if (config.getAutoSendEnabled() == null) {
config.setAutoSendEnabled(true);
}
config.setSendTitle(StrUtil.blankToDefault(config.getSendTitle(), DEFAULT_SEND_TITLE));
config.setSendContent(StrUtil.blankToDefault(config.getSendContent(), DEFAULT_SEND_CONTENT));
return config;
}
private String getBirthdayBaseUrl() {
HttpServletRequest request = Mvcs.getReq();
if (request != null) {
String requestUrl = request.getRequestURL().toString();
return requestUrl.substring(0, requestUrl.length() - request.getRequestURI().length())
+ request.getContextPath();
}
// 定时任务没有请求上下文;本机地址未配置端口时使用项目监听端口。
String baseUrl = StrUtil.removeSuffix(Globals.AppDomain, "/");
URI domain = URI.create(baseUrl);
if (domain.getPort() == -1 && StrUtil.equalsAnyIgnoreCase(domain.getHost(),
"localhost", "127.0.0.1", "[::1]", "::1")) {
int defaultPort = "https".equalsIgnoreCase(domain.getScheme()) ? 443 : 80;
int port = conf.getInt("server.port", defaultPort);
if (port != defaultPort) {
baseUrl = domain.getScheme() + "://" + domain.getRawAuthority() + ":" + port
+ StrUtil.blankToDefault(domain.getRawPath(), "");
}
}
String contextPath = StrUtil.removeSuffix(StrUtil.blankToDefault(Globals.AppBase, ""), "/");
if (StrUtil.isNotBlank(contextPath) && !baseUrl.endsWith(contextPath)) {
baseUrl += contextPath;
}
return baseUrl;
}
/**
* 将生日消息写入当前项目消息中心并生成发送记录。
* 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=" + URLEncoder.encode(config.getBirthdayUrl(), StandardCharsets.UTF_8);
}
String fullLink = getBirthdayBaseUrl() + 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();
}
}
@@ -0,0 +1,14 @@
-- 已有生日配置表增加查询和发送配置字段。
ALTER TABLE `user_birthday_config`
ADD COLUMN `birthdayDays` int DEFAULT 15 COMMENT '默认查询未来生日天数',
ADD COLUMN `autoSendEnabled` tinyint(1) DEFAULT 1 COMMENT '是否自动发送生日祝福',
ADD COLUMN `sendTitle` varchar(255) DEFAULT NULL COMMENT '默认发送标题',
ADD COLUMN `sendContent` text COMMENT '默认发送内容';
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry, picIcon, initialPinyinName, isRecommendApp, isRecommendService) VALUES ('9f5c6f6bc684452997079b536ad5fa87', '93487f22face4e18887d90154b70094a', '01610004', '生日消息记录', null, 'menu', '/platform/staffManage/birthday/msgLog', null, null, true, false, 'staff.birthday.msgLog', null, 1013, false, '17a7f8ad3ee947b4a26175049a9c253d', 1790214237143, '17a7f8ad3ee947b4a26175049a9c253d', 1790214237144, false, null, 'PC', '07d940ba34bd47399cda140894f5d86b', false, '/platform/sys/file/download?id=f71c9e07faf05ac4bf8a5364ae0bfd85', 's', false, false);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry, picIcon, initialPinyinName, isRecommendApp, isRecommendService) VALUES ('df478143ff1846ddab704616d9fdae41', '93487f22face4e18887d90154b70094a', '01610005', '生日模板配置', null, 'menu', '/platform/staffManage/birthday/config', null, null, true, false, 'staff.birthday.config', null, 1014, false, '17a7f8ad3ee947b4a26175049a9c253d', 1790220577535, '17a7f8ad3ee947b4a26175049a9c253d', 1790220577536, false, null, 'PC', '07d940ba34bd47399cda140894f5d86b', false, '/platform/sys/file/download?id=f71c9e07faf05ac4bf8a5364ae0bfd85', 's', false, false);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry, picIcon, initialPinyinName, isRecommendApp, isRecommendService) VALUES ('f614e4a8117e40e487883c292d7a0950', '93487f22face4e18887d90154b70094a', '01610003', '生日祝福管理', null, 'menu', '/platform/staffManage/birthday/manage', null, null, true, false, 'staff.birthday.manage', null, 1012, false, '17a7f8ad3ee947b4a26175049a9c253d', 1790214217197, '17a7f8ad3ee947b4a26175049a9c253d', 1790215149743, false, null, 'PC', '07d940ba34bd47399cda140894f5d86b', false, '/platform/sys/file/download?id=f71c9e07faf05ac4bf8a5364ae0bfd85', 's', false, false);
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', '9f5c6f6bc684452997079b536ad5fa87');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'df478143ff1846ddab704616d9fdae41');
INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'f614e4a8117e40e487883c292d7a0950');
@@ -0,0 +1,34 @@
CREATE TABLE IF NOT EXISTS `user_birthday_config` (
`id` varchar(32) NOT NULL COMMENT 'ID',
`picUrl` varchar(100) DEFAULT NULL COMMENT '移动端背景图片文件ID',
`birthdayUrl` varchar(100) DEFAULT NULL COMMENT '生日福利贺卡图片文件ID',
`birthdayDays` int DEFAULT 15 COMMENT '默认查询未来生日天数',
`autoSendEnabled` tinyint(1) DEFAULT 1 COMMENT '是否自动发送生日祝福',
`sendTitle` varchar(255) DEFAULT NULL COMMENT '默认发送标题',
`sendContent` text COMMENT '默认发送内容',
`createdBy` varchar(32) DEFAULT NULL COMMENT '创建人',
`createdAt` bigint DEFAULT NULL COMMENT '创建时间',
`updatedBy` varchar(32) DEFAULT NULL COMMENT '修改人',
`updatedAt` bigint DEFAULT NULL COMMENT '修改时间',
`delFlag` tinyint(1) DEFAULT 0 COMMENT '删除标记',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='生日祝福页面配置';
CREATE TABLE IF NOT EXISTS `user_birthday_msg_log` (
`id` varchar(32) NOT NULL COMMENT 'ID',
`pushTime` varchar(50) DEFAULT NULL COMMENT '推送时间',
`msgTitle` varchar(255) DEFAULT NULL COMMENT '消息标题',
`msgContent` text COMMENT '消息内容',
`receiveBy` varchar(32) DEFAULT NULL COMMENT '接收人用户ID',
`receiveName` varchar(100) DEFAULT NULL COMMENT '接收人姓名',
`pushBy` varchar(32) DEFAULT NULL COMMENT '推送人用户ID',
`pushByName` varchar(100) DEFAULT NULL COMMENT '推送人姓名',
`pushType` varchar(50) DEFAULT NULL COMMENT '推送类型',
`link` varchar(500) DEFAULT NULL COMMENT '生日祝福页面链接',
`createdBy` varchar(32) DEFAULT NULL COMMENT '创建人',
`createdAt` bigint DEFAULT NULL COMMENT '创建时间',
`updatedBy` varchar(32) DEFAULT NULL COMMENT '修改人',
`updatedAt` bigint DEFAULT NULL COMMENT '修改时间',
`delFlag` tinyint(1) DEFAULT 0 COMMENT '删除标记',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='生日祝福消息发送记录';
+35 -34
View File
@@ -1,59 +1,60 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<configuration scan="false" scanPeriod="60000" debug="false"> <configuration scan="false" scanPeriod="60000" debug="false">
<!-- 定义日志文件的存储路径 --> <!-- 日志存储路径 -->
<property name="LOG_HOME" value="./logs"/> <property name="LOG_HOME" value="./logs"/>
<!-- 控制台输出配置 --> <!-- 控制台输出 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder> <encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder> </encoder>
</appender> </appender>
<!-- INFO级别日志文件配置 -->
<appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <!--
<file>${LOG_HOME}/info.log</file> #################################################################################
# #
# 全文输出日志(一个文件存储)start #
# #
#################################################################################
-->
<!-- 所有级别日志写入同一个文件 -->
<appender name="ALL_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/app.log</file>
<encoder> <encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder> </encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>INFO</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/info-%d{yyyy-MM-dd}.log</fileNamePattern> <fileNamePattern>${LOG_HOME}/app-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory> <maxHistory>300</maxHistory>
</rollingPolicy> </rollingPolicy>
</appender> </appender>
<!-- ERROR级别日志文件配置 --> <!-- 框架日志级别(按需调整) -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/error.log</file>
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/error-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
</appender>
<logger name="java" additivity="false" />
<logger name="org.eclipse.jetty" level="INFO"/> <logger name="org.eclipse.jetty" level="INFO"/>
<logger name="org.quartz" level="INFO"/> <logger name="org.quartz" level="INFO"/>
<logger name="org.nutz" level="DEBUG"/> <logger name="org.nutz" level="DEBUG"/>
<!-- 日志级别和appender的关联 --> <!-- root logger:DEBUG 级别,输出到控制台和全量文件 -->
<root level="DEBUG"> <root level="DEBUG">
<appender-ref ref="STDOUT"/> <appender-ref ref="STDOUT"/>
<appender-ref ref="INFO_FILE"/> <appender-ref ref="ALL_FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root> </root>
<!--
#################################################################################
# #
# 全文输出日志(一个文件存储)end #
# #
#################################################################################
-->
</configuration> </configuration>
Binary file not shown.

After

Width:  |  Height:  |  Size: 985 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 KiB

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none">
<circle cx="64" cy="64" r="58" stroke="#F5222D" stroke-width="3"/>
<path d="M21 54A44 44 0 0 1 107 54" stroke="#F5222D" stroke-width="9" stroke-linecap="round"/>
<path d="M21 74A44 44 0 0 0 107 74" stroke="#F5222D" stroke-width="9" stroke-linecap="round"/>
<circle cx="64" cy="64" r="27" stroke="#F5222D" stroke-width="9"/>
<path d="M64 20V108" stroke="#F5222D" stroke-width="8" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 531 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1021 KiB

@@ -0,0 +1,8 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="24" r="22" fill="#EAF3FF"/>
<rect x="15" y="11" width="18" height="17" rx="4" fill="#2F7CF6"/>
<path d="M12 24C10.8954 24 10 24.8954 10 26V32C10 33.1046 10.8954 34 12 34H36C37.1046 34 38 33.1046 38 32V26C38 24.8954 37.1046 24 36 24C34.8954 24 34 24.8954 34 26V29H14V26C14 24.8954 13.1046 24 12 24Z" fill="#1769E8"/>
<rect x="14" y="28" width="20" height="7" rx="2.5" fill="#2F7CF6"/>
<path d="M16 34H20L19 38H15L16 34Z" fill="#1769E8"/>
<path d="M28 34H32L33 38H29L28 34Z" fill="#1769E8"/>
</svg>

After

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 986 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -124,11 +124,71 @@ body {
} }
.custom-list-loading { .custom-list-loading {
background-color: unset; min-width: 132px;
padding: 15px 12px 12px;
border-radius: 14px;
color: #666;
background-color: rgba(255, 255, 255, 0.98);
box-shadow: 0 6px 22px rgba(128, 72, 72, 0.08);
} }
.custom-list-loading .van-icon { .custom-list-loading .van-icon {
font-size: 108px; position: relative;
width: 88px;
height: 88px;
overflow: visible;
font-size: 88px;
}
.custom-list-loading .van-icon::before {
position: absolute;
inset: 6px;
border: 1px solid rgba(255, 112, 103, 0.24);
border-radius: 50%;
content: "";
}
.custom-list-loading .van-icon::after {
position: absolute;
top: 2px;
left: 41px;
width: 6px;
height: 6px;
border-radius: 50%;
background: #ff8c83;
box-shadow: 33px 37px 0 #ff6f68, -35px 21px 0 #ffb0a9;
content: "";
transform-origin: 3px 42px;
animation: smart-union-loading-orbit 1.8s linear infinite;
}
.custom-list-loading .van-icon__image {
position: absolute;
top: 50%;
left: 50%;
box-sizing: border-box;
width: 54px;
height: 54px;
padding: 5px;
border-radius: 50%;
background: rgba(255, 238, 236, 0.92);
object-fit: contain;
transform: translate(-50%, -50%);
}
@keyframes smart-union-loading-orbit {
to {
transform: rotate(360deg);
}
}
.custom-list-loading .van-toast__text {
margin-top: 4px;
color: #707070;
font-size: 11px;
font-weight: 500;
line-height: 18px;
white-space: pre-line;
} }
.list-card { .list-card {
@@ -308,3 +368,11 @@ body {
text-align: left; text-align: left;
} }
/* 西财独有的幸福工荟与立案分派页面沿用迁移后的卡片间距和手机安全区。 */
.swufe-mobile-page { min-height:100vh; background:#f3f7fd; padding-bottom:calc(20px + env(safe-area-inset-bottom)); }
.swufe-mobile-page .van-search { margin:10px 12px; padding:0; border-radius:12px; overflow:hidden; }
.swufe-mobile-page .van-search__content { background:#fff; }
.swufe-mobile-page .van-nav-bar .van-icon, .swufe-mobile-page .van-nav-bar__text { color:#1989fa; }
.swufe-mobile-page .van-cell-group { border-radius:12px; overflow:hidden; }
.swufe-mobile-page img { max-width:100%; }
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

@@ -8,31 +8,27 @@
) )
})(navigator.userAgent || navigator.vendor || window.opera) })(navigator.userAgent || navigator.vendor || window.opera)
// jQuery 回调的 this 不是 Vue 实例,使用项目已有的 Element 消息入口。
// 每个请求最多提示一次;保留响应正文,页面 then/always 继续执行并关闭 loading。
$.ajaxSetup({ $.ajaxSetup({
contentType: "application/x-www-form-urlencoded;charset=utf-8", contentType: "application/x-www-form-urlencoded;charset=utf-8",
success: function (response, textStatus, jqXHR) { success: (response, textStatus, jqXHR) => {
if (response.code !== 0) { if (response && typeof response === "object" && response.code !== undefined && response.code !== 0) {
this.$message.error(response.msg) ELEMENT.Message.error(response.msg || "请求处理失败,请稍后重试");
} else { jqXHR.platformErrorShown = true;
return response
} }
}, },
error: function (v1) { error: (xhr) => {
this.$message.error(v1.status + " " + v1.statusText) const message = xhr.responseJSON && xhr.responseJSON.msg;
ELEMENT.Message.error(message || (xhr.status === 0 ? "网络连接失败或请求超时,请稍后重试" : "请求失败,状态码:" + xhr.status));
xhr.platformErrorShown = true;
}, },
complete: function (XMLHttpRequest, textStatus) { complete: (xhr) => {
var sessionstatus = XMLHttpRequest.getResponseHeader("loginStatus") //通过XMLHttpRequest取得响应头,sessionstatus, const sessionStatus = xhr.getResponseHeader("loginStatus");
if (sessionstatus == "accessDenied") { if (!xhr.platformErrorShown && sessionStatus === "accessDenied") {
this.$message({ ELEMENT.Message.error("登录失效,请刷新页面重新登录");
message: "\u767b\u5f55\u5931\u6548\uff0c\u8bf7\u5237\u65b0\u9875\u9762\u91cd\u65b0\u767b\u5f55", } else if (!xhr.platformErrorShown && sessionStatus === "unauthorized") {
type: "error" ELEMENT.Message.error("没有权限");
})
}
if (sessionstatus == "unauthorized") {
this.$message({
message: "\u6ca1\u6709\u6743\u9650",
type: "error"
})
} }
} }
}) })
@@ -69,13 +69,17 @@ const initTableMixins = {
}, },
pageData(data = null) { pageData(data = null) {
const address = this.pageDataUrl ? this.pageDataUrl : loc() + "/pageData" const address = this.pageDataUrl ? this.pageDataUrl : loc() + "/pageData"
this.tableLoading = true this.$set(this, "tableLoading", true)
this.$axios.post(address, data ? data : this.pageForm).then((res) => { // 业务错误会由公共拦截器 reject,同样需要 finally 解除列表加载状态。
this.tableLoading = false return this.$axios.post(address, data ? data : this.pageForm).then((res) => {
if (res.code === 0) { if (res.code === 0) {
this.tableData = res.data.list this.$set(this, "tableData", res.data.list)
this.pageForm.totalCount = res.data.totalCount this.$set(this.pageForm, "totalCount", res.data.totalCount)
} }
}).catch(() => {
// 中文错误已由统一响应拦截器提示,避免页面重复弹出。
}).finally(() => {
this.$set(this, "tableLoading", false)
}) })
}, },
notifySuccess(msg) { notifySuccess(msg) {
@@ -8,6 +8,8 @@ const commonUtil = {
"x-requested-with": "XMLHttpRequest" "x-requested-with": "XMLHttpRequest"
} }
}) })
// H5 布局提供加载状态适配;PC 布局未提供此方法时不改变其请求行为。
if (window.bindH5AxiosLoading) window.bindH5AxiosLoading(axiosService)
//axios拦截器 //axios拦截器
axiosService.interceptors.response.use( axiosService.interceptors.response.use(
(response) => { (response) => {
@@ -0,0 +1,197 @@
/**
* H5 弹框统一历史栈。两种页面 API 和 Vant 弹框适配共用唯一的 popstate 监听。
* 历史状态保留 PJAX 页面信息;关闭弹框只消费本页增加的记录,不触发业务页面返回。
*/
;((global) => {
if (global.h5PopupHistory && global.h5PopupHistory.unified) return
const callbacks = new Map()
const entries = []
const subscribers = new Set()
const pageInstances = new Set()
const sessionId = Date.now().toString(36)
let sequence = 0
let pending = null
let notifying = false
let pageKey = ""
let pageCallback = null
const names = () => entries.map((entry) => entry.popupKey)
const notify = () => {
notifying = true
try {
if (pageCallback) pageCallback(names())
subscribers.forEach((callback) => callback(names()))
} finally { notifying = false }
}
/** entry 为栈记录;同步业务回调与 Vant input 事件,不再创建或回退历史。 */
const dismiss = (entry) => {
const callback = callbacks.get(entry.token)
if (callback) callback(entry.popupKey)
if (entry.component && !entry.component._isDestroyed) {
const component = entry.component
if ((component.$options.name || "").replace(/-/g, "").toLowerCase() === "vandialog") {
// 返回键等同取消确认,结束 Dialog.confirm 的 Promise,避免后续操作一直等待。
component.$emit("cancel")
component.onClose("cancel")
} else component.$emit("input", false)
}
}
/** token 为注册标识,popupKey 为弹框名称;返回栈记录,重复打开同一弹框时复用记录。 */
const open = (token, popupKey, component = null) => {
const existing = entries.find((entry) => entry.token === token && entry.popupKey === popupKey)
if (existing) return existing
if (pending) {
pending.promise.then(() => {
if (!component || (!component._isDestroyed && component.value)) open(token, popupKey, component)
})
return null
}
const entry = {id: ++sequence, token: token, popupKey: popupKey, component: component, claimable: !component}
entries.push(entry)
global.history.pushState(Object.assign({}, global.history.state || {}, {
__h5PopupHistory: {session: sessionId, ids: entries.map((item) => item.id)}
}), global.document.title, global.location.href)
notify()
// 命名弹层触发本轮 Vue 渲染后不再允许其他弹框占用其记录。
if (global.Vue) global.Vue.nextTick(() => { entry.claimable = false })
return entry
}
/** index 为需要关闭的最外层弹框位置;Promise<void> 在浏览器完成回退后才结束。 */
const closeFrom = (index) => {
if (index < 0 || index >= entries.length) return Promise.resolve()
if (pending) {
// 重复关闭只追踪本次请求时的弹框,避免异步回退结束后误关新打开的弹框。
const closingIds = entries.slice(index).map((entry) => entry.id)
return pending.promise.then(() => closeFrom(entries.findIndex((entry) => closingIds.includes(entry.id))))
}
let resolveClose
const promise = new Promise((resolve) => { resolveClose = resolve })
pending = {promise: promise, resolve: resolveClose}
global.history.go(-(entries.length - index))
return promise
}
global.addEventListener("popstate", (event) => {
const marker = event.state && event.state.__h5PopupHistory
if (!entries.length && !pending) {
// 已关闭的弹框不因浏览器前进而重新显示,也不把旧弹框记录交给 PJAX。
if (marker && marker.session === sessionId) event.stopImmediatePropagation()
return
}
event.stopImmediatePropagation()
const targetIds = marker && marker.session === sessionId ? marker.ids : []
const removed = []
while (entries.length && !targetIds.includes(entries[entries.length - 1].id)) removed.push(entries.pop())
notifying = true
try { removed.forEach(dismiss) } finally { notifying = false }
notify()
const finished = pending
pending = null
if (finished) finished.resolve()
}, true)
global.h5PopupHistory = {
unified: true,
register(closeCallback) {
const token = "h5-popup-" + (++sequence)
callbacks.set(token, closeCallback)
return token
},
unregister(token) { this.clear(token).then(() => callbacks.delete(token)) },
open(token, popupKey) { if (callbacks.has(token)) open(token, popupKey) },
close(token, popupKey) {
if (notifying) return false
const index = entries.findIndex((entry) => entry.token === token && entry.popupKey === popupKey)
if (index < 0) return false
closeFrom(index)
return true
},
clear(token) { return closeFrom(entries.findIndex((entry) => entry.token === token)) },
clearAll() { return closeFrom(0) },
hasOpen() { return entries.length > 0 || !!pending },
// PJAX 替换 DOM 前销毁旧页面实例,使业务组件的 beforeDestroy 能正常注销监听。
destroyPage() {
pageInstances.forEach((instance) => { if (!instance._isDestroyed) instance.$destroy() })
pageInstances.clear()
}
}
// 命名弹层 API 适配到同一历史栈,不另行监听 popstate。
global.h5HistoryLayerManager = {
get stack() { return names() },
get pageKey() { return pageKey },
register(key, onChange) {
pageKey = key
pageCallback = typeof onChange === "function" ? onChange : null
notify()
},
ensureRegistered(key) { if (!pageKey) pageKey = key; return true },
subscribe(callback) {
subscribers.add(callback)
callback(names())
return () => subscribers.delete(callback)
},
unregister(key) { if (!key || key === pageKey) this.reset() },
open(name) { if (!name) return false; open("named-layer", name); return true },
close(name, afterClose = null) {
if (notifying) return false
const index = name ? entries.findIndex((entry) => entry.popupKey === name) : entries.length - 1
if (index < 0) { if (afterClose) afterClose(); return false }
closeFrom(index).then(() => { if (afterClose) afterClose() })
return true
},
closeAll(afterClose = null) {
const hadEntries = entries.length > 0
closeFrom(0).then(() => { if (afterClose) afterClose() })
return hadEntries
},
/** url 为业务页面地址,data 为可选 PJAX 参数;返回 true 表示已接管导航。 */
closeAllAndNavigate(url, data = null) {
if (!url) return false
closeFrom(0).then(() => global.pjaxReplace(url, data))
return true
},
reset() {
pageCallback = null
pageKey = ""
subscribers.clear()
return closeFrom(0)
}
}
/**
* Vant Popup/Dialog 统一适配已有 v-model 页面,包括 ActionSheet 内部的 Popup。
* 已登记弹层复用记录;未登记弹层自动注册,组件销毁时注销 watcher 和回调。
*/
if (global.Vue) global.Vue.mixin({
beforeCreate() {
const props = this.$options.props || {}
if (props.closeOnPopstate) {
// 禁用 Vant 的组件级返回监听,由统一栈按后进先出顺序处理。
this.$options.propsData = Object.assign({}, this.$options.propsData, {closeOnPopstate: false})
}
},
mounted() {
if (this.$el && this.$el.id === "app" && this.$root === this) pageInstances.add(this)
const name = (this.$options.name || "").toLowerCase().replace(/-/g, "")
if (name !== "vanpopup" && name !== "vandialog") return
this._h5PopupToken = global.h5PopupHistory.register(() => this.$emit("input", false))
const sync = (visible) => {
if (notifying) return
if (visible) {
const top = entries[entries.length - 1]
if (top && !top.component && top.claimable) top.component = this
else open(this._h5PopupToken, "vant-popup-" + this._uid, this)
} else {
const index = entries.findIndex((entry) => entry.component === this)
if (index >= 0) closeFrom(index)
}
}
this._h5PopupUnwatch = this.$watch("value", sync, {immediate: true})
},
beforeDestroy() {
pageInstances.delete(this)
if (this._h5PopupUnwatch) this._h5PopupUnwatch()
if (this._h5PopupToken) {
const index = entries.findIndex((entry) => entry.component === this)
if (index >= 0) closeFrom(index)
global.h5PopupHistory.unregister(this._h5PopupToken)
}
}
})
})(window)
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,634 @@
<template>
<div>
<van-action-sheet
v-model="visible"
class="activity-schedule-calendar"
:style="{ height: '78%' }"
:close-on-click-overlay="true"
title="活动日历"
closeable>
<div class="activity-schedule-calendar__body">
<div class="activity-schedule-calendar__month-bar">
<button type="button" class="activity-schedule-calendar__month-btn" @click="changeMonth(-1)">
<van-icon name="arrow-left"></van-icon>
</button>
<div class="activity-schedule-calendar__month-title">{{ currentYear }}年{{ currentMonth }}月</div>
<button type="button" class="activity-schedule-calendar__month-btn" @click="changeMonth(1)">
<van-icon name="arrow"></van-icon>
</button>
</div>
<div class="activity-schedule-calendar__weekdays">
<span v-for="item in weekdays" :key="item">{{ item }}</span>
</div>
<div class="activity-schedule-calendar__days">
<button
v-for="day in calendarDays"
:key="day.key"
type="button"
class="activity-schedule-calendar__day"
:class="{
'is-other-month': !day.currentMonth,
'is-selected': day.key === selectedDate,
'is-today': day.isToday
}"
@click="selectDay(day)">
<span>
<span class="activity-schedule-calendar__day-number">{{ day.day }}</span>
<small v-if="day.isToday" class="activity-schedule-calendar__today-label">今</small>
</span>
<i v-if="day.status" class="activity-schedule-calendar__day-status" :class="'is-' + day.status"></i>
</button>
</div>
<div class="activity-schedule-calendar__legend">
<span><i class="is-solid"></i>已签到</span>
<span><i class="is-outline"></i>可签到</span>
<span><i class="is-muted"></i>未开始</span>
</div>
<div v-if="showMap" :id="mapContainerId" class="activity-schedule-calendar__map"></div>
<section class="activity-schedule-calendar__schedule-card">
<div class="activity-schedule-calendar__schedule-title">
{{ selectedDate ? selectedDate + ' 的活动时段' : '活动时段' }}
</div>
<div v-if="selectedTimes.length" class="activity-schedule-calendar__schedule-list">
<div v-for="item in selectedTimes" :key="item.id" class="activity-schedule-calendar__schedule-item">
<div class="activity-schedule-calendar__schedule-main">
<van-icon :name="item.isAttend === true ? 'checked' : 'clock-o'"></van-icon>
<div class="activity-schedule-calendar__schedule-copy">
<div class="activity-schedule-calendar__schedule-time">
{{ formatTime(item.courseStartTime) }} - {{ formatTime(item.courseEndTime) }}
</div>
<div v-if="row && row.courseLocation" class="activity-schedule-calendar__schedule-location">
<van-icon name="location-o"></van-icon>
<span>{{ row.courseLocation }}</span>
</div>
</div>
</div>
<span v-if="item.isAttend === true" class="activity-schedule-calendar__status is-success">已签到</span>
<button
v-else-if="canSignItem(item)"
type="button"
class="activity-schedule-calendar__sign-btn"
@click.stop="onSign(item)">签到</button>
<span v-else class="activity-schedule-calendar__status" :class="{
'is-ended': getScheduleStatus(item) === '已结束',
'is-progressing': getScheduleStatus(item) === '进行中'
}">
{{ getScheduleStatus(item) }}
</span>
</div>
</div>
<div v-else class="activity-schedule-calendar__empty">
<img src="/assets/mobile/img/activity/schedule-empty.png?v=20260821-transparent" alt="">
<div class="activity-schedule-calendar__empty-title">当天暂无签到时段</div>
<div class="activity-schedule-calendar__empty-desc">当前日期没有需要签到的活动安排</div>
</div>
</section>
</div>
</van-action-sheet>
<van-popup
v-model="signVisible"
round
closeable
:safe-area-inset-bottom="true"
:close-on-click-overlay="false"
:style="{ width: '80%', height: '66%' }"
get-container="#app"
@close="onSignClose">
<scan-code ref="scanCodeRef"></scan-code>
</van-popup>
</div>
</template>
<script>
module.exports = {
name: "ActivityScheduleCalendar",
props: {
moduleType: {
type: String,
default: "brand"
}
},
components: {
"scan-code": httpVueLoader("/components/plugins/sysScanCode/index.vue?v=" + new Date().getTime())
},
data() {
return {
visible: false,
row: null,
currentYear: new Date().getFullYear(),
currentMonth: new Date().getMonth() + 1,
selectedDate: "",
weekdays: ["日", "一", "二", "三", "四", "五", "六"],
selectCourseTime: null,
signVisible: false,
markerLayer: null,
map: null,
circle: null,
mapContainerId: "activityScheduleMap_" + Math.random().toString(36).slice(2)
}
},
computed: {
courseTimes() {
return this.row && Array.isArray(this.row.courseTimes) ? this.row.courseTimes : []
},
scheduleDateMap() {
const map = {}
this.courseTimes.forEach(item => {
const key = this.dateKey(item)
if (!key) return
if (!map[key]) map[key] = []
map[key].push(item)
})
return map
},
selectedTimes() {
return this.scheduleDateMap[this.selectedDate] || []
},
canSign() {
return !!(this.row && this.row.isSign === true && this.row.isMobileSign === true)
},
showMap() {
return !!(this.moduleType === "brand" && this.row && this.row.isMobileSign === true && this.row.signType === 3)
},
calendarDays() {
const firstDay = new Date(this.currentYear, this.currentMonth - 1, 1)
const startDate = new Date(this.currentYear, this.currentMonth - 1, 1 - firstDay.getDay())
const todayKey = this.formatDateKey(new Date())
return Array.from({ length: 42 }, (_, index) => {
const date = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate() + index)
const key = this.formatDateKey(date)
return {
key,
day: date.getDate(),
currentMonth: date.getFullYear() === this.currentYear && date.getMonth() + 1 === this.currentMonth,
isToday: key === todayKey,
status: this.getDayStatus(this.scheduleDateMap[key] || [])
}
})
}
},
methods: {
onOpen(row) {
this.row = row
const initialDate = this.formatDateKey(new Date())
const date = this.$moment(initialDate)
this.currentYear = date.year()
this.currentMonth = date.month() + 1
this.selectedDate = initialDate
this.visible = true
this.$nextTick(() => {
if (this.showMap) this.initMap()
})
},
dateKey(item) {
const value = item && (item.courseDate || item.courseStartTime)
return value ? this.$moment(value).format("YYYY-MM-DD") : ""
},
formatDateKey(date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, "0")
const day = String(date.getDate()).padStart(2, "0")
return year + "-" + month + "-" + day
},
formatTime(value) {
return value ? this.$moment(value).format("HH:mm") : "--"
},
canSignItem(item) {
if (!this.canSign || !item || !item.courseStartTime || !item.courseEndTime) return false
const now = this.$moment()
return !now.isBefore(this.$moment(item.courseStartTime)) && !now.isAfter(this.$moment(item.courseEndTime))
},
getScheduleStatus(item) {
if (!item || !item.courseStartTime || !item.courseEndTime) return "待开始"
const now = this.$moment()
if (now.isBefore(this.$moment(item.courseStartTime))) return "待开始"
if (now.isAfter(this.$moment(item.courseEndTime))) return "已结束"
return "进行中"
},
getDayStatus(items) {
if (items.some(item => item.isAttend === true)) return "signed"
if (items.some(item => this.canSignItem(item))) return "available"
if (items.some(item => item.courseStartTime && this.$moment().isBefore(this.$moment(item.courseStartTime)))) return "pending"
return ""
},
changeMonth(offset) {
const date = new Date(this.currentYear, this.currentMonth - 1 + offset, 1)
this.currentYear = date.getFullYear()
this.currentMonth = date.getMonth() + 1
},
selectDay(day) {
this.selectedDate = day.key
if (!day.currentMonth) {
const date = this.$moment(day.key)
this.currentYear = date.year()
this.currentMonth = date.month() + 1
}
},
onSign(courseTime) {
this.selectCourseTime = courseTime
if (this.row.signType === 1) {
this.signVisible = true
this.$nextTick(() => this.$refs.scanCodeRef.init())
} else if (this.row.signType === 2) {
this.makeCode()
} else if (this.row.signType === 3 && this.moduleType === "brand") {
this.positionSign()
} else {
this.$toast("此签到模式正在升级中")
}
},
onSignClose() {
if (this.$refs.scanCodeRef) this.$refs.scanCodeRef.closeScan()
},
makeCode() {
const baseUrl = this.moduleType === "family"
? "/platform/family/mine/passiveScan"
: "/platform/trainSignUp/mine/passiveScan"
const content = jrQrcode.getQrBase64(baseUrl + "?id=" + this.selectCourseTime.id)
vant.ImagePreview([content])
},
positionSign() {
this.getLocation(coords => {
if (!coords) return
this.$axios.post("/platform/trainSignUp/mine/positionSign", {
timeId: this.selectCourseTime.id,
courseId: this.row.id,
lat: coords.lat,
lng: coords.lng
}).then(res => {
if (res.code === 0) {
this.selectCourseTime.isAttend = true
this.$toast("签到成功")
} else {
this.$toast(res.msg)
}
})
})
},
getLocation(callback) {
if (!navigator.geolocation) {
this.$toast("当前浏览器不支持定位功能")
callback(null)
return
}
const loading = vant.Toast.loading({ message: "获取定位中...", forbidClick: false, duration: 0 })
navigator.geolocation.getCurrentPosition(position => {
loading.close()
callback({ lat: position.coords.latitude, lng: position.coords.longitude })
}, error => {
loading.close()
const messageMap = {
1: "请允许浏览器获取位置信息",
2: "无法获取当前位置",
3: "定位超时,请重试"
}
this.$toast(messageMap[error.code] || "定位失败,请稍后重试")
callback(null)
}, { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 })
},
initMap() {
if (!this.row.courseLocationCoordinates || typeof TMap === "undefined") return
const position = JSON.parse(this.row.courseLocationCoordinates)
const center = new TMap.LatLng(position[0], position[1])
if (this.map) this.map.destroy()
this.map = new TMap.Map(this.mapContainerId, { zoom: 16, center })
this.markerLayer = new TMap.MultiMarker({
map: this.map,
geometries: [{ id: "activity", position: center }]
})
this.circle = new TMap.MultiCircle({
map: this.map,
geometries: [{ center, radius: this.row.radius || 100 }]
})
}
}
}
</script>
<style scoped>
.activity-schedule-calendar {
overflow: hidden;
border-radius: 18px 18px 0 0;
background: #f4f7fc;
}
.activity-schedule-calendar /deep/ .van-action-sheet__header {
height: 50px;
color: #1f2f43;
background: #fff;
font-size: 16px;
line-height: 50px;
}
.activity-schedule-calendar /deep/ .van-action-sheet__close {
top: 14px;
color: #7e899a;
font-size: 21px;
}
.activity-schedule-calendar__body {
height: 100%;
padding: 14px 14px 24px;
overflow-y: auto;
overflow-anchor: none;
box-sizing: border-box;
}
.activity-schedule-calendar__month-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.activity-schedule-calendar__month-btn {
display: flex;
width: 36px;
height: 36px;
padding: 0;
align-items: center;
justify-content: center;
border: 1px solid #edf1f6;
border-radius: 50%;
color: #263548;
background: #fff;
box-shadow: 0 5px 14px rgba(55, 85, 126, .08);
}
.activity-schedule-calendar__month-title {
color: #1f2f43;
font-size: 18px;
font-weight: 600;
}
.activity-schedule-calendar__weekdays,
.activity-schedule-calendar__days {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
text-align: center;
}
.activity-schedule-calendar__weekdays {
margin-bottom: 5px;
color: #58677b;
font-size: 12px;
line-height: 28px;
}
.activity-schedule-calendar__day {
position: relative;
display: flex;
width: 38px;
height: 48px;
margin: 1px auto;
padding: 0;
align-items: center;
flex-direction: column;
justify-content: center;
border: 0;
color: #263548;
background: transparent;
font-size: 14px;
line-height: 1;
box-sizing: border-box;
}
.activity-schedule-calendar__day > span {
position: absolute;
top: 4px;
left: 50%;
transform: translateX(-50%);
z-index: 1;
display: flex;
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
border-radius: 50%;
box-sizing: border-box;
}
.activity-schedule-calendar__day-number {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
line-height: 14px;
}
.activity-schedule-calendar__today-label {
position: absolute;
top: 2px;
right: 0;
left: 0;
color: #1989fa;
font-size: 10px;
line-height: 10px;
text-align: center;
}
.activity-schedule-calendar__day.is-other-month > span {
color: #bdc5d0;
}
.activity-schedule-calendar__day.is-selected > span {
flex: 0 0 40px;
width: 40px;
min-width: 40px;
height: 40px;
min-height: 40px;
color: #fff;
background: #1989fa;
box-shadow: 0 5px 12px rgba(25, 137, 250, .22);
}
.activity-schedule-calendar__day.is-selected .activity-schedule-calendar__today-label {
top: 2px;
color: #fff;
}
.activity-schedule-calendar__day-status {
position: absolute;
bottom: -3px;
left: 50%;
width: 6px;
height: 6px;
transform: translateX(-50%);
border-radius: 50%;
box-sizing: border-box;
}
.activity-schedule-calendar__day-status.is-signed {
background: #1989fa;
}
.activity-schedule-calendar__day-status.is-available {
border: 1px solid #1989fa;
background: #fff;
}
.activity-schedule-calendar__day-status.is-pending {
background: #d8dde5;
}
.activity-schedule-calendar__legend {
display: flex;
margin: 12px 4px 16px;
align-items: center;
justify-content: space-around;
color: #78869a;
font-size: 11px;
}
.activity-schedule-calendar__legend span {
display: inline-flex;
align-items: center;
gap: 5px;
}
.activity-schedule-calendar__legend i {
width: 9px;
height: 9px;
border-radius: 50%;
box-sizing: border-box;
}
.activity-schedule-calendar__legend .is-solid { background: #1989fa; }
.activity-schedule-calendar__legend .is-outline { border: 1px solid #1989fa; }
.activity-schedule-calendar__legend .is-muted { background: #d8dde5; }
.activity-schedule-calendar__map {
width: 100%;
height: 150px;
margin-bottom: 12px;
overflow: hidden;
border-radius: 12px;
}
.activity-schedule-calendar__schedule-card {
overflow: hidden;
border-radius: 12px;
background: #fff;
box-shadow: 0 7px 20px rgba(55, 85, 126, .07);
}
.activity-schedule-calendar__schedule-title {
padding: 12px 14px;
border-bottom: 1px solid #edf1f6;
color: #263548;
font-size: 14px;
font-weight: 600;
}
.activity-schedule-calendar__schedule-item {
display: flex;
min-height: 70px;
padding: 12px 14px;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #edf1f6;
box-sizing: border-box;
}
.activity-schedule-calendar__schedule-item:last-child { border-bottom: 0; }
.activity-schedule-calendar__schedule-main {
display: flex;
min-width: 0;
align-items: flex-start;
gap: 9px;
}
.activity-schedule-calendar__schedule-main > .van-icon {
margin-top: 2px;
color: #1989fa;
font-size: 18px;
}
.activity-schedule-calendar__schedule-time {
color: #263548;
font-size: 15px;
line-height: 22px;
}
.activity-schedule-calendar__schedule-location {
display: flex;
margin-top: 5px;
align-items: center;
gap: 4px;
color: #7d899a;
font-size: 12px;
line-height: 18px;
}
.activity-schedule-calendar__status {
flex: 0 0 auto;
padding: 4px 8px;
border-radius: 5px;
color: #8b96a7;
background: #f1f4f8;
font-size: 11px;
}
.activity-schedule-calendar__status.is-success {
color: #19a965;
background: #eaf8f1;
}
.activity-schedule-calendar__status.is-progressing {
color: #ff8a00;
background: #fff4e6;
}
.activity-schedule-calendar__status.is-ended {
color: #98a2b2;
background: #eef1f5;
}
.activity-schedule-calendar__sign-btn {
display: inline-flex;
min-width: 42px !important;
height: 24px !important;
padding: 0 8px !important;
align-items: center;
justify-content: center;
border: 1px solid #1989fa !important;
border-radius: 5px !important;
color: #fff !important;
background-color: #1989fa !important;
box-shadow: none !important;
font-size: 11px !important;
}
.activity-schedule-calendar__empty {
padding: 18px 14px 22px;
color: #9aa5b5;
text-align: center;
}
.activity-schedule-calendar__empty img {
display: block;
width: 142px;
height: 94px;
margin: 0 auto 4px;
object-fit: contain;
}
.activity-schedule-calendar__empty-title {
color: #58677b;
font-size: 13px;
line-height: 20px;
}
.activity-schedule-calendar__empty-desc {
margin-top: 2px;
color: #a2adbc;
font-size: 11px;
line-height: 18px;
}
</style>
@@ -37,26 +37,32 @@ module.exports = {
<style scoped> <style scoped>
.table-column { .table-column {
display: flex; display: flex;
padding: 6px 0; width: calc(50% - 6px);
align-items: center; min-width: 0;
padding: 4px 0;
align-items: center;
box-sizing: border-box;
} }
.label { .label {
color: #333; color: #98a3b3;
font-size: 14px; font-size: 12px;
line-height: 18px;
flex-shrink: 0; flex-shrink: 0;
max-width: 120px; max-width: 50%;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.value { .value {
color: #555; min-width: 0;
font-size: 14px; color: #56657a;
flex-grow: 1; /* 动态占据剩余部分 */ font-size: 12px;
white-space: nowrap; /* 防止换行 */ line-height: 18px;
overflow: hidden; /* 隐藏溢出的部分 */ flex-grow: 1;
text-overflow: ellipsis; /* 省略号 */ white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
</style> </style>
@@ -1,7 +1,9 @@
<template> <template>
<van-pull-refresh v-model="tableRefreshing" @refresh="onRefresh"> <van-pull-refresh class="table-list" v-model="tableRefreshing" @refresh="onRefresh">
<div v-if="tableData.length === 0" class="empty-state"> <div v-if="tableData.length === 0" class="empty-state">
<van-empty description="暂无数据"></van-empty> <slot name="empty">
<van-empty description="暂无数据"></van-empty>
</slot>
</div> </div>
<van-list v-if="tableData && tableData.length>0" <van-list v-if="tableData && tableData.length>0"
v-model="tableLoading" v-model="tableLoading"
@@ -9,7 +11,8 @@
finished-text="没有更多了" finished-text="没有更多了"
@load="onLoad"> @load="onLoad">
<div class="table-list-container"> <div class="table-list-container">
<div v-for="(row, index) in tableData" :key="index" class="table-list-item"> <div v-for="(row, index) in tableData" :key="index" class="table-list-item"
:data-row-id="row && row.id ? row.id : ''">
<slot name="header" :index="index" :row="row"> <slot name="header" :index="index" :row="row">
<div class="item-header"> <div class="item-header">
<div class="item-title">{{ calcTitle(row) }}</div> <div class="item-title">{{ calcTitle(row) }}</div>
@@ -18,12 +21,12 @@
</div> </div>
</slot> </slot>
<div style="display: flex;column-gap: 10px" <div class="table-list-content"
:style="{'max-height':img ? '100px':'unset', 'overflow': img ? 'hidden':'unset' }"> :style="{'max-height':img ? '100px':'unset', 'overflow': img ? 'hidden':'unset' }">
<div class="img-container" v-if="img"> <div class="img-container" v-if="img">
<img :src="row[img]" alt="" style="object-fit: cover"> <img :src="row[img]" alt="">
</div> </div>
<div style="width: 100%"> <div class="table-list-columns">
<slot :index="index" :row="row"></slot> <slot :index="index" :row="row"></slot>
</div> </div>
</div> </div>
@@ -66,6 +69,10 @@ module.exports = {
img: { img: {
type: String, type: String,
default: "" default: ""
},
show_loading_overlay: {
type: Boolean,
default: true
} }
}, },
watch: { watch: {
@@ -87,6 +94,33 @@ module.exports = {
} }
}, },
methods: { methods: {
/**
* 获取当前列表快照。
* 返回值包含已加载记录、分页参数和加载完成状态,供业务页面在进入详情前暂存列表现场。
*/
getListState() {
return {
tableData: JSON.parse(JSON.stringify(this.tableData)),
localPageForm: JSON.parse(JSON.stringify(this.localPageForm)),
tableFinished: this.tableFinished
}
},
/**
* 恢复列表快照。
* state 应包含 tableData、localPageForm 和 tableFinished;返回 true 表示恢复成功。
*/
restoreListState(state) {
if (!state || !Array.isArray(state.tableData)) return false
this.$set(this, "tableData", state.tableData)
this.$set(this, "localPageForm", {...this.localPageForm, ...(state.localPageForm || {})})
this.$set(this, "tableFinished", !!state.tableFinished)
this.$set(this, "tableLoading", false)
this.$set(this, "tableRefreshing", false)
this.$emit("update:page_form", {...this.localPageForm})
return true
},
calcTitle(row) { calcTitle(row) {
if (!this.title) return "" if (!this.title) return ""
// 处理key1.key2.key3 这样的形式 // 处理key1.key2.key3 这样的形式
@@ -111,23 +145,32 @@ module.exports = {
pageData() { pageData() {
this.tableLoading = true this.tableLoading = true
const isInitialLoad = this.tableData.length === 0
this.$emit("loading-change", {loading: true, initial: isInitialLoad})
const loading = createListLoading() const loading = this.show_loading_overlay ? createListLoading() : null
this.$axios.post(this.api, this.json ? ({pageForm: JSON.stringify(this.localPageForm)}) : this.localPageForm).then((res) => { return this.$axios.post(
this.api,
this.json ? ({pageForm: JSON.stringify(this.localPageForm)}) : this.localPageForm,
{h5SkeletonLoading: !this.show_loading_overlay}
).then((res) => {
if (res.code === 0) { if (res.code === 0) {
this.tableData = this.tableData.concat(res.data.list) // 接口异常返回 null 行时不渲染该行,避免业务插槽读取行字段产生渲染异常。
const responseList = Array.isArray(res.data.list) ? res.data.list.filter((item) => item) : []
this.tableData = this.tableData.concat(responseList)
this.localPageForm.totalCount = res.data.totalCount this.localPageForm.totalCount = res.data.totalCount
if (this.tableData.length >= this.localPageForm.totalCount) { if (this.tableData.length >= this.localPageForm.totalCount) {
this.tableFinished = true this.tableFinished = true
} }
this.$emit("update:page_form", {...this.localPageForm}) this.$emit("update:page_form", {...this.localPageForm})
} }
}).catch(() => {
// 公共请求拦截器展示错误;保留当前列表,允许用户再次下拉刷新。
}).finally(() => { }).finally(() => {
loading.close() if (loading) loading.close()
console.log(this.tableLoading)
this.tableLoading = false this.tableLoading = false
console.log(this.tableLoading)
this.tableRefreshing = false this.tableRefreshing = false
this.$emit("loading-change", {loading: false, initial: isInitialLoad})
}) })
}, },
doSearch() { doSearch() {
@@ -152,33 +195,36 @@ module.exports = {
<style scoped> <style scoped>
.table-list-container { .table-list-container {
padding: 0; padding: 0;
margin-top: 10px; margin-top: 12px;
} }
.table-list-container .table-list-item { .table-list-container .table-list-item {
background-color: #fff; background-color: #fff;
border-bottom: 1px solid #eaecef; border: 1px solid #eef1f6;
padding: 16px; border-radius: 12px;
box-shadow: 0 7px 20px rgba(55, 85, 126, .08);
padding: 14px;
position: relative; position: relative;
margin-bottom: 10px; margin-bottom: 12px;
overflow: hidden;
} }
.table-list-container .table-list-item:last-child { .table-list-container .table-list-item:last-child {
border-bottom: none;
margin-bottom: 0; margin-bottom: 0;
} }
.table-list-container .table-list-item .item-header { .table-list-container .table-list-item .item-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: center;
margin-bottom: 10px; margin-bottom: 8px;
} }
.table-list-container .table-list-item .item-title { .table-list-container .table-list-item .item-title {
font-size: 16px; font-size: 15px;
font-weight: 500; line-height: 22px;
color: #1f2f3d; font-weight: 600;
color: #263548;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -190,15 +236,29 @@ module.exports = {
width: 100px; width: 100px;
height: 100px; height: 100px;
flex-shrink: 0; flex-shrink: 0;
border-radius: 6px; overflow: hidden;
border-radius: 10px;
} }
.table-list-container .table-list-item .img-container img { .table-list-container .table-list-item .img-container img {
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 6px; object-fit: cover;
border-radius: 10px;
} }
.table-list-container .table-list-item .table-list-content {
display: flex;
column-gap: 10px;
}
.table-list-container .table-list-item .table-list-columns {
display: flex;
width: 100%;
min-width: 0;
flex-wrap: wrap;
column-gap: 12px;
}
.table-list-container .table-list-item .item-meta { .table-list-container .table-list-item .item-meta {
display: flex; display: flex;
@@ -245,48 +305,57 @@ module.exports = {
.table-list-container .table-list-item .item-actions { .table-list-container .table-list-item .item-actions {
display: flex; display: flex;
column-gap: 20px; gap: 8px;
flex-wrap: wrap;
justify-content: end; justify-content: end;
margin-top: 15px; margin-top: 10px;
padding-top: 12px; padding-top: 10px;
border-top: 1px solid #eaecef; border-top: 1px solid #edf1f6;
} }
.table-list-container .table-list-item .action-btn { .table-list-container .table-list-item .action-btn {
display: flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: end; justify-content: center;
font-size: 14px; min-height: 26px;
padding: 4px 10px;
box-sizing: border-box;
border-radius: 14px;
background-color: #eaf4ff;
font-size: 12px;
line-height: 18px;
color: var(--color-primary); color: var(--color-primary);
padding: 4px 0; cursor: pointer;
} }
.table-list-container .table-list-item .action-btn i { .table-list-container .table-list-item .action-btn i {
margin-right: 6px; margin-right: 5px;
font-size: 16px; font-size: 13px;
} }
.table-list-container .table-list-item .action-btn.delete { .table-list-container .table-list-item .action-btn.delete {
color: #ff0000; color: #f04444;
background-color: #fff0f0;
} }
.table-list-container .table-list-item .action-btn.review { .table-list-container .table-list-item .action-btn.review {
color: #67c23a; color: #67c23a;
background-color: #eef9e9;
} }
.table-list-container .empty-state { .empty-state {
text-align: center; text-align: center;
padding: 60px 20px; padding: 60px 20px;
color: #909399; color: #909399;
} }
.table-list-container .empty-state i { .empty-state i {
font-size: 60px; font-size: 60px;
margin-bottom: 16px; margin-bottom: 16px;
color: #dcdee0; color: #dcdee0;
} }
.table-list-container .empty-state p { .empty-state p {
margin-top: 10px; margin-top: 10px;
font-size: 14px; font-size: 14px;
} }
@@ -3,16 +3,16 @@
<div class="file-preview-container" v-if="fileList && fileList.length > 0"> <div class="file-preview-container" v-if="fileList && fileList.length > 0">
<div class="file-item" v-for="item in fileList" :key="item.id"> <div class="file-item" v-for="item in fileList" :key="item.id">
<img <img
v-if="item.suffix === 'jpg' || item.suffix === 'png' || item.suffix === 'jpeg' || item.suffix === 'gif'" v-if="isImageFile(item)"
:src="item.thumbnail" :src="item.thumbnail"
@click.stop="$commonUtil.previewFile(item)" @click.stop="onPreviewFile(item)"
class="file-image" class="file-image"
/> />
<img <img
v-else v-else
:src="$commonUtil.getFileItemShowIcon(item.suffix)" :src="$commonUtil.getFileItemShowIcon(item.suffix)"
@click.stop="$commonUtil.previewFile(item)" @click.stop="onPreviewFile(item)"
class="file-image" class="file-image"
/> />
@@ -57,7 +57,12 @@ module.exports = {
}, },
data() { data() {
return { return {
fileList: [] fileList: [],
imagePreviewInstance: null,
imagePreviewContainer: null,
imagePreviewHistoryUnsubscribe: null,
closingImagePreviewFromHistory: false,
imagePreviewLayerName: "h5-file-image-preview"
} }
}, },
methods: { methods: {
@@ -65,22 +70,10 @@ module.exports = {
let ids = [] let ids = []
if (this.complete_result) { if (this.complete_result) {
if (Array.isArray(val)) { if (Array.isArray(val)) {
ids = val.map((item) => { ids = val.map((item) => this.resolveFileId(item)).filter((id) => id)
if (item.response.data.includes("=")) {
return item?.response?.data.substring(item?.response?.data.lastIndexOf("=") + 1)
} else {
return item?.response?.data
}
})
} else { } else {
try { try {
ids = JSON.parse(val).map((item) => { ids = JSON.parse(val).map((item) => this.resolveFileId(item)).filter((id) => id)
if (item.response.data.includes("=")) {
return item?.response?.data.substring(item?.response?.data.lastIndexOf("=") + 1)
} else {
return item?.response?.data
}
})
} catch (err) { } catch (err) {
this.fileList = [] this.fileList = []
} }
@@ -100,12 +93,109 @@ module.exports = {
this.requestFullFile(ids) this.requestFullFile(ids)
}, },
// 新版上传组件保存 response.data,迁移的历史附件保留原文件主键 id。
resolveFileId(item) {
if (!item) {
return ""
}
const fileValue = item.response && item.response.data ? item.response.data : item.id
if (typeof fileValue !== "string" || !fileValue) {
return ""
}
return fileValue.includes("=") ? fileValue.substring(fileValue.lastIndexOf("=") + 1) : fileValue
},
requestFullFile(ids) { requestFullFile(ids) {
this.$axios.post("/platform/sys/file/previewFileData", {ids: JSON.stringify(ids)}).then((res) => { this.$axios.post("/platform/sys/file/previewFileData", {ids: JSON.stringify(ids)}).then((res) => {
if (res.code === 0) { if (res.code === 0) {
this.fileList = res.data this.$set(this, "fileList", res.data || [])
} }
}) })
},
// 判断附件是否为图片;item 传完整文件对象,返回 boolean 类型。
isImageFile(item) {
const suffix = item && item.suffix ? String(item.suffix).toLowerCase() : ""
return ["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(suffix)
},
/**
* 预览附件。
* item 传 previewFileData 返回的文件对象,需包含 id、suffix、downloadPath;方法无返回值。
* 图片会与当前附件列表中的其他图片组成轮播,非图片继续使用公共文件预览逻辑。
*/
onPreviewFile(item) {
if (!this.isImageFile(item)) {
this.$commonUtil.previewFile(item)
return
}
if (this.imagePreviewInstance) return
const imageFiles = this.fileList.filter((file) => this.isImageFile(file) && file.downloadPath)
const startPosition = imageFiles.findIndex((file) => file.id === item.id)
if (startPosition < 0) return
const previewContainer = document.createElement("div")
previewContainer.style.display = "none"
imageFiles.forEach((file) => {
const image = document.createElement("img")
image.src = file.downloadPath
image.alt = file.name || ""
previewContainer.appendChild(image)
})
document.body.appendChild(previewContainer)
this.$set(this, "closingImagePreviewFromHistory", false)
this.$set(this, "imagePreviewContainer", previewContainer)
this.$set(this, "imagePreviewInstance", new Viewer(previewContainer, {
zIndex: 99999,
title: false,
initialViewIndex: startPosition,
hidden: () => { this.handleImagePreviewHidden() }
}))
this.imagePreviewInstance.show()
window.h5HistoryLayerManager.open(this.imagePreviewLayerName)
},
// Viewer 完全关闭后销毁实例和临时图片容器,并按关闭来源同步浏览器历史。
handleImagePreviewHidden() {
const closedFromHistory = this.closingImagePreviewFromHistory
this.cleanupImagePreview()
if (closedFromHistory) {
this.$set(this, "closingImagePreviewFromHistory", false)
return
}
if (window.h5HistoryLayerManager.stack.includes(this.imagePreviewLayerName)) {
window.h5HistoryLayerManager.close(this.imagePreviewLayerName)
}
},
// 清理 Viewer 实例及其临时 DOM;方法无参数、无返回值。
cleanupImagePreview() {
const previewInstance = this.imagePreviewInstance
const previewContainer = this.imagePreviewContainer
this.$set(this, "imagePreviewInstance", null)
this.$set(this, "imagePreviewContainer", null)
if (previewInstance) previewInstance.destroy()
if (previewContainer && previewContainer.parentNode) {
previewContainer.parentNode.removeChild(previewContainer)
}
},
// 浏览器返回导致图片预览层出栈时,仅关闭预览实例,不再重复回退历史记录。
syncImagePreviewHistory(stack) {
if (!this.imagePreviewInstance || stack.includes(this.imagePreviewLayerName)) return
this.$set(this, "closingImagePreviewFromHistory", true)
this.imagePreviewInstance.hide()
}
},
created() {
this.$set(this, "imagePreviewHistoryUnsubscribe", window.h5HistoryLayerManager.subscribe((stack) => {
this.syncImagePreviewHistory(stack)
}))
},
beforeDestroy() {
if (this.imagePreviewHistoryUnsubscribe) this.imagePreviewHistoryUnsubscribe()
if (this.imagePreviewInstance || this.imagePreviewContainer) {
this.$set(this, "closingImagePreviewFromHistory", true)
this.cleanupImagePreview()
} }
} }
} }
@@ -1,13 +1,28 @@
<template> <template>
<div class="h5-signature"> <div class="h5-signature">
<div v-if="!showSignaturePanel" class="main-panel"> <slot v-if="$slots.default"></slot>
<slot v-if="$slots.default"></slot> <div v-else class="signature-entry">
<template v-else> <!-- 未签名时展示统一空状态和入口,组件放入 van-field 时仍保持完整宽度。 -->
<van-button @click="openSignature" class="open-button" size="middle" plain native-type="button">打开签字板</van-button> <div v-if="!signatureContent" class="signature-entry-panel">
<van-image :src="signatureContent"></van-image> <van-icon class="signature-placeholder-icon" name="edit"></van-icon>
</template> <div class="signature-placeholder-text">请完成本人手写签名</div>
<van-button @click="openSignature" class="signature-open-button" block plain
native-type="button">进入签字板</van-button>
</div>
<!-- 已签名时直接预览结果,并在下方提供统一的重新签名入口。 -->
<div v-else class="signature-entry-panel signature-entry-panel--completed">
<van-image :src="signatureContent" class="signature-preview" fit="contain"></van-image>
<div class="signature-completed-text">
<van-icon name="passed"></van-icon>
<span>已完成本人手写签名</span>
</div>
</div>
<button v-if="signatureContent" @click="openSignature" class="signature-reset-button"
type="button">重新签名</button>
</div> </div>
<van-popup v-model="showSignaturePanel" :style="{ height: '100vh', width: '100vw' }"> <van-popup v-model="showSignaturePanel" :close-on-click-overlay="false"
:style="{ height: '100vh', width: '100vw' }" @close="handleSignaturePopupClose">
<signature @save="save"></signature> <signature @save="save"></signature>
</van-popup> </van-popup>
</div> </div>
@@ -36,14 +51,43 @@ module.exports = {
data() { data() {
return { return {
signatureContent: null, signatureContent: null,
showSignaturePanel: false
showSignaturePanel: false,
historyLayerName: "",
historyLayerUnsubscribe: null
} }
}, },
methods: { methods: {
/**
* 打开签字板。
* 无请求参数;优先向全局历史栈增加当前签字弹层,返回值为空。
*/
openSignature() { openSignature() {
const manager = window.h5HistoryLayerManager
if (manager && manager.open(this.historyLayerName)) return
this.showSignaturePanel = true this.showSignaturePanel = true
}, },
// 保存成功或业务主动关闭时同步回退签字弹层历史,管理器不可用时直接关闭 Popup。
closeSignaturePanel() {
const manager = window.h5HistoryLayerManager
if (manager && manager.close(this.historyLayerName)) return
this.showSignaturePanel = false
},
// Vant 组件自行关闭时仅处理仍位于栈顶的签字层,避免返回同步触发重复 history.back。
handleSignaturePopupClose() {
const manager = window.h5HistoryLayerManager
if (!manager) return
const stack = manager.stack
if (stack[stack.length - 1] === this.historyLayerName) {
manager.close(this.historyLayerName)
}
},
save(signature) { save(signature) {
const file = this.base64ToFile(signature, "signature.png") const file = this.base64ToFile(signature, "signature.png")
const formData = new FormData() const formData = new FormData()
@@ -59,11 +103,20 @@ module.exports = {
}) })
.then((res) => { .then((res) => {
if (res.code === 0) { if (res.code === 0) {
alert("保存成功") // 保存成功后使用项目统一的 Vant 反馈,避免浏览器原生弹框阻塞签字流程。
this.showSignaturePanel = false this.$toast.success({
message: "签名保存成功",
duration: 1500,
forbidClick: true
})
this.$emit("input", res.data) this.$emit("input", res.data)
this.closeSignaturePanel()
} else { } else {
alert("保存失败") // 接口返回失败时保留签字板,方便用户重试。
this.$toast.fail({
message: res.msg || "签名保存失败,请重试",
duration: 2000
})
} }
}) })
}, },
@@ -86,27 +139,114 @@ module.exports = {
return new File([blob], filename, { type: contentType }) return new File([blob], filename, { type: contentType })
} }
}, },
created() {} mounted() {
const manager = window.h5HistoryLayerManager
if (!manager || typeof manager.ensureRegistered !== "function" || typeof manager.subscribe !== "function") return
// 每个组件实例使用唯一弹层名称,同一页面存在多个签字组件时互不影响。
this.historyLayerName = "h5-signature-" + this._uid
manager.ensureRegistered("h5-signature-page-" + window.location.pathname + window.location.search)
this.historyLayerUnsubscribe = manager.subscribe((stack) => {
this.showSignaturePanel = stack.includes(this.historyLayerName)
})
},
beforeDestroy() {
if (typeof this.historyLayerUnsubscribe === "function") {
this.historyLayerUnsubscribe()
}
}
} }
</script> </script>
<style scoped> <style scoped>
.h5-signature { .h5-signature {
width: 100%; width: 100%;
min-width: 0;
box-sizing: border-box;
} }
.main-panel { .signature-entry {
position: relative; width: 100%;
flex-direction: column-reverse;
display: flex; display: flex;
row-gap: 10px; flex-direction: column;
align-items: stretch;
box-sizing: border-box;
} }
.main-panel .van-image { .signature-entry-panel {
width: 100%;
min-height: 150px; min-height: 150px;
padding: 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-sizing: border-box;
border: 1px dashed #dfe6ef;
border-radius: 9px;
background: #fbfcfe;
} }
.main-panel .open-button { .signature-placeholder-icon {
color: var(--color-primary); margin-bottom: 6px;
color: #c7cfda;
font-size: 38px;
}
.signature-placeholder-text {
margin-bottom: 12px;
color: #9aa4b2;
font-size: 13px;
line-height: 20px;
}
.signature-open-button {
width: 100%;
height: 40px;
color: var(--color-primary, #1989fa);
font-size: 15px;
font-weight: 500;
background: linear-gradient(90deg, #f0f7ff, #f5f9ff);
border-color: #d8e9ff;
border-radius: 7px;
}
.signature-entry-panel--completed {
min-height: 150px;
justify-content: space-between;
}
.signature-preview {
width: 100%;
height: 96px;
background: #fff;
border-radius: 6px;
overflow: hidden;
}
.signature-completed-text {
margin-top: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #5f6b7a;
font-size: 13px;
}
.signature-completed-text .van-icon {
margin-right: 5px;
color: #28a745;
font-size: 16px;
}
.signature-reset-button {
align-self: center;
margin-top: 10px;
padding: 3px 12px;
color: var(--color-primary, #1989fa);
font-size: 14px;
line-height: 22px;
background: transparent;
border: 0;
} }
</style> </style>
@@ -57,7 +57,16 @@ module.exports = {
}, },
save() { save() {
if (signature.isEmpty()) { if (signature.isEmpty()) {
alert("请签字后再保存") // H5 页面使用统一的 Vant 提示;未加载 Vant 的独立签字页保留原生提示作为兼容兜底。
if (typeof this.$toast === "function") {
this.$toast({
message: "请先完成手写签名",
icon: "edit",
duration: 2000
})
} else {
alert("请先完成手写签名")
}
return return
} }
// const png = signature.getPNG() // const png = signature.getPNG()

Some files were not shown because too many files have changed in this diff Show More