Compare commits

..
6 Commits
44 changed files with 1268 additions and 534 deletions
@@ -9,7 +9,7 @@ import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.dao.util.Daos;
import org.nutz.dao.util.cri.Static;
import io.v.nutz.sys.services.SysLocalProcessService;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
@@ -29,6 +29,9 @@ public class SysLocalProcessController {
@Inject
private Dao dao;
@Inject
private SysLocalProcessService localProcessService;
/**
* 查询待办
* @param mode 1待处理 2已处理 3我发起的
@@ -39,28 +42,7 @@ public class SysLocalProcessController {
@Ok("json:full")
public Result todoList(Integer mode) {
if (mode == 1 || mode == 2) {
Sql sql = Sqls.create("""
SELECT
t.taskNodeName,
t.createdByUserName,
t.formUrl,
t.formUrlView,
t.createdOn,
t2.processName,
t2.nodeName
FROM
sys_local_process_instance_task t
LEFT JOIN sys_local_process_instance t2 ON t2.processUniqueId = t.processUniqueId
$condition
""");
Cnd cnd = Cnd.NEW();
cnd.and(new Static("JSON_CONTAINS( t.assignments, '\"" + ShiroUtil.getPrincipalProperty("loginname") + "\"')"));
cnd.and("t.status", "=", mode);
cnd.and("t.taskDeleteFlag", "=", 0);
cnd.desc("t2.processInitiationTime");
sql.setCondition(cnd);
List<NutMap> list = (List<NutMap>) Daos.query(dao, sql.toString(), Sqls.callback.maps());
return Result.success(list);
return Result.success(localProcessService.getTaskList(mode));
} else if (mode == 3) {
Sql sql = Sqls.create("""
SELECT
@@ -2,25 +2,19 @@ package io.v.nutz.sys.controllers.platform.sys.club.applyIn;
import cn.hutool.core.util.StrUtil;
import io.v.nutz.base.annontation.ViReturn;
import io.v.nutz.base.model.Audit;
import io.v.nutz.base.service.BaseService;
import io.v.nutz.base.utils.MsgApi;
import io.v.nutz.base.utils.PageUtil;
import io.v.nutz.base.utils.Roles;
import io.v.nutz.sys.constant.club.ClubRegistAuditState;
import io.v.nutz.sys.controllers.platform.sys.handler.ClubUserApplyToDoHandler;
import io.v.nutz.sys.models.Sys_club;
import io.v.nutz.sys.models.Sys_club_user;
import io.v.nutz.sys.services.SysClubService;
import io.v.nutz.sys.services.SysClubUserService;
import io.v.nutz.web.commons.utils.ShiroUtil;
import org.apache.shiro.authz.annotation.Logical;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.apache.shiro.authz.annotation.RequiresRoles;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.entity.Record;
import org.nutz.dao.sql.Sql;
@@ -35,7 +29,6 @@ import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import java.util.Date;
import java.util.List;
/**
@@ -85,9 +78,31 @@ public class ClubApplyAuditController {
@Inject
private SysClubUserService sysClubUserService;
/**
* 无请求参数,查询当前会话用户的协会个人待办;无审核权限时返回空列表。
* ViReturn 返回 code、msg、data,data 为包含 title、name、count、mobileHref 的卡片列表。
*/
@At
@ViReturn
@RequiresPermissions(value = {"sys.club.sh", "sys.club.schoolsh"}, logical = Logical.OR)
@RequiresAuthentication
public Object mobileTodo() {
return sysClubUserService.getMobileReviewTodo();
}
/**
* 无请求参数;data 返回本人实际办理的协会已办卡片,applyId 用于打开详情,其余字段见服务接口。
* 未授权或无已办记录时返回空列表;不接收前端指定审核人。
*/
@At
@ViReturn
@RequiresAuthentication
public Object mobileCompleted() {
return sysClubUserService.getMobileReviewCompleted();
}
@At
@ViReturn
@RequiresPermissions("sys.club.sh")
public Object pageData(@Param(value = "clubid", required = false) String clubid,
@Param(value = "searchName", required = false) String searchName,
@Param(value = "searchKeyword", required = false) String searchKeyword,
@@ -100,6 +115,7 @@ public class ClubApplyAuditController {
@Param(value = "userState", required = false) String userState,
@Param(value = "sex", required = false) String sex,
@Param(value = "unitid", required = false) String unitid) {
// 所属单位取人员资料;独立别名避免与申请表 unitName 重名,并用全小写匹配 Record 返回键。
Sql sql = Sqls.create("""
SELECT
scu.*,
@@ -108,6 +124,7 @@ public class ClubApplyAuditController {
u.sex,
u.userState,
u.unionname,
u.unitname AS applicantunitname,
club.name clubname,
club.code,
u.personType rylx
@@ -139,12 +156,12 @@ public class ClubApplyAuditController {
cnd.andEX("u.sex", "=", sex);
cnd.andEX("u.unitid", "=", unitid);
if (!ShiroUtil.hasAnyRoles(new String[]{"sysadmin", "A06"})) {
if (ShiroUtil.hasAnyRoles("club01, club02, club03, club04")) {
cnd.and(new Static("""
scu.clubid IN ( SELECT stid FROM sys_user_role WHERE roleid in ('%s','%s','%s','%s') AND userid = '%s' AND stid IS NOT NULL )
""".formatted(Roles.club01, Roles.club02, Roles.club03, Roles.club04, ShiroUtil.getPrincipalProperty("id"))));
}
// 超管取得全部协会;普通审核人无所属协会时返回空列表,不能省略范围条件。
List<String> reviewClubIds = sysClubUserService.getReviewClubIds();
if (reviewClubIds.isEmpty()) {
cnd.and(new Static("1 = 0"));
} else {
cnd.and("scu.clubid", "in", reviewClubIds);
}
if (Strings.isNotBlank(searchName) && Strings.isNotBlank(searchKeyword)) {
@@ -160,45 +177,28 @@ public class ClubApplyAuditController {
}
/**
* id 为入会申请主键数组,flag 为是否通过,auditOpinion 为审核意见,auditSign 为签字内容,
* isjf 为可选缴费标记;返回 null,由 ViReturn 包装为包含 code、msg、data 的 JSON 结果。
*/
@At
@ViReturn
@RequiresAuthentication
@RequiresPermissions("sys.club.sh")
@RequiresRoles(value = {"sysadmin", "club01", "club03"}, logical = Logical.OR)
@Aop(TransAop.READ_COMMITTED)
public Object check(String[] id, Boolean flag, String auditOpinion,
String auditSign,
@Param(value = "isjf", required = false) Boolean isjf) {
Audit audit = new Audit();
audit.setAuditPass(flag);
audit.setAuditor(ShiroUtil.getPrincipalProperty("id").toString());
audit.setUsername(ShiroUtil.getPrincipalProperty("username").toString());
audit.setLoginname(ShiroUtil.getPrincipalProperty("loginname").toString());
audit.setAuditOpinion(auditOpinion);
audit.setAuditTime(new Date());
audit.setAuditSign(auditSign);
Audit insert = sysClubService.dao().insert(audit);
Cnd cnd = Cnd.NEW();
cnd.where().andInStrArray("id", id);
sysClubUserService.update(Chain.make("status", flag ? 5 : 2)
.add("isjf", isjf).add("clubAuditId", insert.getId()), cnd);
List<Sys_club_user> clubUsers = sysClubUserService.dao().query(Sys_club_user.class, Cnd.where("id", "in", id));
for (Sys_club_user clubUser : clubUsers) {
Sys_club club = sysClubService.fetch(clubUser.getClubid());
Sql sql = Sqls.fetchRecord("select username,loginname from sys_user where id = @id").setParam("id", clubUser.getUserid());
sysClubUserService.dao().execute(sql);
Record userRecord = (Record) sql.getResult();
if (flag) {
String content = "您申请加入的" + club.getName() + "已经审批通过。";
System.out.println(userRecord.getString("username"));
System.out.println(userRecord.getString("loginname"));
System.out.println(content);
} else {
String content = "您申请加入的" + club.getName() + "被审批拒绝了,拒绝理由:" + auditOpinion;
System.out.println(userRecord.getString("username"));
System.out.println(content);
if (id == null || id.length == 0 || flag == null) {
throw new IllegalArgumentException("请选择申请并提供审核结果");
}
for (String applyId : id) {
if (Strings.isBlank(applyId)) {
throw new IllegalArgumentException("申请编号不能为空");
}
}
sysClubUserService.reviewApplications(id, flag, auditOpinion, auditSign, isjf);
return null;
}
@@ -206,7 +206,7 @@ public class ClubApplyAuditController {
//获取当前登录用户负责的社团
@At
@ViReturn
@RequiresAuthentication
@RequiresPermissions("sys.club.sh")
public Object getMyClub(@Param(value = "searchKeyword", required = false) String searchKeyword,
@Param(value = "pageNumber", required = false) int pageNumber,
@Param(value = "pageSize", required = false) int pageSize,
@@ -235,12 +235,12 @@ public class ClubApplyAuditController {
cnd.and("state", "=", ClubRegistAuditState.SCHOOL_PASS);
cnd.and("isjs", "=", false);
if (!ShiroUtil.hasAnyRoles(new String[]{"sysadmin", "A06"})) {
if (ShiroUtil.hasAnyRoles("club01, club02, club03, club04")) {
cnd.and(new Static("""
club.id IN ( SELECT stid FROM sys_user_role WHERE roleid in ('%s','%s','%s','%s') AND userid = '%s' AND stid IS NOT NULL )
""".formatted(Roles.club01, Roles.club02, Roles.club03, Roles.club04, ShiroUtil.getPrincipalProperty("id"))));
}
// 超管取得全部协会;普通审核人无所属协会时返回空列表,不能省略范围条件。
List<String> reviewClubIds = sysClubUserService.getReviewClubIds();
if (reviewClubIds.isEmpty()) {
cnd.and(new Static("1 = 0"));
} else {
cnd.and("club.id", "in", reviewClubIds);
}
if (StrUtil.isNotBlank(clubType)) {
cnd.and("club.type", "=", clubType);
@@ -257,7 +257,7 @@ public class ClubApplyAuditController {
//获取社团已审或者未审人员
@At
@ViReturn
@RequiresAuthentication
@RequiresPermissions("sys.club.sh")
public Object getClubUser(@Param(value = "id", required = false) String id,
@Param(value = "auditType", required = false) String auditType,
@Param(value = "type", required = false) int type) {
@@ -310,12 +310,12 @@ public class ClubApplyAuditController {
}
cnd.andEX("scu.clubid", "=", id);
if (!ShiroUtil.hasAnyRoles(new String[]{"sysadmin", "A06"})) {
if (ShiroUtil.hasAnyRoles("club01, club02, club03, club04")) {
cnd.and(new Static("""
club.id IN ( SELECT stid FROM sys_user_role WHERE roleid in ('%s','%s','%s','%s') AND userid = '%s' AND stid IS NOT NULL )
""".formatted(Roles.club01, Roles.club02, Roles.club03, Roles.club04, ShiroUtil.getPrincipalProperty("id"))));
}
// 超管取得全部协会;普通审核人无所属协会时返回空列表,不能省略范围条件。
List<String> reviewClubIds = sysClubUserService.getReviewClubIds();
if (reviewClubIds.isEmpty()) {
cnd.and(new Static("1 = 0"));
} else {
cnd.and("club.id", "in", reviewClubIds);
}
sql.setCondition(cnd);
List<Record> list = sysClubService.list(sql);
@@ -171,56 +171,7 @@ public class ClubApplyInController {
|| (sys_club_user.getOtherThing() != null && sys_club_user.getOtherThing().length() > 100)) {
throw new IllegalArgumentException("联系电话最多20字,紧急联系人最多50字,特别说明最多100字");
}
sysClubUserService.clear(Cnd.where("clubid", "=", sys_club_user.getClubid()).and("userid", "=", ShiroUtil.getPrincipalProperty("id")));
String dateTime = DateUtil.getDateTime();
sys_club_user.setUserid(ShiroUtil.getPrincipalProperty("id").toString());
sys_club_user.setJoin_time(dateTime);
sys_club_user.setSf(2);
sys_club_user.setStatus(1);
sys_club_user.setIsjf(false);
sys_club_user.setIsNormal(true);
sys_club_user.setGiveMoney(true);
Sys_club_user clubUser = sysClubUserService.insert(sys_club_user);
//插入后,发起待办流程
ClubUserApplyToDoHandler.START_PROCESS.exec(clubUser);
//发任务给协会会长
ClubUserApplyToDoHandler.CREATE_CLUB_TASK.exec(clubUser);
//完成申请人的任务(申请拒绝的情况)
ClubUserApplyToDoHandler.COMPLETE_APPLY_TASK.exec(clubUser);
//根据社团id查询社团负责人,发短信
Sql sql = Sqls.create("""
SELECT
u.loginname as loginName,
u.username as userName,
u.unitid as unitId,
u.unitname as unitName,
u.mobile,
c.`name`
FROM
sys_user_role r
LEFT JOIN
`user` u ON r.userId = u.id
LEFT JOIN
sys_club c ON c.id = r.stid
$condition
""");
Cnd cnd = Cnd.NEW();
cnd.and("stid", "=", sys_club_user.getClubid());
cnd.and("roleId", "in", Lang.array(Roles.club01, Roles.club02, Roles.club03, Roles.club04));
sql.setCondition(cnd);
List<NutMap> list = baseService.listMap(sql);
String platformUsername = ShiroUtil.getPlatformUsername();
list.forEach(item -> {
String content = "%s老师您好,%s老师于%s申请加入%s,请登陆智慧工会进行审核!".formatted(item.getString("userName")
, platformUsername, dateTime, item.getString("name"));
//msgApi.sendWxMsg(person, content);
});
sysClubUserService.submitApplication(sys_club_user);
return null;
}
@@ -84,12 +84,12 @@ public enum ClubUserApplyToDoHandler {
},
/**
* 创建协会会长任务
* 为申请所属协会的会长、秘书长创建审核任务
*/
CREATE_CLUB_TASK() {
@Override
public void exec(Sys_club_user clubUser) {
//查询协会审核人
// 角色关联的 stid 为所属协会主键,按本次申请 clubid 筛选,避免跨协会派发。
Sql sqlStr = Sqls.create("""
SELECT
u.loginname
@@ -100,7 +100,9 @@ public enum ClubUserApplyToDoHandler {
GROUP BY
sur.userId
""");
sqlStr.setCondition(Cnd.NEW().and("sur.roleId", "in", Lang.array(Roles.club01, Roles.club02, Roles.club03, Roles.club04)));
sqlStr.setCondition(Cnd.NEW()
.and("sur.stid", "=", clubUser.getClubid())
.and("sur.roleId", "in", Lang.array(Roles.club01, Roles.club03)));
Sql sql = Sqls.queryString(sqlStr.toString());
dao.execute(sql);
List<String> loginNames = sql.getList(String.class);
@@ -3,6 +3,47 @@ package io.v.nutz.sys.services;
import cn.wizzer.framework.base.service.BaseService;
import io.v.nutz.sys.models.Sys_club_user;
import java.util.List;
import org.nutz.lang.util.NutMap;
public interface SysClubUserService extends BaseService<Sys_club_user> {
/**
* PC、H5 共用的入会提交入口:保存申请、创建审核待办并通知所属协会的会长和秘书长。
* 无返回值;保存或待办失败时抛出异常回滚,消息发送异常仅记录日志。
* @param application 已通过接口必填及长度校验的入会资料,clubid 为申请协会主键;
* 申请人、申请时间和待审核状态由服务端确定,不采信客户端传值
*/
void submitApplication(Sys_club_user application);
/**
* 按当前登录用户确定可查看及审核的协会,不接收客户端用户 ID 或代审标志。
* @return 协会主键列表:超管返回全部协会,其他人仅返回本人担任会长或秘书长的协会;无资格时为空
*/
List<String> getReviewClubIds();
/**
* 查询当前登录人的本年度协会审核待办,不接收客户端用户或协会参数。
* @return 首页卡片列表;无待办返回空列表,卡片包含 title、name、count 和 mobileHref
* 仅统计已分配给本人且仍具有所属协会会长或秘书长资格的有效待办,超管代审不额外派单
*/
List<NutMap> getMobileReviewTodo();
/**
* 查询当前登录人本年度实际完成的协会审核,不接收前端审核人参数。
* @return 已办列表,包含 businessType、applyId、moduleName、auditState、auditTime、mobileUrl;无记录为空列表
*/
List<NutMap> getMobileReviewCompleted();
/**
* 保存协会审核结果,并完成每条申请的审核待办及流程。
* 无返回值;保存或关闭流程失败时抛出异常并回滚事务。
* @param ids 入会申请主键数组,支持单条及批量审核
* @param pass true 通过(状态 5),false 拒绝(状态 2)
* @param auditOpinion 审核意见
* @param auditSign 审核签字内容
* @param isjf 可选缴费标记,沿用现有保存规则
*/
void reviewApplications(String[] ids, Boolean pass, String auditOpinion, String auditSign, Boolean isjf);
}
@@ -2,9 +2,19 @@ package io.v.nutz.sys.services;
import javax.validation.constraints.NotNull;
import java.util.List;
import org.nutz.lang.util.NutMap;
public interface SysLocalProcessService {
/**
* 查询当前登录人的任务列表,用户身份从会话获取,不由客户端指定。
* @param mode 1 待处理,2 已处理;其他值返回空列表
* @return taskNodeName、createdByUserName、formUrl、formUrlView、createdOn、processName、nodeName 组成的列表
* 协会待办须对应有效待审申请;协会已办仅归实际处理人,其他业务沿用原接收人规则
*/
List<NutMap> getTaskList(Integer mode);
/**
* 开始一个实例流程
*
@@ -3,14 +3,268 @@ package io.v.nutz.sys.services.impl;
import cn.wizzer.framework.base.service.BaseServiceImpl;
import io.v.nutz.sys.models.Sys_club_user;
import io.v.nutz.sys.services.SysClubUserService;
import io.v.nutz.base.model.Audit;
import io.v.nutz.base.utils.DateUtil;
import io.v.nutz.base.utils.MsgApi;
import io.v.nutz.base.utils.Roles;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authz.UnauthorizedException;
import org.nutz.lang.Strings;
import org.nutz.lang.util.NutMap;
import java.util.Arrays;
import java.util.HashSet;
import io.v.nutz.sys.controllers.platform.sys.handler.ClubUserApplyToDoHandler;
import io.v.nutz.sys.models.Sys_club;
import io.v.nutz.web.commons.utils.ShiroUtil;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.entity.Record;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import java.util.Date;
import java.util.List;
@IocBean(args = {"refer:dao"})
@Slf4j
public class SysClubUserServiceImpl extends BaseServiceImpl<Sys_club_user> implements SysClubUserService {
@Inject
private MsgApi msgApi;
public SysClubUserServiceImpl(Dao dao) {
super(dao);
}
/**
* 沿用双端原有的申请替换和待办顺序;申请及待办保存完成后发送审核提醒。
* 参数、返回值和异常约定见接口,当前申请人以登录会话为准。
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public void submitApplication(Sys_club_user application) {
String userId = ShiroUtil.getPrincipalProperty("id").toString();
clear(Cnd.where("clubid", "=", application.getClubid()).and("userid", "=", userId));
application.setUserid(userId);
application.setJoin_time(DateUtil.getDateTime());
application.setSf(2);
application.setStatus(1);
application.setIsjf(false);
application.setIsNormal(true);
application.setGiveMoney(true);
Sys_club_user clubUser = insert(application);
// 新建本次流程及会长、秘书长待办,再完成申请人被退回时的提交任务。
ClubUserApplyToDoHandler.START_PROCESS.exec(clubUser);
ClubUserApplyToDoHandler.CREATE_CLUB_TASK.exec(clubUser);
ClubUserApplyToDoHandler.COMPLETE_APPLY_TASK.exec(clubUser);
notifyApplicationReviewers(clubUser);
}
/**
* 按已保存申请的 clubid 查询审核角色,复用日常慰问的企业微信消息卡片发送方式。
* 无返回值;缺少接收人工号、协会资料或通知异常时记录日志,不中断申请提交。
*/
private void notifyApplicationReviewers(Sys_club_user application) {
try {
// stid 限定申请所属协会;按工号去重,兼任会长、秘书长也只接收一条消息。
Sql sql = Sqls.queryString("""
SELECT DISTINCT TRIM(u.loginname)
FROM sys_user_role sur
INNER JOIN `user` u ON u.id = sur.userid
WHERE sur.stid = @clubId
AND sur.roleId IN (@chairman, @secretary)
AND u.loginname IS NOT NULL AND TRIM(u.loginname) <> ''
""");
sql.setParam("clubId", application.getClubid());
sql.setParam("chairman", Roles.club01);
sql.setParam("secretary", Roles.club03);
dao().execute(sql);
List<String> loginNames = sql.getList(String.class);
if (loginNames.isEmpty()) {
log.warn("入会申请审核提醒未发送:未配置会长、秘书长或有效工号,申请ID={},协会ID={}",
application.getId(), application.getClubid());
return;
}
Sys_club club = dao().fetch(Sys_club.class, application.getClubid());
if (club == null) {
log.warn("入会申请审核提醒未发送:协会不存在,申请ID={},协会ID={}",
application.getId(), application.getClubid());
return;
}
String content = "您好,%s老师于%s申请加入【%s】,请您前往门户网站或点击此消息链接登录智慧工会平台进行审核。"
.formatted(ShiroUtil.getPlatformUsername(), application.getJoin_time(), club.getName());
// 统一使用 H5 审核入口,消息开关、开发环境禁发及频率限制由 MsgApi 控制。
msgApi.sendWxMsg("智慧工会【协会入会】", "https://zhghydd.cpu.edu.cn/platform/sys/club/sh/mobile",
"textcard", content, loginNames, "");
} catch (Exception e) {
// 通知渠道故障或频率限制不应使申请失败,保留申请 ID 便于排查未送达原因。
log.error("入会申请审核提醒发送异常,申请ID=" + application.getId(), e);
}
}
/**
* 首页个人待办必须同时满足接收人、当前任职、申请及流程状态,不复用超管全协会代审范围。
* 返回首页卡片结构见接口;按申请去重,防止重复任务或兼任角色导致数量翻倍。
*/
@Override
public List<NutMap> getMobileReviewTodo() {
if (!ShiroUtil.hasPermission("sys.club.sh")
|| !ShiroUtil.hasAnyRoles(new String[]{"club01", "club03"})) {
return List.of();
}
Sql sql = Sqls.fetchInt("""
SELECT COUNT(DISTINCT cu.id)
FROM sys_club_user cu
INNER JOIN sys_local_process_instance_task t
ON t.processUniqueId = CONCAT('sys_club_user@', cu.id)
INNER JOIN sys_local_process_instance p ON p.processUniqueId = t.processUniqueId
WHERE cu.status = 1 AND cu.sf = 2 AND cu.isNormal = 1
AND t.taskUniqueId = 'clubAuditId' AND t.status = 1
AND t.taskDeleteFlag = 0 AND t.delFlag = 0
AND p.processInstanceStatus = 1 AND p.processDeleteFlag = 0 AND p.delFlag = 0
AND t.createdOn >= MAKEDATE(YEAR(CURRENT_DATE), 1)
AND t.createdOn < MAKEDATE(YEAR(CURRENT_DATE) + 1, 1)
AND JSON_CONTAINS(t.assignments, JSON_QUOTE(@loginName))
AND EXISTS (
SELECT 1 FROM sys_user_role sur
WHERE sur.userId = @userId AND sur.stid = cu.clubid
AND sur.roleId IN (@chairman, @secretary)
)
""");
// 登录用户及工号均取自会话;参数绑定避免工号中的特殊字符影响 JSON 或 SQL。
sql.setParam("loginName", ShiroUtil.getPrincipalProperty("loginname"));
sql.setParam("userId", ShiroUtil.getPlatformUid());
sql.setParam("chairman", Roles.club01);
sql.setParam("secretary", Roles.club03);
dao().execute(sql);
int count = sql.getInt();
if (count == 0) return List.of();
return List.of(NutMap.NEW().setv("title", "协会入会申请")
.setv("name", "协会入会审核").setv("count", count)
.setv("mobileHref", "/platform/sys/club/sh/mobile"));
}
/**
* 已办以审核记录中的实际审核人为准,超管代审归超管本人,不归任务的其他接收人。
* 按审核完成年份筛选,因此往年发起、今年办理的申请仍会显示;使用 maps 保留页面字段大小写。
*/
@Override
public List<NutMap> getMobileReviewCompleted() {
if (!ShiroUtil.hasPermission("sys.club.sh")) return List.of();
Sql sql = Sqls.create("""
SELECT 'clubApply' AS businessType, cu.id AS applyId,
CONCAT('协会入会申请 · ', COALESCE(c.name, ''), ' · ', COALESCE(u.username, '')) AS moduleName,
CASE WHEN a.auditPass = 1 THEN '审核通过' ELSE '审核拒绝' END AS auditState,
DATE_FORMAT(a.auditTime, '%Y-%m-%d %H:%i:%s') AS auditTime,
'/platform/sys/club/sh/mobile' AS mobileUrl
FROM sys_club_user cu
INNER JOIN audit a ON a.id = cu.clubAuditId
LEFT JOIN sys_club c ON c.id = cu.clubid
LEFT JOIN sys_user u ON u.id = cu.userid
WHERE cu.sf = 2 AND cu.isNormal = 1 AND cu.status IN (2, 5)
AND a.auditor = @userId
AND a.auditTime >= MAKEDATE(YEAR(CURRENT_DATE), 1)
AND a.auditTime < MAKEDATE(YEAR(CURRENT_DATE) + 1, 1)
ORDER BY a.auditTime DESC, cu.id
""");
sql.setParam("userId", ShiroUtil.getPlatformUid());
sql.setCallback(Sqls.callback.maps());
dao().execute(sql);
return sql.getList(NutMap.class);
}
/** 查询当前用户可查看和审核的协会;超管允许全协会代审,其他人按实际任职限定。 */
@Override
public List<String> getReviewClubIds() {
// 超管使用服务端角色判定,不接收前端传入的代审标志;返回实际协会 ID 供查询与审核共用。
if (ShiroUtil.hasAnyRoles(new String[]{"sysadmin"})) {
Sql allClubs = Sqls.queryString("SELECT id FROM sys_club");
dao().execute(allClubs);
return allClubs.getList(String.class);
}
Sql sql = Sqls.queryString("SELECT DISTINCT stid FROM sys_user_role "
+ "WHERE userId = @userId AND roleId IN (@chairman, @secretary) "
+ "AND stid IS NOT NULL AND stid <> ''");
sql.setParam("userId", ShiroUtil.getPlatformUid());
sql.setParam("chairman", Roles.club01);
sql.setParam("secretary", Roles.club03);
dao().execute(sql);
return sql.getList(String.class);
}
/**
* 审核记录、申请状态、待办及流程在同一事务内保存,防止出现已审核但待办未关闭。
* 参数及返回约定见接口;实际审核人从当前登录会话取得。
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public void reviewApplications(String[] id, Boolean flag, String auditOpinion, String auditSign, Boolean isjf) {
// 所有权限及所属协会检查必须在任何写入前完成,批量混入越权申请时整批拒绝。
SecurityUtils.getSubject().checkPermission("sys.club.sh");
if (!ShiroUtil.hasAnyRoles(new String[]{"sysadmin", "club01", "club03"})) {
throw new UnauthorizedException("仅超管或申请所属协会的会长、秘书长可以审核");
}
if (id == null || id.length == 0 || flag == null
|| Arrays.stream(id).anyMatch(Strings::isBlank)) {
throw new IllegalArgumentException("请选择申请并提供审核结果");
}
// auditSign 只接受上传接口返回的 PNG 文件路径,禁止将 Base64 或任意地址写入审核记录。
if (auditSign == null || !auditSign.matches("^/signature/[0-9]{4}/[0-9]{2}/[0-9]{2}/[A-Za-z0-9_-]+[.]png$")) {
throw new IllegalArgumentException("请完成签字图片上传后再审核");
}
List<Sys_club_user> applications = dao().query(Sys_club_user.class, Cnd.where("id", "in", id));
if (applications.size() != new HashSet<>(Arrays.asList(id)).size()) {
throw new IllegalArgumentException("申请记录不存在,请刷新后重试");
}
List<String> reviewClubIds = getReviewClubIds();
for (Sys_club_user application : applications) {
if (!reviewClubIds.contains(application.getClubid())) {
throw new UnauthorizedException("仅超管或申请所属协会的会长、秘书长可以审核");
}
}
Audit audit = new Audit();
audit.setAuditPass(flag);
audit.setAuditor(ShiroUtil.getPrincipalProperty("id").toString());
audit.setUsername(ShiroUtil.getPrincipalProperty("username").toString());
audit.setLoginname(ShiroUtil.getPrincipalProperty("loginname").toString());
audit.setAuditOpinion(auditOpinion);
audit.setAuditTime(new Date());
audit.setAuditSign(auditSign);
Audit insert = dao().insert(audit);
Cnd cnd = Cnd.NEW();
cnd.where().andInStrArray("id", id);
update(Chain.make("status", flag ? 5 : 2)
.add("isjf", isjf).add("clubAuditId", insert.getId()), cnd);
List<Sys_club_user> clubUsers = dao().query(Sys_club_user.class, Cnd.where("id", "in", id));
for (Sys_club_user clubUser : clubUsers) {
// 先记录实际审核人和意见,再结束本次流程;通过、拒绝均关闭本次待办。
ClubUserApplyToDoHandler.COMPLETE_CLUB_TASK.exec(clubUser);
ClubUserApplyToDoHandler.COMPLETE_PROCESS.exec(clubUser);
Sys_club club = dao().fetch(Sys_club.class, clubUser.getClubid());
Sql sql = Sqls.fetchRecord("select username,loginname from sys_user where id = @id").setParam("id", clubUser.getUserid());
dao().execute(sql);
Record userRecord = (Record) sql.getResult();
if (flag) {
String content = "您申请加入的" + club.getName() + "已经审批通过。";
System.out.println(userRecord.getString("username"));
System.out.println(userRecord.getString("loginname"));
System.out.println(content);
} else {
String content = "您申请加入的" + club.getName() + "被审批拒绝了,拒绝理由:" + auditOpinion;
System.out.println(userRecord.getString("username"));
System.out.println(content);
}
}
}
}
@@ -5,6 +5,9 @@ import io.v.nutz.sys.models.Sys_local_process_instance;
import io.v.nutz.sys.models.Sys_local_process_instance_task;
import io.v.nutz.sys.services.SysLocalProcessService;
import io.v.nutz.web.commons.base.Globals;
import io.v.nutz.web.commons.utils.ShiroUtil;
import io.v.nutz.base.utils.Roles;
import org.nutz.lang.util.NutMap;
import io.v.nutz.zhgh.needhandlebycpu.service.HandleCpuCommonService;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
@@ -34,6 +37,57 @@ public class SysLocalProcessServiceImpl implements SysLocalProcessService {
@Inject
private HandleCpuCommonService handleCpuCommonService;
/**
* 只对协会入会任务补充业务有效性过滤,其他模块保持原来的接收人及任务状态规则。
* 协会已办按 actualOwnerId 归属,避免把其他接收人或超管代审算成本人办理。
*/
@Override
public List<NutMap> getTaskList(Integer mode) {
if (!Integer.valueOf(1).equals(mode) && !Integer.valueOf(2).equals(mode)) return List.of();
Sql sql = Sqls.create("""
SELECT t.taskNodeName, t.createdByUserName, t.formUrl, t.formUrlView, t.createdOn,
t2.processName, t2.nodeName
FROM sys_local_process_instance_task t
LEFT JOIN sys_local_process_instance t2 ON t2.processUniqueId = t.processUniqueId
WHERE t.status = @mode AND t.taskDeleteFlag = 0
AND (
(NOT (t.taskUniqueId = 'clubAuditId' AND LEFT(t.processUniqueId, 14) = 'sys_club_user@')
AND JSON_CONTAINS(t.assignments, JSON_QUOTE(@loginName)))
OR
(t.taskUniqueId = 'clubAuditId' AND LEFT(t.processUniqueId, 14) = 'sys_club_user@'
AND @clubPermission = 1 AND t.delFlag = 0
AND t2.processDeleteFlag = 0 AND t2.delFlag = 0
AND (
(@mode = 2 AND t.actualOwnerId = @userId)
OR
(@mode = 1 AND t2.processInstanceStatus = 1
AND JSON_CONTAINS(t.assignments, JSON_QUOTE(@loginName))
AND EXISTS (
SELECT 1 FROM sys_club_user cu
WHERE t.processUniqueId = CONCAT('sys_club_user@', cu.id)
AND cu.status = 1 AND cu.sf = 2 AND cu.isNormal = 1
AND EXISTS (
SELECT 1 FROM sys_user_role sur
WHERE sur.userId = @userId AND sur.stid = cu.clubid
AND sur.roleId IN (@chairman, @secretary)
)
))
))
)
ORDER BY t2.processInitiationTime DESC
""");
// 工号通过 JSON_QUOTE 和参数绑定处理,避免特殊字符破坏接收人匹配。
sql.setParam("mode", mode);
sql.setParam("loginName", ShiroUtil.getPrincipalProperty("loginname"));
sql.setParam("userId", ShiroUtil.getPlatformUid());
sql.setParam("clubPermission", ShiroUtil.hasPermission("sys.club.sh") ? 1 : 0);
sql.setParam("chairman", Roles.club01);
sql.setParam("secretary", Roles.club03);
sql.setCallback(Sqls.callback.maps());
dao.execute(sql);
return sql.getList(NutMap.class);
}
@Override
public void startProcess(String processName, String processUniqueId, String nodeName, String processInitiatorId, String pcUrl, String mobileUrl) {
int count = dao.count(Sys_local_process_instance.class, Cnd.where("processUniqueId", "=", processUniqueId));
@@ -2,16 +2,24 @@ package io.v.nutz.web.commons.controller;
import cn.hutool.core.codec.Base64;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.IoUtil;
import cn.wizzer.framework.base.Result;
import com.aliyun.core.utils.Base64Util;
import io.v.nutz.sys.models.Sys_file;
import io.v.nutz.web.commons.utils.ShiroUtil;
import io.v.nutz.zhgh.zgfw.model.UserSign;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.boot.starter.ftp.FtpService;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.integration.jedis.RedisService;
import org.nutz.ioc.Ioc;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Strings;
import org.nutz.lang.random.R;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
@@ -19,6 +27,7 @@ import org.nutz.mvc.annotation.Ok;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.List;
/**
* TODO
@@ -30,15 +39,23 @@ import java.io.InputStream;
@At("/signature")
public class SignatureController {
@Inject
private Dao dao;
@Inject
private RedisService redisService;
// 服务由本类工厂注册,按需获取避免控制器与工厂产物形成循环注入。
@Inject("refer:$ioc")
private Ioc ioc;
@Inject
private FtpService ftpService;
/** 当前 Nutz 不扫描内部类,通过同文件的工厂方法注册个人签名服务及其依赖。 */
@IocBean(name = "personalSignatureService")
public PersonalSignatureServiceImpl createPersonalSignatureService(@Inject("dao") Dao dao,
@Inject("ftpService") FtpService ftp, @Inject("redisService") RedisService redis) {
return new PersonalSignatureServiceImpl(dao, ftp, redis);
}
@At("")
@Ok("re")
@RequiresAuthentication
@@ -47,48 +64,21 @@ public class SignatureController {
}
/**
* 移动端提交签名数据存到redis里
*
* @param prefix 前缀
* @param id 唯一id
* @param base64
* @return
* prefix/id 为二维码对应的轮询标识;base64 兼容手写图片数据及当前用户已保存的文件路径。
* is_value_base64 默认 true,false 时返回文件路径给 PC;返回 {code,msg,data},data 为已保存签名。
*/
@At
@Ok("json")
@RequiresAuthentication
@Aop(TransAop.READ_COMMITTED)
public Object doSub(String prefix, String id, String base64, Boolean is_value_base64) {
if (is_value_base64) {
redisService.setex(prefix + ":" + id, 60 * 10, base64);
} else {
String today = DateUtil.today();
String[] split = today.split("-");
String todayPath = "/signature/" + split[0] + "/" + split[1] + "/" + split[2];
String fileName = R.UU32() + ".png";
String fullPath = todayPath + "/" + fileName;
String replaceData = base64.replaceAll("data:image/png;base64,", "");
try (InputStream is = new ByteArrayInputStream(Base64.decode(replaceData))) {
boolean uploadRes = ftpService.upload(todayPath, fileName, is);
if (uploadRes) {
//上传成功记录到文件表
Sys_file sysFile = new Sys_file();
sysFile.setFilename(fileName);
sysFile.setFilepath(fullPath);
sysFile.setSource(null);
dao.insert(sysFile);
redisService.setex(prefix + ":" + id, 60 * 10, fullPath);
} else {
throw new RuntimeException("上传文件失败");
}
} catch (Exception e) {
return Result.error("签字数据上传失败");
}
if (Strings.isBlank(prefix) || Strings.isBlank(id) || Strings.isBlank(base64)) {
return Result.error("缺少签字参数,请重新扫码");
}
return Result.success();
String sign = ioc.get(PersonalSignatureService.class, "personalSignatureService")
.submitScan(ShiroUtil.getPrincipalProperty("id").toString(),
prefix, id, base64, !Boolean.FALSE.equals(is_value_base64));
return Result.success().addData(sign);
}
@@ -108,40 +98,15 @@ public class SignatureController {
/**
* 签字base64转图片链接
*
* @param data
* @return
* data 为 data:image/...;base64,... 图片字符串;返回 {code,msg,data},data 是上传后的文件路径。
*/
@At
@Ok("json")
@RequiresAuthentication
@Aop(TransAop.READ_COMMITTED)
public Result uploadSignData(String data) {
String today = DateUtil.today();
String[] split = today.split("-");
String todayPath = "/signature/" + split[0] + "/" + split[1] + "/" + split[2];
String fileName = R.UU32() + ".png";
String fullPath = todayPath + "/" + fileName;
String replaceData = data.replaceAll("data:image/png;base64,", "");
try (InputStream is = new ByteArrayInputStream(Base64.decode(replaceData))) {
boolean uploadRes = ftpService.upload(todayPath, fileName, is);
if (uploadRes) {
//上传成功记录到文件表
Sys_file sysFile = new Sys_file();
sysFile.setFilename(fileName);
sysFile.setFilepath(fullPath);
sysFile.setSource(null);
dao.insert(sysFile);
} else {
throw new RuntimeException("上传文件失败");
}
} catch (Exception e) {
return Result.error("签字数据上传失败");
}
return Result.success().addData(fullPath);
if (Strings.isBlank(data)) return Result.error("请先签字");
return Result.success().addData(ioc.get(PersonalSignatureService.class, "personalSignatureService").uploadImage(data));
}
@@ -158,4 +123,128 @@ public class SignatureController {
}
}
/** 个人签名服务与控制器同文件定义,查询、上传和按类型保存由独立 IoC 服务承接。 */
public interface PersonalSignatureService {
/** userId 为登录用户ID;base64 为签名类型;返回同类型签名原值,未找到返回 null。 */
String findPersonal(String userId, boolean base64);
/** sign 为手写 Base64 或本人已有文件路径;按 base64 类型保存,返回实际保存值。 */
String savePersonal(String userId, String sign, boolean base64);
/** data 为带图片类型头的 Base64;上传并记录文件信息,返回文件路径。 */
String uploadImage(String data);
/** prefix/id 对应 PC 轮询;保存本人签名后发布结果,返回与 base64 参数一致的数据格式。 */
String submitScan(String userId, String prefix, String id, String sign, boolean base64);
}
public static class PersonalSignatureServiceImpl implements PersonalSignatureService {
private Dao dao;
private FtpService ftpService;
private RedisService redisService;
public PersonalSignatureServiceImpl(Dao dao, FtpService ftpService, RedisService redisService) {
this.dao = dao;
this.ftpService = ftpService;
this.redisService = redisService;
}
/** 只把有明确图片数据头的内容归为 Base64,空值和异常内容不冒充文件签名。 */
private boolean isBase64(String sign) {
return sign != null && sign.matches("^data:image/(png|jpeg|jpg|gif|webp);base64,[A-Za-z0-9+/]+={0,2}$");
}
/** 文件模式使用站内存储路径;排除 URL、路径穿越和图片数据字符串。 */
private boolean isFile(String sign) {
return sign != null && sign.startsWith("/") && !sign.startsWith("//")
&& !sign.contains("..") && !sign.contains("?") && !sign.contains("#")
&& !sign.contains("\\") && !sign.contains("\n") && !sign.contains("\r");
}
/** 同类型历史多条按 ID 固定选择,优先公共记录;更新时保留该记录原有 prefix,不批量覆盖。 */
private UserSign findRecord(String userId, boolean base64) {
if (Strings.isBlank(userId)) throw new IllegalArgumentException("请先登录");
List<UserSign> records = dao.query(UserSign.class, Cnd.where("user_id", "=", userId).asc("id"));
UserSign first = null;
for (UserSign record : records) {
if (!(base64 ? isBase64(record.getData()) : isFile(record.getData()))) continue;
if (Strings.isBlank(record.getPrefix())) return record;
if (first == null) first = record;
}
return first;
}
@Override
public String findPersonal(String userId, boolean base64) {
UserSign record = findRecord(userId, base64);
return record == null ? null : record.getData();
}
@Override
@Aop(TransAop.READ_COMMITTED)
public String savePersonal(String userId, String sign, boolean base64) {
if (Strings.isBlank(userId) || Strings.isBlank(sign)) throw new IllegalArgumentException("请先登录并签字");
// 锁定已有用户行,使同一用户的首次并发保存串行执行,无需新增类型字段或唯一索引。
Sql lock = Sqls.create("SELECT id FROM sys_user WHERE id=@id FOR UPDATE").setParam("id", userId);
lock.setCallback(Sqls.callback.map());
dao.execute(lock);
if (lock.getResult() == null) throw new IllegalArgumentException("当前用户不存在");
String saved;
if (base64) {
if (!isBase64(sign)) throw new IllegalArgumentException("当前页面需要 Base64 签名,请重新签字");
saved = sign;
} else if (isBase64(sign)) {
// 这里只上传本次新手写的图片;查询个人签名时不转换另一种类型的历史记录。
saved = uploadImage(sign);
} else {
if (!isFile(sign) || dao.fetch(UserSign.class, Cnd.where("user_id", "=", userId)
.and("data", "=", sign)) == null) {
throw new IllegalArgumentException("请使用本人的文件签名或重新签字");
}
saved = sign;
}
UserSign record = findRecord(userId, base64);
if (record == null) {
dao.insert(new UserSign(userId, saved));
} else if (!saved.equals(record.getData())) {
dao.update(UserSign.class, Chain.make("data", saved), Cnd.where("id", "=", record.getId())
.and("user_id", "=", userId));
}
return saved;
}
@Override
@Aop(TransAop.READ_COMMITTED)
public String uploadImage(String data) {
if (!isBase64(data)) throw new IllegalArgumentException("签字图片格式不正确");
String type = data.substring("data:image/".length(), data.indexOf(';'));
String directory = "/signature/" + DateUtil.format(new java.util.Date(), "yyyy/MM/dd");
String filename = R.UU32() + "." + ("jpeg".equals(type) ? "jpg" : type);
String path = directory + "/" + filename;
byte[] bytes = Base64.decode(data.substring(data.indexOf(',') + 1));
try (InputStream stream = new ByteArrayInputStream(bytes)) {
if (!ftpService.upload(directory, filename, stream)) throw new IllegalStateException("签字图片上传失败");
} catch (Exception e) {
throw new IllegalStateException("签字图片上传失败,请重试", e);
}
Sys_file file = new Sys_file();
file.setFilename(filename);
file.setFilepath(path);
file.setSource(null);
dao.insert(file);
return path;
}
@Override
@Aop(TransAop.READ_COMMITTED)
public String submitScan(String userId, String prefix, String id, String sign, boolean base64) {
if (Strings.isBlank(prefix) || Strings.isBlank(id)) throw new IllegalArgumentException("请重新扫码");
// 新版组件已保存个人签名,文件路径直接复用;旧扫码客户端仍可提交原始手写图片。
String saved = savePersonal(userId, sign, base64);
redisService.setex(prefix + ":" + id, 60 * 10, saved);
return saved;
}
}
}
@@ -1,21 +1,17 @@
package io.v.nutz.zhgh.mobile.common;
import cn.hutool.core.util.StrUtil;
import io.v.nutz.base.annontation.ViReturn;
import io.v.nutz.base.result.Result;
import io.v.nutz.base.service.ViService;
import io.v.nutz.web.commons.controller.SignatureController.PersonalSignatureService;
import io.v.nutz.web.commons.utils.ShiroUtil;
import io.v.nutz.zhgh.zgfw.model.UserSign;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
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.lang.Strings;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import java.util.List;
/**
* @author: Aaron
* @create: 2020-11-25 11:29
@@ -26,26 +22,41 @@ import java.util.List;
@Ok("json:full")
public class updateSign {
@Inject("UserSign")
private ViService<UserSign> userSignService;
@Inject("personalSignatureService")
private PersonalSignatureService personalSignatureService;
@At
/**
* 兼容旧 Java 调用:sign 为带图片数据头的 Base64,user_id 为空或当前登录人ID。
* 默认按 Base64 类型保存;返回 Result,code=0 表示成功,data 为保存后的签名原值。
* 此重载仅用于内部调用,不标注 @At,HTTP 请求仍使用三参数入口。
*/
@RequiresAuthentication
@Aop(TransAop.READ_COMMITTED)
public Result update(String sign, String user_id) {
if(StrUtil.isBlank(user_id)){
user_id = (String) ShiroUtil.getPrincipalProperty("id");
}
if (Strings.isNotBlank(getMySign())) {
userSignService.update(Chain.make("data", sign), Cnd.where("user_id", "=", user_id));
} else {
userSignService.insert(new UserSign(user_id, sign));
}
return Result.success();
return update(sign, user_id, Boolean.TRUE);
}
/**
* sign 为新手写 Base64 或本人已有文件路径;is_value_base64 默认 true,false 保存文件类型。
* user_id 兼容旧参数但只允许当前登录人;返回 {code,msg,data},data 为保存后的签名原值。
*/
@At
public String getMySign() {
List<UserSign> userSigns = userSignService.query(Cnd.NEW().and("user_id", "=", ShiroUtil.getPrincipalProperty("id")));
return userSigns.isEmpty() ? null : userSigns.get(0).getData();
@RequiresAuthentication
@Aop(TransAop.READ_COMMITTED)
public Result update(String sign, String user_id, Boolean is_value_base64) {
String currentUserId = ShiroUtil.getPrincipalProperty("id").toString();
if (Strings.isNotBlank(user_id) && !currentUserId.equals(user_id)) return Result.error("只能保存本人的签名");
if (Strings.isBlank(sign)) return Result.error("请先签字");
String saved = personalSignatureService.savePersonal(currentUserId, sign, !Boolean.FALSE.equals(is_value_base64));
return Result.success().addData(saved);
}
/** is_value_base64 默认 true;按当前登录用户查询对应类型,返回签名字符串,未找到返回 null。 */
@At
@RequiresAuthentication
public String getMySign(Boolean is_value_base64) {
return personalSignatureService.findPersonal(ShiroUtil.getPrincipalProperty("id").toString(),
!Boolean.FALSE.equals(is_value_base64));
}
}
@@ -33,7 +33,7 @@
</el-descriptions-item>
<el-descriptions-item label="签字" :span="3">
<el-image v-if="viewData.clubAudit.auditSign"
:src="viewData.clubAudit.auditSign"
:src="'/signature/getSignData?path=' + encodeURIComponent(viewData.clubAudit.auditSign)"
fit="contain"
style="width: 300px; height: 100px"></el-image>
<span v-else>暂无</span>
@@ -0,0 +1,84 @@
<template>
<div>
<van-loading v-if="loading" style="padding: 20px; text-align: center">加载中</van-loading>
<van-empty v-else-if="error" :description="error">
<van-button size="small" @click="getInfo(applyId)">重新加载</van-button>
</van-empty>
<template v-else-if="viewData.id">
<!-- 复用手机慰问详情的红色竖线标题样式。 -->
<div class="van-cell-group__title">
<div class="van-sidebar-item van-sidebar-item--select">申请信息</div>
</div>
<van-cell-group>
<van-cell title="姓名" :value="viewData.username"></van-cell>
<van-cell title="性别" :value="viewData.sex"></van-cell>
<van-cell title="工号" :value="viewData.loginname"></van-cell>
<van-cell title="出生年月" :value="viewData.birthday ? moment(viewData.birthday).format('YYYY-MM-DD') : ''"></van-cell>
<van-cell title="联系电话" :value="viewData.mobile"></van-cell>
<van-cell title="是否有特殊病史" :value="viewData.medicalHistory === true ? '是' : viewData.medicalHistory === false ? '否' : '暂无'"></van-cell>
<van-cell title="是否有特别需要说明的问题" :value="viewData.otherThing || '无'"></van-cell>
<van-cell title="紧急联系人" :value="viewData.emergencyContact || '暂无'"></van-cell>
<van-cell title="紧急联系电话" :value="viewData.emergencyMobile || '暂无'"></van-cell>
</van-cell-group>
<van-cell-group v-if="viewData.clubAudit" title="社团审核信息">
<van-cell title="审核人员" :value="viewData.clubAudit.username"></van-cell>
<van-cell title="审核时间" :value="viewData.clubAudit.auditTime"></van-cell>
<van-cell title="审核意见" :value="viewData.clubAudit.auditOpinion"></van-cell>
<van-cell title="审核签字">
<van-image v-if="viewData.clubAudit.auditSign" :src="'/signature/getSignData?path=' + encodeURIComponent(viewData.clubAudit.auditSign)" width="180" fit="contain"></van-image>
<span v-else>暂无</span>
</van-cell>
</van-cell-group>
<template v-if="handle">
<slot name="handle"></slot>
</template>
</template>
</div>
</template>
<script>
module.exports = {
name: 'mobileClubUserApplyInfo',
props: {
applyId: {type: String, default: ''},
handle: {type: Boolean, default: false}
},
data() {
return {viewData: {}, loading: false, error: '', requestVersion: 0}
},
watch: {
// 异步组件挂载后立即加载当前 ID,不依赖父页的 nextTick 和 ref 时序。
applyId: {
immediate: true,
handler(id) { this.getInfo(id) }
}
},
methods: {
// id 为入会申请主键;data 包含申请资料及可选 clubAudit 审核对象。
getInfo(id) {
const version = this.requestVersion + 1
this.$set(this, 'requestVersion', version)
this.$set(this, 'viewData', {})
this.$set(this, 'error', '')
this.$set(this, 'loading', !!id)
if (!id) return
return $.post('/platform/sys/club/sq/findOne', {id}).then(res => {
// 切换申请或组件销毁后忽略旧响应,避免显示上一位申请人的资料。
if (version !== this.requestVersion) return
if (res.code === 0 && res.data && res.data.id) {
this.$set(this, 'viewData', res.data)
} else {
this.$set(this, 'error', res.msg || '申请资料加载失败')
}
}).fail(() => {
if (version === this.requestVersion) this.$set(this, 'error', '申请资料加载失败,请重试')
}).always(() => {
if (version === this.requestVersion) this.$set(this, 'loading', false)
})
}
},
beforeDestroy() {
this.$set(this, 'requestVersion', this.requestVersion + 1)
}
}
</script>
@@ -3,8 +3,11 @@
<div class="main-panel">
<slot v-if="$slots.default"></slot>
<template v-else>
<van-button @click="openSignature" class="open-button" size="middle" plain native-type="button" :disabled="disabled">打开签字板</van-button>
<van-image :src="signatureContent" fit="contain">
<van-button @click="openSignature" class="open-button" size="middle" plain native-type="button" :disabled="disabled || saving">打开签字板</van-button>
<!-- 默认回显仅供预览,主动使用后才回传,避免扫码页收到 input 就自动提交。 -->
<van-button v-if="canUsePersonalSignature" @click="usePersonalSignature" class="open-button" size="middle"
native-type="button" :loading="saving" :disabled="disabled || saving">使用此签名</van-button>
<van-image :src="signatureImageUrl" fit="contain">
<template v-slot:error>请点击下方打开签字板按钮进行签字</template>
</van-image>
</template>
@@ -37,7 +40,9 @@ module.exports = {
my_sign: {type: String, default: ""},
// 不传 popupVisible 时保持组件自行开关;传入布尔值时由页面统一历史栈管理。
popupVisible: {type: Boolean, default: null},
// 默认仍保存个人签名;业务只需本次签字时传 false,通过 input 回传 Base64。
// 与 PC 一致:true 使用 Base64,false 使用个人签名表中的文件路径。
is_value_base64: {type: Boolean, default: true},
// 仅接收旧页面属性以兼容模板;不再参与判断,所有主动提交均保存个人签名。
saveToProfile: {type: Boolean, default: true},
disabled: {type: Boolean, default: false},
panelTitle: {type: String, default: "签字"}
@@ -51,15 +56,34 @@ module.exports = {
},
boundSignature() {
return Object.prototype.hasOwnProperty.call(this.$options.propsData || {}, "value") ? this.value : this.my_sign
},
canUsePersonalSignature() {
return !this.matchesSignatureType(this.boundSignature) && this.matchesSignatureType(this.personalSignature)
&& this.signatureContent === this.personalSignature
},
signatureImageUrl() {
// 显示地址与提交值分离:文件接口 URL 只用于 img,回填仍使用原始文件路径。
if (!this.matchesSignatureType(this.signatureContent)) return ''
return this.is_value_base64 ? this.signatureContent
: '/signature/getSignData?path=' + encodeURIComponent(this.signatureContent)
}
},
watch: {
boundSignature: {
handler(val) {
this.signatureContent = val
// 业务回显优先;表单清空时恢复个人签名预览,仍须点击使用才回填。
this.signatureVersion++
this.signatureContent = this.matchesSignatureType(val) ? val : this.personalSignature
},
immediate: true
},
is_value_base64() {
// 类型切换后清除另一种个人签名,只重新读取新类型,不互转历史签名。
this.signatureVersion++
this.personalSignature = ''
this.signatureContent = this.matchesSignatureType(this.boundSignature) ? this.boundSignature : ''
this.getMySign(true)
},
signaturePanelVisible(visible) {
// 每次打开重新创建画布;等待弹出动画完成后再按真实可用区域计算尺寸。
if (!visible) this.boardReady = false
@@ -68,12 +92,27 @@ module.exports = {
data() {
return {
signatureContent: null,
personalSignature: '',
signatureVersion: 0,
signatureRequest: 0,
signatureLoading: false,
showSignaturePanel: false,
boardReady: false,
saving: false
}
},
methods: {
/** value 为签名原值;只接受当前参数对应的数据,空值或另一种格式不能冒充本类型签名。 */
matchesSignatureType(value) {
if (typeof value !== 'string' || !value) return false
return this.is_value_base64 ? /^data:image\/(png|jpeg|jpg|gif|webp);base64,[A-Za-z0-9+/]+={0,2}$/.test(value)
: value.startsWith('/') && !value.startsWith('//') && !/[?#\\\r\n]/.test(value) && !value.includes('..')
},
/** 复用签名同样经过个人签名保存;没有打开签字层时不能误关父页面的详情弹框。 */
usePersonalSignature() {
if (this.disabled || this.saving || !this.canUsePersonalSignature) return
return this.save(this.personalSignature, false)
},
openSignature() {
if (!this.disabled && !this.saving) this.changePanelVisible(true)
},
@@ -93,41 +132,73 @@ module.exports = {
onPanelOpened() {
this.boardReady = this.signaturePanelVisible
},
/** data 为签名图片字符串,同时通知两种已有绑定方式,父表单提交字段保持不变。 */
/** data 为当前模式的 Base64 或文件路径,同时通知两种已有绑定方式。 */
updateSignature(data) {
this.signatureVersion++
this.signatureContent = data
this.$emit("input", data)
this.$emit("update:my_sign", data)
},
/** signature 为公共签字板生成的 Base64;本次签字模式不调用个人签名保存接口。 */
save(signature) {
/**
* signature 为新手写 Base64 或已读取的同类型个人签名;closePanel=false 表示在预览区复用。
* 提交 sign/is_value_base64,接口返回 {code,msg,data};data 为保存后的对应类型原值。
* 个人签名保存成功后才回填业务表单,文件模式的新手写图片由服务端上传。
*/
save(signature, closePanel = true) {
if (this.saving || this.disabled) return
if (!this.saveToProfile) {
this.updateSignature(signature)
this.changePanelVisible(false)
return
}
// 新签字开始保存后,之前发出的个人签名查询不得覆盖本次操作。
const version = ++this.signatureVersion
const base64 = this.is_value_base64
this.saving = true
return $.post("/mobile/common/signing/update", {sign: signature})
return $.post("/mobile/common/signing/update", {sign: signature, is_value_base64: base64})
.then((res) => {
if (this._isDestroyed || this._isBeingDestroyed || version !== this.signatureVersion
|| base64 !== this.is_value_base64) return
if (res.code === 0) {
if (!this.matchesSignatureType(res.data)) {
this.$toast.fail("返回的签名格式不正确,请重试")
return
}
this.personalSignature = res.data
if (closePanel && this.signaturePanelVisible) this.changePanelVisible(false)
this.updateSignature(res.data)
this.$toast.success("保存成功")
this.changePanelVisible(false)
return this.getMySign()
} else {
this.$toast.fail("保存失败")
this.$toast.fail(res.msg || "保存失败")
}
})
.fail(() => this.$toast.fail("签字保存失败,请重试"))
.always(() => { this.saving = false })
},
/** 沿用个人签名查询接口,返回图片数据并同步给已有 v-model 调用方。 */
getMySign() {
return $.get("/mobile/common/signing/getMySign").then((data) => {
if (data) {
this.updateSignature(data)
}
})
/**
* previewOnly=true 仅初始化预览;省略参数保留旧页面主动调用时的回填行为。
* 接口传 is_value_base64,不传用户ID;按登录账号返回对应类型签名或 null,方法返回 jQuery Promise。
* 无个人签名时保留当前内容;请求期间表单变更、重签或组件销毁后不再应用旧结果。
*/
getMySign(previewOnly = false) {
const request = ++this.signatureRequest
const version = this.signatureVersion
this.signatureLoading = true
return $.get("/mobile/common/signing/getMySign", {is_value_base64: this.is_value_base64})
.then((data) => {
if (request !== this.signatureRequest || version !== this.signatureVersion
|| this._isDestroyed || this._isBeingDestroyed) return
this.personalSignature = this.matchesSignatureType(data) ? data : ''
if (previewOnly) {
if (!this.matchesSignatureType(this.boundSignature)) this.signatureContent = this.personalSignature
} else if (this.personalSignature) {
this.updateSignature(this.personalSignature)
}
})
.fail(() => {
// 自动预览失败仍可手写;仅主动读取失败时提示,避免阻断业务页面进入。
if (!previewOnly && request === this.signatureRequest && !this._isDestroyed && !this._isBeingDestroyed) {
this.$toast.fail("个人签名读取失败,请重新签字")
}
})
.always(() => {
if (request === this.signatureRequest) this.signatureLoading = false
})
}
},
created() {
@@ -137,6 +208,8 @@ module.exports = {
})
// 旧表单通过 PJAX 替换 DOM 时不会自动销毁 Vue,主动释放签字实例和公共栈回调。
$(document).one("pjax:beforeReplace.mobileSignature" + this._uid, () => this.$destroy())
// 所有页面均按类型预览个人签名,初始化不触发 input、保存或打开签字板。
this.getMySign(true)
},
beforeDestroy() {
$(document).off("pjax:beforeReplace.mobileSignature" + this._uid)
@@ -131,7 +131,8 @@ module.exports = {
if (this.qz) {
this.getImageUrl(this.qz)
} else {
$.get("/mobile/common/signing/getMySign").then(data => {
// 与手机共用分类查询:只读取当前模式对应的个人签名。
$.get("/mobile/common/signing/getMySign", {is_value_base64: this.is_value_base64}).then(data => {
if (data) {
this.getImageUrl(data)
this.$emit("change", data)
@@ -101,7 +101,7 @@ layout("/mobile/platform.html"){
left-arrow
placeholder
fixed
@click-left="pjaxReplace('/mobile/index')"
@click-left="leavePage"
safe-area-inset-top
></van-nav-bar>
@@ -135,7 +135,7 @@ layout("/mobile/platform.html"){
@load="onLoad">
<div class="van-doc-card" v-for="o in tableData">
<div @click="openView(o.id)">
<div @click="openView(o)">
<div style="display: flex; justify-content: space-between">
<div style="width: 74%">
@@ -167,7 +167,7 @@ layout("/mobile/platform.html"){
<van-row>
<van-col span="24">
<span style="color: grey">所属单位:</span>
<span>{{o.unitname}}</span>
<span>{{o.applicantunitname}}</span>
</van-col>
</van-row>
<van-row>
@@ -185,7 +185,7 @@ layout("/mobile/platform.html"){
style="margin-right: 8px; width: auto;">
查看
</van-button>
<van-button @click.stop="openAudit(o)"
<van-button v-if="o.status == 1" @click.stop="openAudit(o)"
type="primary" size="small" color="#1867b0"
style="margin-right: 8px; width: auto;">
审核
@@ -203,16 +203,16 @@ layout("/mobile/platform.html"){
></van-empty>
</div>
<van-popup v-model="infoShow" position="right" :style="{width: '100%', height: '100%' }">
<van-popup :value="infoShow" :close-on-popstate="false" :close-on-click-overlay="false" position="right" :style="{width: '100%', height: '100%' }">
<van-nav-bar
title="详细信息"
left-arrow
placeholder
fixed
@click-left="infoShow = false"
@click-left="closePopup"
safe-area-inset-top
></van-nav-bar>
<club-user-apply-info ref="applyInfo" :handle="handle">
<club-user-apply-info v-if="infoShow" :key="applyId" :apply-id="applyId" :handle="handle">
<template #handle>
<div class="van-cell-group__title">
<div class="van-sidebar-item van-sidebar-item--select">
@@ -243,20 +243,18 @@ layout("/mobile/platform.html"){
<div class="mb10">审核签字</div>
</template>
<template #input>
<mobile-sign ref="signature" :my_sign.sync="formData.auditSign"></mobile-sign>
<!-- 新版签字组件使用文件模式,回填路径供协会审核保存和详情展示。 -->
<mobile-sign :is_value_base64="false" ref="signature" v-model="formData.auditSign"
:popup-visible="signatureShow" :disabled="formLoading"
@open="openSignature" @close="closePopup"></mobile-sign>
</template>
</van-field>
<van-row gutter="20" style="margin-top: 30px;padding: 0 25px">
<van-col span="12">
<van-button :color="themeColor" plain block @click="doReview(false)">拒 绝
</van-button>
</van-col>
<van-col span="12">
<van-button :color="themeColor" block @click="doReview(true)">通 过
</van-button>
</van-col>
</van-row>
<!-- 与慰问分工会审核页保持一致:红色拒绝、绿色通过。 -->
<div style="display: flex; justify-content: space-between; column-gap: 10px; padding: 10px">
<van-button type="danger" block :loading="formLoading" native-type="button" @click="doReview(false)">拒绝</van-button>
<van-button type="primary" block :loading="formLoading" native-type="button" @click="doReview(true)">通过</van-button>
</div>
</van-form>
</template>
@@ -272,6 +270,8 @@ layout("/mobile/platform.html"){
data() {
return {
handle: false,
applyId: '',
formLoading: false,
clubOptions: [],
userTypeOptions: [],
auditOptions: [
@@ -283,52 +283,71 @@ layout("/mobile/platform.html"){
radioType: 3
},
infoShow: false,
signatureShow: false,
formData: {},
}
},
components: {
'club-user-apply-info': httpVueLoader('/components/mobile/club/mobileClubUserApplyInfo.vue?v=1.0.1'),
'mobile-sign': httpVueLoader('/components/sign/mobileSign.vue'),
'club-user-apply-info': httpVueLoader('/components/mobile/club/mobileClubUserApplyInfo.vue?v=1.0.4'),
'mobile-sign': httpVueLoader('/components/sign/mobileSign.vue?v=1.0.3'),
},
methods: {
// 使用远端统一历史会话;签字层和详情层由同一回调驱动,避免重复压栈。
syncPopup(layers) {
this.$set(this, 'infoShow', layers.includes('info'))
this.$set(this, 'signatureShow', layers.includes('signature'))
},
closePopup() {
return this.$options.popupSession.close()
},
openSignature() {
this.$options.popupSession.open('signature')
},
leavePage() {
if (this.infoShow || this.signatureShow) return this.closePopup()
this.disposePopup()
pjaxReplace('/mobile/index')
},
disposePopup() {
if (this.$options.popupSession) this.$options.popupSession.dispose()
$(document).off('pjax:beforeSend.clubAudit')
$(document).off('pjax:beforeReplace.clubAudit')
},
openAudit(row) {
this.formData = {
this.$set(this, 'formData', {
id: row.id,
auditTime: moment().format('YYYY-MM-DD'),
username: "${@shiro.getPrincipalProperty('username')}",
auditOpinion: null,
auditSign: ""
}
this.infoShow = true
this.handle = true
this.$nextTick(() => {
this.$refs.applyInfo.getInfo(row.id)
auditSign: ''
})
this.$set(this, 'applyId', row.id)
this.$set(this, 'handle', true)
this.$options.popupSession.open('info')
},
openView(row) {
this.infoShow = true
this.handle = false
this.$nextTick(() => {
this.$refs.applyInfo.getInfo(row.id)
})
this.$set(this, 'applyId', row.id)
this.$set(this, 'handle', false)
this.$options.popupSession.open('info')
},
async doReview(flag) {
if (!this.formData.auditOpinion) {
vant.Toast('请填写审核意见');
// id 为申请记录,flag 为是否通过;响应 code 为 0 时清理弹框历史并刷新列表。
doReview(flag) {
if (this.formLoading) return
if (!this.formData.auditOpinion || !this.formData.auditOpinion.trim()) {
vant.Toast('请填写审核意见')
return
}
if (!this.formData.auditSign) {
vant.Toast('请签字')
return
}
// this.$refs.signature.submitSign()
this.formData.flag = flag
const resp = await $.post("/platform/sys/club/sh/check", this.formData)
if (resp.code === 0) {
this.doSearch()
this.infoShow = false
}
vant.Toast(resp.msg)
this.$set(this, 'formLoading', true)
const params = Object.assign({}, this.formData, {flag})
$.post('/platform/sys/club/sh/check', params).then(resp => {
if (resp.code === 0) this.$options.popupSession.clear().then(() => this.doSearch())
vant.Toast(resp.msg)
}).fail(() => vant.Toast('审核提交失败,请重试'))
.always(() => { this.$set(this, 'formLoading', false) })
},
doSearch() {
this.pageForm.pageNumber = 1
@@ -336,7 +355,7 @@ layout("/mobile/platform.html"){
this.finished = false
this.onLoad()
},
async onLoad() {
onLoad() {
this.loading = true
$.post('/platform/sys/club/sh/pageData', this.pageForm).then(res => {
if (res.code === 0) {
@@ -347,26 +366,51 @@ layout("/mobile/platform.html"){
this.pageForm.pageNumber++
}
}
this.loading = false
})
}).always(() => { this.$set(this, 'loading', false) })
},
async getMyMangeClub() {
const {data} = await $.get("/platform/sys/club/users/getMyMangeClub")
return data
getMyMangeClub() {
return $.get('/platform/sys/club/users/getMyMangeClub').then(res => res.data || [])
},
},
async created() {
const data = await this.getMyMangeClub()
data.forEach(item => {
this.clubOptions.push({text: item.name, value: item.id})
created() {
this.$options.popupSession = window.mobilePopupHistory.register('clubAudit', this.syncPopup)
// PJAX 离开前清理本页历史,再执行原跳转,防止返回到已关闭弹框。
$(document).on('pjax:beforeSend.clubAudit', (event, xhr, options) => {
if (this.infoShow || this.signatureShow) {
event.preventDefault()
this.$options.popupSession.clear().then(() => {
this.disposePopup()
pjaxReplace(options.url)
})
return false
}
this.disposePopup()
})
this.clubOptions.unshift({text: '全部社团'})
const userType = await getDictOptions('UserType')
userType.forEach(item => {
this.userTypeOptions.push({text: item.name, value: item.code})
// PJAX 不会自动销毁旧页面实例,替换容器前释放本页会话及事件。
$(document).one('pjax:beforeReplace.clubAudit', () => this.$destroy())
this.getMyMangeClub().then(data => {
this.$set(this, 'clubOptions', [{text: '全部社团'}].concat(
data.map(item => ({text: item.name, value: item.id}))))
})
this.userTypeOptions.unshift({text: '人员类型'})
getDictOptions('UserType').then(data => {
this.$set(this, 'userTypeOptions', [{text: '人员类型'}].concat(
data.map(item => ({text: item.name, value: item.code}))))
})
// 首页已办传 audit=1、applyId;复用现有详情弹框和全局历史栈,只读展示审核结果。
if (GetQueryString('audit') === '1') {
this.$set(this.pageForm, 'radioType', 2)
const applyId = GetQueryString('applyId')
if (applyId) this.openView({id: applyId})
}
this.onLoad()
},
beforeDestroy() {
// 空栈同步注销,防止下一页注册时旧会话尚未释放。
if (this.infoShow || this.signatureShow) {
this.$options.popupSession.clear().then(() => this.disposePopup())
} else {
this.disposePopup()
}
}
})
</script>
@@ -217,7 +217,7 @@ layout("/mobile/platform.html"){
</van-popup>
</div>
<script>
<!--#include('../common/info.js'){}#-->
<!--#include('/mobile/difficulty/common/info.js'){}#-->
const vue = new Vue({
el: '#app',
@@ -189,7 +189,7 @@ layout("/mobile/platform.html"){
</van-popup>
</div>
<script>
<!--#include('../common/info.js'){}#-->
<!--#include('/mobile/difficulty/common/info.js'){}#-->
const vue = new Vue({
el: '#app',
@@ -219,7 +219,7 @@ layout("/mobile/platform.html"){
</van-popup>
</div>
<script>
<!--#include('../common/info.js'){}#-->
<!--#include('/mobile/difficulty/common/info.js'){}#-->
const vue = new Vue({
el: '#app',
@@ -219,7 +219,7 @@ layout("/mobile/platform.html"){
</van-popup>
</div>
<script>
<!--#include('../common/info.js'){}#-->
<!--#include('/mobile/difficulty/common/info.js'){}#-->
const vue = new Vue({
el: '#app',
+59 -9
View File
@@ -172,7 +172,7 @@ layout("/mobile/platform.html"){
<div id="app">
<!--首页-->
<div style="margin-bottom: 60px" v-if="active === 0">
<div v-if="active === 0">
<!--轮播图-->
<van-swipe :autoplay="3000" class="swiper">
<van-swipe-item :key="index" v-for="(image, index) in images">
@@ -353,7 +353,7 @@ layout("/mobile/platform.html"){
description="暂无待办"
image="https://img01.yzcdn.cn/vant/custom-empty-image.png"
style="margin-top: 44px"
v-if="needItems.length === 0"
v-if="needItems.length === 0 && !clubTodoLoading"
></van-empty>
</div>
</van-tab>
@@ -376,7 +376,7 @@ layout("/mobile/platform.html"){
description="暂无已办"
image="https://img01.yzcdn.cn/vant/custom-empty-image.png"
style="margin-top: 44px"
v-if="completedItems.length === 0"
v-if="completedItems.length === 0 && !clubCompletedLoading"
></van-empty>
</div>
</van-tab>
@@ -385,7 +385,7 @@ layout("/mobile/platform.html"){
</div>
<!--工作台-->
<div style="margin-bottom: 60px" v-if="active === 2">
<div v-if="active === 2">
<van-row class="module" v-for="m in moduleMenus">
<div class="van-sidebar-item van-sidebar-item--select">
{{m.moduleName}}
@@ -401,7 +401,8 @@ layout("/mobile/platform.html"){
</van-row>
</div>
<van-tabbar @change="tabbrChange" v-model="active">
<!-- 按底部导航实际高度(含手机安全区域)占位,确保末行菜单可完整滚动到导航上方。 -->
<van-tabbar @change="tabbrChange" v-model="active" placeholder>
<van-tabbar-item icon="home-o">首页</van-tabbar-item>
<van-tabbar-item :dot="needCount" icon="envelop-o">我的办理</van-tabbar-item>
<van-tabbar-item icon="apps-o">工作台</van-tabbar-item>
@@ -509,6 +510,10 @@ layout("/mobile/platform.html"){
active: 0,
needCount: 0,
needItems: [],
clubTodoLoading: false,
clubTodoVersion: 0,
clubCompletedLoading: false,
clubCompletedVersion: 0,
activeName: '0',
completedItems: [],
bigPictureList: [],
@@ -554,14 +559,18 @@ layout("/mobile/platform.html"){
tabbrChange(o) {
console.log(o)
sessionStorage.setItem("zhgh-mobile-home-active",o)
this.needItems = []
this.initData()
},
toUrl(o) {
pjaxReplace(o.mobileHref)
},
toHref(o) {
pjaxReplace(o.mobileUrl + '?audit=1');
// 协会已办携带申请 ID,直接以查看模式打开该申请,不进入待审核列表。
if (o.businessType === 'clubApply') {
pjaxReplace(o.mobileUrl + '?audit=1&applyId=' + encodeURIComponent(o.applyId))
return
}
pjaxReplace(o.mobileUrl + '?audit=1');
},
async getNeedItems() {
const {data, code} = await $.get('/platform/needItems/getNeedItems')
@@ -598,14 +607,55 @@ layout("/mobile/platform.html"){
this.needItems = this.needItems.concat(data.filter(v => v.count > 0))
}
},
async initData() {
// 无参数;data 返回协会个人待办卡片。每次替换协会卡片,保留其他业务待办。
getClubTodo() {
const version = this.clubTodoVersion + 1
this.$set(this, 'clubTodoVersion', version)
this.$set(this, 'clubTodoLoading', true)
return $.post('/platform/sys/club/sh/mobileTodo', {}).then(res => {
if (version !== this.clubTodoVersion) return
if (res.code === 0) {
const otherItems = this.needItems.filter(item => item.mobileHref !== '/platform/sys/club/sh/mobile')
this.$set(this, 'needItems', otherItems.concat(res.data || []))
this.$set(this, 'needCount', this.needItems.reduce((total, item) => total + (Number(item.count) || 0), 0))
} else {
vant.Toast(res.msg || '协会待办加载失败,请重试')
}
}).fail(() => {
if (version === this.clubTodoVersion) vant.Toast('协会待办加载失败,请重试')
}).always(() => {
if (version === this.clubTodoVersion) this.$set(this, 'clubTodoLoading', false)
})
},
// 已办按实际审核人查询,刷新替换而非追加,避免页签切换产生重复卡片。
getClubCompleted() {
const version = this.clubCompletedVersion + 1
this.$set(this, 'clubCompletedVersion', version)
this.$set(this, 'clubCompletedLoading', true)
return $.post('/platform/sys/club/sh/mobileCompleted', {}).then(res => {
if (version !== this.clubCompletedVersion) return
if (res.code === 0) {
const otherItems = this.completedItems.filter(item => item.businessType !== 'clubApply')
this.$set(this, 'completedItems', otherItems.concat(res.data || []))
} else {
vant.Toast(res.msg || '协会已办加载失败,请重试')
}
}).fail(() => {
if (version === this.clubCompletedVersion) vant.Toast('协会已办加载失败,请重试')
}).always(() => {
if (version === this.clubCompletedVersion) this.$set(this, 'clubCompletedLoading', false)
})
},
initData() {
// await this.getNeedItems();
// await this.getMemberCheckSelfAgenda()
// await this.getWelfareCheckSelfAgenda()
// await this.getCompletes();
// await this.getMsg()
this.listActivity();
this.needCount = this.needItems.length
// 首页首次进入、办理页签切换及 pageshow 返回均走此入口,刷新已处理数量。
this.getClubTodo()
this.getClubCompleted()
},
async setNeedItems() {
const {data, code} = await $.get('/platform/needItems/getNeedItems')
@@ -33,6 +33,8 @@ layout("/mobile/platform.html"){
.association-description { padding: 0 14px; line-height: 1.8; white-space: pre-wrap; overflow-wrap: anywhere; }
.association-muted { margin: 10px 14px; color: #969799; }
.association-total .van-cell__value { color: #086bc3; font-weight: 600; }
/* 共用详情中的预算分组沿用申报审核页的分隔样式。 */
.association-budget-item + .association-budget-item { border-top: 1px solid #f2f3f5; }
.association-sign-image { display: block; max-width: calc(100% - 28px); width: 240px; height: 100px; object-fit: contain; margin: 8px 14px; }
.association-actions { flex-shrink: 0; display: flex; gap: 12px; padding: 10px 14px calc(10px + env(safe-area-inset-bottom)); background: white; border-top: 1px solid #eee; z-index: 2; }
.association-actions .van-button { flex: 1; border-radius: 6px; height: 40px; }
@@ -127,7 +129,7 @@ layout("/mobile/platform.html"){
</van-popup>
</div>
<script>
<!--#include('info.js'){}#-->
<!--#include('/mobile/jf/activitybx/review/info.js'){}#-->
<!--#include('page.js'){}#-->
</script>
<!--#
@@ -1,104 +0,0 @@
/** 协会活动申报详情:仅展示服务端已校验权限的数据,审核表单通过末尾插槽传入。 */
const associationInfo = {
template: /*language=HTML*/ `
<div class="association-info">
<section class="association-section">
<h3>{{ data.activity_name }}</h3>
<van-cell title="当前状态" :value="data.state_name"></van-cell>
<van-cell title="申请人" :value="data.user_name + '(' + (data.loginname || '—') + ')'"></van-cell>
<van-cell title="联系方式" :value="data.mobile || '—'"></van-cell>
<van-cell title="申请时间" :value="data.apply_time || '—'"></van-cell>
<van-cell title="承办协会" :value="data.clubName || data.fundsUnitName || '—'"></van-cell>
<van-cell title="项目编号" :value="data.projectCode || '—'"></van-cell>
<van-cell title="经费类型" :value="data.projectJfName || '—'"></van-cell>
<van-cell title="计划时间" :value="(data.startPlannedDate || '—') + ' 至 ' + (data.endPlannedDate || '—')"></van-cell>
<van-cell title="活动地点" :value="data.address || '—'"></van-cell>
<van-cell title="活动人数" :value="data.activity_number || '—'"></van-cell>
</section>
<section class="association-section">
<h3>活动方案及简介</h3>
<div class="association-description">{{ activityText || '暂无' }}</div>
<img v-for="(url, index) in activityImages" :key="index" :src="url" class="association-content-image"
alt="活动方案配图" @click="$emit('preview', {imageUrl: url})">
</section>
<section class="association-section">
<h3>预算明细</h3>
<van-cell v-for="(item, index) in budgets" :key="index" :title="item.name || '费用项目'"
:value="'¥' + money(item.budgetPrice)"></van-cell>
<van-cell title="预算合计" :value="'¥' + money(data.funds_money)" class="association-total"></van-cell>
</section>
<section class="association-section">
<h3>申请附件</h3>
<van-cell v-for="(file, index) in files" :key="file.id || index" :title="file.filename || file.name || '附件'" class="association-file"
value="查看" is-link @click="$emit('preview', file)"></van-cell>
<p v-if="!files.length" class="association-muted">暂无附件</p>
<h3>申请人签字</h3>
<img v-if="data.us_s" class="association-sign-image" :src="signUrl(data.us_s)" alt="申请人签字">
<p v-else class="association-muted">暂无签字</p>
</section>
<section v-for="audit in audits" :key="audit.key" class="association-section">
<h3>{{ audit.title }}</h3>
<van-cell title="审核人" :value="audit.name || '—'"></van-cell>
<van-cell title="审核时间" :value="audit.time || '—'"></van-cell>
<van-cell title="审核意见" :value="audit.idea || '暂无'"></van-cell>
<img v-if="audit.sign" class="association-sign-image" :src="signUrl(audit.sign)" :alt="audit.title + '签字'">
</section>
<slot></slot>
</div>
`,
props: {
data: {type: Object, default: () => ({})}
},
computed: {
budgets() {
return this.asList(this.data.budgets)
},
files() {
return this.asList(this.data.files)
},
activityText() {
// 申请内容来自富文本,按段落提取文本,避免在审核页面执行其中的脚本或事件属性。
const container = document.createElement('div')
container.innerHTML = (this.data.activity_content || '').replace(/<\/(p|div|li)>/gi, '\n')
container.querySelectorAll('script,style').forEach((node) => node.remove())
return container.textContent.trim()
},
activityImages() {
// 保留富文本中的方案配图,只提取图片地址,不执行原文中的 HTML 属性和事件。
const content = new DOMParser().parseFromString(this.data.activity_content || '', 'text/html')
return Array.from(content.querySelectorAll('img')).map((img) => img.getAttribute('src') || '')
.filter((url) => /^(https?:\/\/|\/(?!\/)|data:image\/(?:png|jpeg|gif|webp);base64,)/i.test(url))
},
audits() {
const data = this.data
return [
{key: 'association', title: '协会负责人审核', id: data.association_principal_id,
name: data.ap_username, time: data.association_principal_time, idea: data.association_principal_idea, sign: data.ap_s},
{key: 'accounting', title: '校工会负责人审核', id: data.accounting_name,
name: data.a_username, time: data.accounting_time, idea: data.accounting_idea, sign: data.a_s},
{key: 'union', title: '校工会主席审核', id: data.union_chairman_name,
name: data.ucc_username, time: data.union_chairman_time, idea: data.union_chairman_idea, sign: data.ucc_s}
].filter((audit) => audit.id)
}
},
methods: {
/** 历史接口可能返回 JSON 字符串或数组;空值、异常数据均不阻断整页展示。 */
asList(value) {
if (Array.isArray(value)) return value
if (!value) return []
try {
const list = JSON.parse(value)
return Array.isArray(list) ? list : []
} catch (error) {
return []
}
},
money(value) {
return value == null || !Number.isFinite(Number(value)) ? '—' : Number(value).toLocaleString('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2})
},
/** 签字兼容 PC 保存的 Base64 图片和历史文件路径。 */
signUrl(value) {
return value && value.startsWith('data:image/') ? value : '/signature/getSignData?path=' + encodeURIComponent(value || '')
}
}
}
@@ -1,7 +1,7 @@
// 独立手机审核页面;不复用旧 declare 页面及其有缺陷的滚动分页。
const vue = new Vue({
el: '#app',
components: {'association-info': associationInfo},
components: {'association-info': activityReviewInfo},
data() {
return {
baseUrl: '/platform/jf/activity_bx/association',
@@ -1,67 +1,69 @@
/** 活动申报审核详情:仅展示服务端已校验权限的数据,审核表单通过末尾插槽传入。 */
/** 四个活动申报审核入口共用详情;可选字段按返回数据显示,审核表单通过末尾插槽传入。 */
const activityReviewInfo = {
template: /*language=HTML*/ `
<div class="review-info">
<section class="review-section">
<!-- 同时保留两套入口的既有样式类,详情内容和显隐规则共用一份。 -->
<div class="review-info association-info">
<section class="review-section association-section">
<h3>{{ data.activity_name }}</h3>
<van-cell title="当前状态" :value="data.state_name"></van-cell>
<van-cell title="申请人" :value="data.user_name + '(' + (data.loginname || '—') + ')'"></van-cell>
<van-cell title="所属工会" :value="data.bxr_unionname || '—'"></van-cell>
<van-cell title="所属单位" :value="data.bxr_unitname || '—'"></van-cell>
<van-cell title="活动主体" :value="activityType"></van-cell>
<van-cell title="申请类型" :value="Number(data.apply_type) === 1 ? '经费申报' : '报销申请'"></van-cell>
<!-- 可选组织和类型字段由详情数据决定显隐,不根据审核入口切换展示模式。 -->
<van-cell v-if="data.bxr_unionname && String(data.bxr_unionname).trim()" title="所属工会" :value="data.bxr_unionname"></van-cell>
<van-cell v-if="data.bxr_unitname && String(data.bxr_unitname).trim()" title="所属单位" :value="data.bxr_unitname"></van-cell>
<van-cell v-if="data.activity_type" title="活动主体" :value="activityType"></van-cell>
<van-cell v-if="data.apply_type" title="申请类型" :value="Number(data.apply_type) === 1 ? '经费申报' : '报销申请'"></van-cell>
<van-cell title="联系方式" :value="data.mobile || '—'"></van-cell>
<van-cell title="申请时间" :value="data.apply_time || '—'"></van-cell>
<van-cell title="承办单位/协会" :value="data.clubName || data.fundsUnitName || '—'"></van-cell>
<van-cell :title="Number(data.activity_type) === 40003 ? '承办协会' : '承办单位/协会'" :value="data.clubName || data.fundsUnitName || '—'"></van-cell>
<van-cell title="项目编号" :value="data.projectCode || '—'"></van-cell>
<van-cell title="经费类型" :value="data.projectJfName || '—'"></van-cell>
<van-cell title="计划时间" :value="(data.startPlannedDate || '—') + ' 至 ' + (data.endPlannedDate || '—')"></van-cell>
<van-cell title="活动时间" :value="data.startTime ? data.startTime + ' 至 ' + (data.endTime || '—') : '暂无'"></van-cell>
<van-cell title="实际费用" :value="'¥' + money(data.activity_money)"></van-cell>
<van-cell v-if="data.startTime || data.endTime" title="活动时间" :value="(data.startTime || '—') + ' 至 ' + (data.endTime || '—')"></van-cell>
<!-- 零元是有效费用;仅未提供金额时隐藏,不能用金额真假判断。 -->
<van-cell v-if="data.activity_money != null && String(data.activity_money).trim() !== ''" title="实际费用" :value="'¥' + money(data.activity_money)"></van-cell>
<van-cell title="活动地点" :value="data.address || '—'"></van-cell>
<van-cell title="活动人数" :value="data.activity_number || '—'"></van-cell>
</section>
<section class="review-section">
<section class="review-section association-section">
<h3>活动方案及简介</h3>
<div class="review-description">{{ activityText || '暂无' }}</div>
<img v-for="(url, index) in activityImages" :key="index" :src="url" class="review-content-image"
alt="活动方案配图" @click="$emit('preview', {imageUrl: url})">
<div class="review-description association-description" style="white-space:normal;min-width:0"
v-html="activityHtml || '暂无'" @click="previewActivityImage"></div>
</section>
<section class="review-section">
<section class="review-section association-section">
<h3>预算明细</h3>
<div v-for="(item, index) in budgets" :key="index" class="review-budget-item">
<div v-for="(item, index) in budgets" :key="index" class="review-budget-item association-budget-item">
<van-cell :title="item.name || '费用项目'" :value="'¥' + money(item.budgetPrice)"></van-cell>
<template v-if="data.reimbursement_state_id">
<van-cell title="实际费用" :value="'¥' + money(item.actualPrice)"></van-cell>
<van-cell title="收款人" :value="item.username || '—'"></van-cell>
<van-cell title="支行名称" :value="item.bankName || '—'"></van-cell>
<van-cell title="报销卡号" :value="item.bankCardNum || '—'"></van-cell>
<van-cell title="备注" :value="item.remark || '—'"></van-cell>
<van-cell v-if="item.actualPrice != null && String(item.actualPrice).trim() !== ''" title="实际费用" :value="'¥' + money(item.actualPrice)"></van-cell>
<van-cell v-if="item.username" title="收款人" :value="item.username"></van-cell>
<van-cell v-if="item.bankName" title="支行名称" :value="item.bankName"></van-cell>
<van-cell v-if="item.bankCardNum" title="报销卡号" :value="item.bankCardNum"></van-cell>
<van-cell v-if="item.remark" title="备注" :value="item.remark"></van-cell>
</template>
</div>
<van-cell title="预算合计" :value="'¥' + money(data.funds_money)" class="review-total"></van-cell>
<van-cell title="预算合计" :value="'¥' + money(data.funds_money)" class="review-total association-total"></van-cell>
</section>
<section class="review-section">
<section class="review-section association-section">
<h3>申请附件</h3>
<van-cell v-for="(file, index) in files" :key="file.id || index" :title="file.filename || file.name || '附件'" class="review-file"
<van-cell v-for="(file, index) in files" :key="file.id || index" :title="file.filename || file.name || '附件'" class="review-file association-file"
value="查看" is-link @click="$emit('preview', file)"></van-cell>
<p v-if="!files.length" class="review-muted">暂无附件</p>
<p v-if="!files.length" class="review-muted association-muted">暂无附件</p>
<h3>申请人签字</h3>
<img v-if="data.us_s" class="review-sign-image" :src="signUrl(data.us_s)" alt="申请人签字">
<p v-else class="review-muted">暂无签字</p>
<img v-if="data.us_s" class="review-sign-image association-sign-image" :src="signUrl(data.us_s)" alt="申请人签字">
<p v-else class="review-muted association-muted">暂无签字</p>
</section>
<section v-for="group in reimbursementFiles" :key="group.key" class="review-section">
<section v-for="group in reimbursementFiles" :key="group.key" class="review-section association-section">
<h3>{{ group.title }}</h3>
<van-cell v-for="(file, index) in group.files" :key="file.id || index" :title="file.filename || file.name || '附件'"
class="review-file" value="查看" is-link @click="$emit('preview', file)"></van-cell>
<p v-if="!group.files.length" class="review-muted">暂无附件</p>
class="review-file association-file" value="查看" is-link @click="$emit('preview', file)"></van-cell>
<p v-if="!group.files.length" class="review-muted association-muted">暂无附件</p>
</section>
<section v-for="audit in audits" :key="audit.key" class="review-section">
<section v-for="audit in audits" :key="audit.key" class="review-section association-section">
<h3>{{ audit.title }}</h3>
<van-cell title="审核人" :value="audit.name || '—'"></van-cell>
<van-cell title="审核时间" :value="audit.time || '—'"></van-cell>
<van-cell title="审核意见" :value="audit.idea || '暂无'"></van-cell>
<img v-if="audit.sign" class="review-sign-image" :src="signUrl(audit.sign)" :alt="audit.title + '签字'">
<img v-if="audit.sign" class="review-sign-image association-sign-image" :src="signUrl(audit.sign)" :alt="audit.title + '签字'">
</section>
<slot></slot>
</div>
@@ -74,9 +76,11 @@ const activityReviewInfo = {
return {40001: '校工会活动', 40002: '基层工会活动', 40003: '协会活动', 40004: '女职工活动'}[this.data.activity_type] || '—'
},
reimbursementFiles() {
// 报销状态存在且附件组有内容时才展示,未上传的可选附件不占用空白分组。
if (!this.data.reimbursement_state_id) return []
return [{key: 'billFiles', title: '发票'}, {key: 'photoFiles', title: '活动报道照片/总结/链接'}, {key: 'otherFiles', title: '奖品/纪念品/服装/道具等活动人员名单'}]
.map((group) => Object.assign({}, group, {files: this.asList(this.data[group.key])}))
.filter((group) => group.files.length)
},
budgets() {
return this.asList(this.data.budgets)
@@ -84,18 +88,94 @@ const activityReviewInfo = {
files() {
return this.asList(this.data.files)
},
activityText() {
// 申请内容来自富文本,按段落提取文本,避免在审核页面执行其中的脚本或事件属性。
const container = document.createElement('div')
container.innerHTML = (this.data.activity_content || '').replace(/<\/(p|div|li)>/gi, '\n')
container.querySelectorAll('script,style').forEach((node) => node.remove())
return container.textContent.trim()
},
activityImages() {
// 保留富文本中的方案配图,只提取图片地址,不执行原文中的 HTML 属性和事件。
activityHtml() {
// activity_content 为历史富文本;只重建允许的内容节点,不复制脚本、事件、类名或任意内联样式。
const content = new DOMParser().parseFromString(this.data.activity_content || '', 'text/html')
return Array.from(content.querySelectorAll('img')).map((img) => img.getAttribute('src') || '')
.filter((url) => /^(https?:\/\/|\/(?!\/)|data:image\/(?:png|jpeg|gif|webp);base64,)/i.test(url))
const container = content.createElement('div')
const allowed = new Set(['P', 'DIV', 'SPAN', 'BR', 'B', 'STRONG', 'I', 'EM', 'U', 'S', 'DEL', 'SUB', 'SUP',
'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'UL', 'OL', 'LI', 'BLOCKQUOTE', 'PRE', 'CODE', 'HR',
'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR', 'TH', 'TD', 'CAPTION', 'IMG', 'FONT', 'MARK'])
const blocked = new Set(['SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'SVG', 'MATH', 'TEMPLATE',
'FORM', 'INPUT', 'BUTTON', 'TEXTAREA', 'SELECT', 'LINK', 'META', 'BASE'])
const appendContent = (source, parent) => {
if (source.nodeType === 3) {
parent.appendChild(content.createTextNode(source.textContent))
return
}
if (source.nodeType !== 1 || blocked.has(source.tagName)) return
const tag = source.tagName
if (!allowed.has(tag)) {
Array.from(source.childNodes).forEach((child) => appendContent(child, parent))
return
}
const node = content.createElement(tag === 'FONT' ? 'span' : tag.toLowerCase())
// 仅保留文字强调、颜色和对齐;固定宽高、定位和 Word 段落间距不带入手机页面。
const textStyles = ['color', 'backgroundColor', 'fontWeight', 'fontStyle', 'textDecorationLine', 'textAlign', 'verticalAlign']
textStyles.forEach((name) => { node.style[name] = source.style[name] })
if (tag === 'FONT' && !node.style.color) node.style.color = source.getAttribute('color') || ''
if (!node.style.textAlign && /^(left|center|right|justify)$/i.test(source.getAttribute('align') || '')) {
node.style.textAlign = source.getAttribute('align')
}
if (/^(P|DIV|H[1-6]|BLOCKQUOTE|PRE|UL|OL)$/.test(tag)) {
node.style.margin = '0 0 8px'
node.style.lineHeight = '1.8'
}
if (/^H[1-6]$/.test(tag)) node.style.fontSize = '16px'
if (tag === 'UL' || tag === 'OL') {
node.style.paddingLeft = '24px'
node.style.listStyleType = tag === 'UL' ? 'disc' : 'decimal'
if (tag === 'OL' && /^-?\d{1,6}$/.test(source.getAttribute('start') || '')) {
node.setAttribute('start', source.getAttribute('start'))
}
}
if (tag === 'PRE') {
node.style.whiteSpace = 'pre-wrap'
node.style.overflowWrap = 'anywhere'
}
if (tag === 'IMG') {
// 图片沿用原有地址范围;保留正文中的位置,点击后交给现有预览弹框和历史栈。
const url = (source.getAttribute('src') || '').trim()
if (!/^(https?:\/\/|\/(?![\/\\])|data:image\/(?:png|jpeg|gif|webp);base64,)/i.test(url)) return
node.setAttribute('src', url)
node.setAttribute('alt', source.getAttribute('alt') || '活动方案配图')
node.style.cssText = 'display:block;max-width:100%;height:auto;margin:8px 0;cursor:pointer'
}
if (tag === 'TABLE') {
// 每张表单独横向滚动,保留行列关系;不让宽表撑开整个审核页面。
const scroll = content.createElement('div')
scroll.style.cssText = 'max-width:100%;overflow-x:auto;margin:8px 0;white-space:normal'
parent.appendChild(scroll)
parent = scroll
node.style.width = '100%'
node.style.borderCollapse = 'collapse'
node.style.tableLayout = 'auto'
}
if (tag === 'TD' || tag === 'TH') {
node.style.border = '1px solid #dcdfe6'
node.style.padding = '6px 8px'
node.style.minWidth = '72px'
node.style.overflowWrap = 'anywhere'
// 合并单元格只接受浏览器支持范围内的整数,保留预算表的表头和分组结构。
;['rowspan', 'colspan'].forEach((name) => {
const value = source.getAttribute(name)
if (/^\d{1,4}$/.test(value || '') && Number(value) >= 1 && Number(value) <= 1000) {
node.setAttribute(name, value)
}
})
}
Array.from(source.childNodes).forEach((child) => appendContent(child, node))
parent.appendChild(node)
}
if (!content.body.children.length) {
// 旧手机申请可能保存纯文字;保留用户输入的换行,但不把它作为 HTML 再解析。
const plain = content.createElement('div')
plain.style.whiteSpace = 'pre-wrap'
plain.textContent = content.body.textContent.trim()
container.appendChild(plain)
} else {
Array.from(content.body.childNodes).forEach((node) => appendContent(node, container))
}
return container.textContent.trim() || container.querySelector('img') ? container.innerHTML : ''
},
audits() {
const data = this.data
@@ -124,6 +204,11 @@ const activityReviewInfo = {
}
},
methods: {
/** event 为正文点击事件;仅图片触发 preview,参数 imageUrl 交由父页面现有弹框处理,无返回值。 */
previewActivityImage(event) {
const target = event.target
if (target && target.tagName === 'IMG') this.$emit('preview', {imageUrl: target.getAttribute('src')})
},
/** 历史接口可能返回 JSON 字符串或数组;空值、异常数据均不阻断整页展示。 */
asList(value) {
if (Array.isArray(value)) return value
@@ -23,9 +23,8 @@ const reimbursementInfo = {
</section>
<section class="reimbursement-review-section">
<h3>活动方案及简介</h3>
<div class="reimbursement-review-description">{{ activityText || '暂无' }}</div>
<img v-for="(url, index) in activityImages" :key="index" :src="url" class="reimbursement-review-content-image"
alt="活动方案配图" @click="$emit('preview', {imageUrl: url})">
<div class="reimbursement-review-description" style="white-space:normal;min-width:0"
v-html="activityHtml || '暂无'" @click="previewActivityImage"></div>
</section>
<section class="reimbursement-review-section">
<h3>费用及收款信息</h3>
@@ -85,18 +84,94 @@ const reimbursementInfo = {
files() {
return this.asList(this.data.files)
},
activityText() {
// 申请内容来自富文本,按段落提取文本,避免在审核页面执行其中的脚本或事件属性。
const container = document.createElement('div')
container.innerHTML = (this.data.activity_content || '').replace(/<\/(p|div|li)>/gi, '\n')
container.querySelectorAll('script,style').forEach((node) => node.remove())
return container.textContent.trim()
},
activityImages() {
// 保留富文本中的方案配图,只提取图片地址,不执行原文中的 HTML 属性和事件。
activityHtml() {
// activity_content 为历史富文本;只重建允许的内容节点,不复制脚本、事件、类名或任意内联样式。
const content = new DOMParser().parseFromString(this.data.activity_content || '', 'text/html')
return Array.from(content.querySelectorAll('img')).map((img) => img.getAttribute('src') || '')
.filter((url) => /^(https?:\/\/|\/(?!\/)|data:image\/(?:png|jpeg|gif|webp);base64,)/i.test(url))
const container = content.createElement('div')
const allowed = new Set(['P', 'DIV', 'SPAN', 'BR', 'B', 'STRONG', 'I', 'EM', 'U', 'S', 'DEL', 'SUB', 'SUP',
'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'UL', 'OL', 'LI', 'BLOCKQUOTE', 'PRE', 'CODE', 'HR',
'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR', 'TH', 'TD', 'CAPTION', 'IMG', 'FONT', 'MARK'])
const blocked = new Set(['SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'SVG', 'MATH', 'TEMPLATE',
'FORM', 'INPUT', 'BUTTON', 'TEXTAREA', 'SELECT', 'LINK', 'META', 'BASE'])
const appendContent = (source, parent) => {
if (source.nodeType === 3) {
parent.appendChild(content.createTextNode(source.textContent))
return
}
if (source.nodeType !== 1 || blocked.has(source.tagName)) return
const tag = source.tagName
if (!allowed.has(tag)) {
Array.from(source.childNodes).forEach((child) => appendContent(child, parent))
return
}
const node = content.createElement(tag === 'FONT' ? 'span' : tag.toLowerCase())
// 仅保留文字强调、颜色和对齐;固定宽高、定位和 Word 段落间距不带入手机页面。
const textStyles = ['color', 'backgroundColor', 'fontWeight', 'fontStyle', 'textDecorationLine', 'textAlign', 'verticalAlign']
textStyles.forEach((name) => { node.style[name] = source.style[name] })
if (tag === 'FONT' && !node.style.color) node.style.color = source.getAttribute('color') || ''
if (!node.style.textAlign && /^(left|center|right|justify)$/i.test(source.getAttribute('align') || '')) {
node.style.textAlign = source.getAttribute('align')
}
if (/^(P|DIV|H[1-6]|BLOCKQUOTE|PRE|UL|OL)$/.test(tag)) {
node.style.margin = '0 0 8px'
node.style.lineHeight = '1.8'
}
if (/^H[1-6]$/.test(tag)) node.style.fontSize = '16px'
if (tag === 'UL' || tag === 'OL') {
node.style.paddingLeft = '24px'
node.style.listStyleType = tag === 'UL' ? 'disc' : 'decimal'
if (tag === 'OL' && /^-?\d{1,6}$/.test(source.getAttribute('start') || '')) {
node.setAttribute('start', source.getAttribute('start'))
}
}
if (tag === 'PRE') {
node.style.whiteSpace = 'pre-wrap'
node.style.overflowWrap = 'anywhere'
}
if (tag === 'IMG') {
// 图片沿用原有地址范围;保留正文中的位置,点击后交给现有预览弹框和历史栈。
const url = (source.getAttribute('src') || '').trim()
if (!/^(https?:\/\/|\/(?![\/\\])|data:image\/(?:png|jpeg|gif|webp);base64,)/i.test(url)) return
node.setAttribute('src', url)
node.setAttribute('alt', source.getAttribute('alt') || '活动方案配图')
node.style.cssText = 'display:block;max-width:100%;height:auto;margin:8px 0;cursor:pointer'
}
if (tag === 'TABLE') {
// 每张表单独横向滚动,保留行列关系;不让宽表撑开整个审核页面。
const scroll = content.createElement('div')
scroll.style.cssText = 'max-width:100%;overflow-x:auto;margin:8px 0;white-space:normal'
parent.appendChild(scroll)
parent = scroll
node.style.width = '100%'
node.style.borderCollapse = 'collapse'
node.style.tableLayout = 'auto'
}
if (tag === 'TD' || tag === 'TH') {
node.style.border = '1px solid #dcdfe6'
node.style.padding = '6px 8px'
node.style.minWidth = '72px'
node.style.overflowWrap = 'anywhere'
// 合并单元格只接受浏览器支持范围内的整数,保留预算表的表头和分组结构。
;['rowspan', 'colspan'].forEach((name) => {
const value = source.getAttribute(name)
if (/^\d{1,4}$/.test(value || '') && Number(value) >= 1 && Number(value) <= 1000) {
node.setAttribute(name, value)
}
})
}
Array.from(source.childNodes).forEach((child) => appendContent(child, node))
parent.appendChild(node)
}
if (!content.body.children.length) {
// 旧手机申请可能保存纯文字;保留用户输入的换行,但不把它作为 HTML 再解析。
const plain = content.createElement('div')
plain.style.whiteSpace = 'pre-wrap'
plain.textContent = content.body.textContent.trim()
container.appendChild(plain)
} else {
Array.from(content.body.childNodes).forEach((node) => appendContent(node, container))
}
return container.textContent.trim() || container.querySelector('img') ? container.innerHTML : ''
},
audits() {
const data = this.data
@@ -125,6 +200,11 @@ const reimbursementInfo = {
}
},
methods: {
/** event 为正文点击事件;仅图片触发 preview,参数 imageUrl 交由父页面现有弹框处理,无返回值。 */
previewActivityImage(event) {
const target = event.target
if (target && target.tagName === 'IMG') this.$emit('preview', {imageUrl: target.getAttribute('src')})
},
/** 历史接口可能返回 JSON 字符串或数组;空值、异常数据均不阻断整页展示。 */
asList(value) {
if (Array.isArray(value)) return value
@@ -48,7 +48,7 @@ layout("/mobile/platform.html"){
</div>
<script>
<!--#include('../common/siteInfo.js'){}#-->
<!--#include('/mobile/site/common/siteInfo.js'){}#-->
<!--#include('apply.js'){}#-->
new Vue({
el: "#app",
@@ -64,8 +64,8 @@ layout("/mobile/platform.html"){
</div>
<script>
<!--#include('../apply/apply.js'){}#-->
<!--#include('../common/info.js'){}#-->
<!--#include('/mobile/site/apply/apply.js'){}#-->
<!--#include('/mobile/site/common/info.js'){}#-->
new Vue({
el: "#app",
components: {
@@ -214,7 +214,7 @@ layout("/mobile/platform.html"){
</van-popup>
</div>
<script>
<!--#include('../common/info.js'){}#-->
<!--#include('/mobile/site/nursing/common/info.js'){}#-->
const vue = new Vue({
el: '#app',
@@ -193,7 +193,7 @@ layout("/mobile/platform.html"){
</van-popup>
</div>
<script>
<!--#include('../common/info.js'){}#-->
<!--#include('/mobile/site/nursing/common/info.js'){}#-->
const vue = new Vue({
el: '#app',
@@ -82,7 +82,7 @@ layout("/mobile/platform.html"){
</div>
<script>
<!--#include('../common/info.js'){}#-->
<!--#include('/mobile/site/common/info.js'){}#-->
new Vue({
el: "#app",
components: {
@@ -17,7 +17,7 @@
<div id="app" v-cloak>
<van-nav-bar title="签字"></van-nav-bar>
<van-notice-bar v-if="subDis" text="缺少签字参数,请重新扫码。"></van-notice-bar>
<mobile-sign :value="sign" :save-to-profile="false" :popup-visible="showSignaturePanel"
<mobile-sign :value="sign" :is_value_base64="isValueBase64" :popup-visible="showSignaturePanel"
:disabled="subDis || formLoading" @input="save" @open="openSignature" @close="closeSignature"></mobile-sign>
</div>
<script>
@@ -27,7 +27,7 @@
'mobile-sign': httpVueLoader("/components/sign/mobileSign.vue?v=" + new Date().getTime())
},
data() {
return {prefix: '', id: '', sign: '', subDis: false, formLoading: false, showSignaturePanel: false}
return {prefix: '', id: '', sign: '', isValueBase64: true, subDis: false, formLoading: false, showSignaturePanel: false}
},
methods: {
openSignature() {
@@ -37,8 +37,8 @@
if (!this.formLoading) this.$options.popupSession.close()
},
/**
* signature 为公共签字板回传的 PNG Base64;prefix/id 来自二维码,关联 PC 端轮询。
* 保留 is_value_base64=false 的文件保存方式;接口返回 {code, msg},code=0 表示成功。
* signature 为组件已保存的个人签名:true 为 Base64,false 为文件路径。
* prefix/id 和 is_value_base64 来自二维码;接口返回 {code,msg,data},code=0 表示发布给 PC 成功。
*/
save(signature) {
if (this.formLoading || this.subDis) return
@@ -48,7 +48,7 @@
prefix: this.prefix,
id: this.id,
base64: signature,
is_value_base64: false
is_value_base64: this.isValueBase64
})
.then((res) => {
if (res.code === 0) {
@@ -70,7 +70,10 @@
const query = new URLSearchParams(window.location.search)
this.id = query.get('id') || ''
this.prefix = query.get('prefix') || ''
this.subDis = !this.id || !this.prefix
// 缺省沿用 PC 默认 true;显式 false 使用文件签名,异常参数不默默切换类型。
const signType = query.get('is_value_base64')
this.isValueBase64 = signType !== 'false'
this.subDis = !this.id || !this.prefix || (signType !== null && signType !== 'true' && signType !== 'false')
},
beforeDestroy() {
const session = this.$options.popupSession
@@ -83,7 +83,7 @@ layout("/layouts/platform.html"){
</guava>
</div>
<script>
<!--#include('../manage/info.js'){}#-->
<!--#include('/platform/site/manage/info.js'){}#-->
<!--#include('apply.js'){}#-->
const vue = new Vue({
el: "#app",
@@ -110,8 +110,8 @@ layout("/layouts/platform.html"){
</div>
<script>
<!--#include('../common/applyInfo.js'){}#-->
<!--#include('../apply/apply.js'){}#-->
<!--#include('/platform/site/common/applyInfo.js'){}#-->
<!--#include('/platform/site/apply/apply.js'){}#-->
new Vue({
el: "#app",
mixins: [initTableMixins],
@@ -89,8 +89,8 @@ layout("/layouts/platform.html"){
</guava>
</div>
<script>
<!--#include("../common/info.js"){}#-->
<!--#include("../common/commonQuery.js"){}#-->
<!--#include("/platform/site/nursing/common/info.js"){}#-->
<!--#include("/platform/site/nursing/common/commonQuery.js"){}#-->
new Vue({
el: '#app',
@@ -49,8 +49,8 @@ layout("/layouts/platform.html"){
</guava>
</div>
<script>
<!--#include("../common/info.js"){}#-->
<!--#include("../common/commonQuery.js"){}#-->
<!--#include("/platform/site/nursing/common/info.js"){}#-->
<!--#include("/platform/site/nursing/common/commonQuery.js"){}#-->
new Vue({
el: '#app',
@@ -54,8 +54,8 @@ layout("/layouts/platform.html"){
</guava>
</div>
<script>
<!--#include("../common/info.js"){}#-->
<!--#include("../common/commonQuery.js"){}#-->
<!--#include("/platform/site/nursing/common/info.js"){}#-->
<!--#include("/platform/site/nursing/common/commonQuery.js"){}#-->
new Vue({
el: '#app',
@@ -82,7 +82,7 @@ layout("/layouts/platform.html"){
</div>
<script>
<!--#include('../common/applyInfo.js'){}#-->
<!--#include('/platform/site/common/applyInfo.js'){}#-->
new Vue({
el: "#app",
mixins: [initTableMixins],
@@ -124,7 +124,7 @@ layout("/layouts/platform.html"){
</div>
<script>
<!--#include('../common/applyInfo.js'){}#-->
<!--#include('/platform/site/common/applyInfo.js'){}#-->
new Vue({
el: "#app",
mixins: [initTableMixins],
@@ -114,7 +114,7 @@ layout("/layouts/platform.html"){
prop="rylx"></el-table-column>
<el-table-column width="100" align="center" header-align="center" label="在职状态"
prop="userstate"></el-table-column>-->
<el-table-column align="center" header-align="center" label="所属单位" prop="unitname"
<el-table-column align="center" header-align="center" label="所属单位" prop="applicantunitname"
show-overflow-tooltip
sortable></el-table-column>
<el-table-column align="center" header-align="center" label="申请时间" prop="join_time"
@@ -141,7 +141,7 @@ layout("/layouts/platform.html"){
<template scope="{row}">
<el-button @click="openView(row)" size="mini" type="primary">查看</el-button>
<el-button v-if="pageForm.radioType == 3" @click="openAudit(row)" size="mini" type="primary">审核</el-button>
<el-button type="primary" size="mini" @click="doExportApply(row)">导出申请表</el-button>
<!-- <el-button type="primary" size="mini" @click="doExportApply(row)">导出申请表</el-button> -->
</template>
</el-table-column>
@@ -190,14 +190,14 @@ layout("/layouts/platform.html"){
</el-form-item>
<el-form-item prop="auditSign" label="签&emsp;&emsp;字">
<sign prefix="club" :qz.sync="formData.auditSign"></sign>
<sign :is_value_base64="false" prefix="club" :qz.sync="formData.auditSign"></sign>
</el-form-item>
</el-form>
<div style="float: right;margin: 20px 0">
<el-button @click="doReview(false)">拒绝</el-button>
<el-button type="primary" @click="doReview(true)">通过</el-button>
<el-button :loading="formLoading" @click="doReview(false)">拒绝</el-button>
<el-button :loading="formLoading" type="primary" @click="doReview(true)">通过</el-button>
</div>
</template>
@@ -219,6 +219,7 @@ layout("/layouts/platform.html"){
tabKey: '',
clubOptions: [],
tabloading: false,
formLoading: false,
formData: {
sign: ''
},
@@ -242,7 +243,7 @@ layout("/layouts/platform.html"){
}
},
components: {
'club-user-apply-info': httpVueLoader('/components/club/clubUserApplyInfo.vue?v=1.0.1'),
'club-user-apply-info': httpVueLoader('/components/club/clubUserApplyInfo.vue?v=1.0.2'),
'dict-select': httpVueLoader('/components/plugins/DictSelect.vue?v=1.0.0'),
},
methods: {
@@ -273,18 +274,39 @@ layout("/layouts/platform.html"){
}
this.$refs.guava.edit()
},
async doReview(flag) {
const valid = await this.$refs.form.validate()
if (!valid) return
this.formData.flag = flag
const resp = await $.post(base + "/platform/sys/club/sh/check", this.formData)
if(resp.code === 0) {
this.doSearch()
this.$refs.guava.index()
this.$message({message: resp.msg, type: 'success'})
} else {
this.$message({message: resp.msg, type: 'warning'})
}
doReview(flag) {
if (this.formLoading) return
this.$refs.form.validate((valid) => {
if (!valid || this.formLoading) return
this.$set(this, 'formLoading', true)
this.$set(this.formData, 'flag', flag)
// 公共签字组件可能读取当前用户的个人 Base64 签名,先上传为路径再提交审核。
// uploadSignData 的 data 参数为 PNG Base64,成功响应的 data 为文件路径。
const sign = this.formData.auditSign
const signRequest = sign.indexOf('data:image/png;base64,') === 0
? $.post('/signature/uploadSignData', {data: sign}).then((res) => {
return res.code === 0 && res.data
? $.Deferred().resolve(res.data).promise()
: $.Deferred().reject(res.msg || '签字上传失败').promise()
})
: $.Deferred().resolve(sign).promise()
signRequest.then((path) => {
this.$set(this.formData, 'auditSign', path)
return $.post(base + '/platform/sys/club/sh/check', this.formData)
}).then((resp) => {
if (resp.code === 0) {
this.doSearch()
this.$refs.guava.index()
this.$message({message: resp.msg, type: 'success'})
} else {
this.$message({message: resp.msg, type: 'warning'})
}
}).fail((error) => {
this.$message.error(typeof error === 'string' ? error : '审核提交失败,请重试')
}).always(() => {
this.$set(this, 'formLoading', false)
})
})
},
doSearch() {
this.pageForm.pageNumber = 1
@@ -131,7 +131,7 @@ layout("/layouts/platform.html"){
</div>
<script>
<!--#include('./common/info.js'){}#-->
<!--#include('/platform/zgfw/difficulty/common/info.js'){}#-->
const vue = new Vue({
el: '#app',
@@ -184,7 +184,7 @@ layout("/layouts/platform.html"){
<script>
<!--#include('./common/info.js'){}#-->
<!--#include('/platform/zgfw/difficulty/common/info.js'){}#-->
const vue = new Vue({
el: '#app',
@@ -159,7 +159,7 @@ layout("/layouts/platform.html"){
</div>
<script>
<!--#include('./common/info.js'){}#-->
<!--#include('/platform/zgfw/difficulty/common/info.js'){}#-->
const vue = new Vue({
el: '#app',
@@ -194,7 +194,7 @@ layout("/layouts/platform.html"){
<script>
<!--#include('./common/info.js'){}#-->
<!--#include('/platform/zgfw/difficulty/common/info.js'){}#-->
const vue = new Vue({
el: '#app',
@@ -173,7 +173,7 @@ layout("/layouts/platform.html"){
<script>
<!--#include('./common/info.js'){}#-->
<!--#include('/platform/zgfw/difficulty/common/info.js'){}#-->
const vue = new Vue({
el: '#app',