手机端协会入会增加审核、双端待办优化,协会入会申请增加双端待办,并给会长、秘书发送企业微信消息,这两个角色同时拥有审核权限

This commit is contained in:
2026-10-21 16:36:50 +08:00
34 changed files with 3437 additions and 1045 deletions
@@ -1,74 +1,50 @@
package io.v.nutz.zhgh.jf.controller.activitybx;
import io.v.nutz.base.annontation.ViReturn;
import io.v.nutz.base.model.Goods;
import io.v.nutz.base.service.BaseService;
import io.v.nutz.base.utils.DateUtil;
import io.v.nutz.base.utils.MsgApi;
import io.v.nutz.base.utils.Roles;
import io.v.nutz.base.utils.Vi;
import io.v.nutz.sys.models.Sys_signature;
import io.v.nutz.sys.models.Sys_user;
import io.v.nutz.sys.models.Sys_user_role;
import io.v.nutz.sys.services.SysSignatureService;
import io.v.nutz.web.commons.utils.ShiroUtil;
import io.v.nutz.zhgh.jf.handle.funds.FundsToHandler;
import io.v.nutz.zhgh.jf.model.ActivityBx;
import io.v.nutz.zhgh.jf.service.ActivityBxService;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json;
import org.nutz.lang.Lang;
import org.nutz.lang.Strings;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import org.nutz.json.Json;
import java.util.List;
/**
* @author zhf
* @date 2020/11/14 14:55
* @description 校工会会计核报
*/
/** 校工会负责人审核:PC 与手机共用业务接口,审核逻辑统一在 Service 中处理。 */
@IocBean
@Ok("json")
@At("/platform/jf/activity_bx/accounting")
public class AccountingCon {
@Inject
private Vi vi;
@Inject
private ActivityBxService activityBxService;
@Inject
private BaseService baseService;
@Inject
private MsgApi msgApi;
@Inject
private SysSignatureService sysSignatureService;
@At("")
@Ok("beetl:/platform/jf/activitybx/accounting.html")
@RequiresPermissions("sys.jf.activity_bx.accounting")
public void index() {
}
/** 手机审核入口,使用独立页面目录及现有 PC 权限。 */
@At
@Ok("beetl:/mobile/jf/activitybx/accounting/index.html")
@RequiresPermissions("sys.jf.activity_bx.accounting")
public void mobile() {
}
/**
* year 为年度,isAudit 为1全部/2已审核/3未审核;unitId、unionId 为筛选组织ID;
* searchName 为 username/loginname,searchKeyword 为姓名或工号(空值不筛选)。
* pageNumber 从1开始,pageSize 为1至100;pageOrderName 为 bxr_unionname/activity_type/absName/apply_type/apply_time/state_id,
* pageOrderBy 为 asc/desc(兼容 ascending/descending),未指定有效排序时按状态、申请时间排序。
* 返回分页对象,list 为申请数组,totalCount 为符合当前权限及筛选的总数。
*/
@At
@ViReturn
@RequiresPermissions("sys.jf.activity_bx.accounting")
public Object pageData(@Param(value = "year", required = false) Integer year,
@Param(value = "isAudit", required = false) Integer isAudit,
@Param(value = "unitId", required = false) String unitId,
@@ -78,89 +54,44 @@ public class AccountingCon {
@Param("pageNumber") int pageNumber, @Param("pageSize") int pageSize,
@Param(value = "pageOrderName", required = false) String pageOrderName,
@Param(value = "pageOrderBy", required = false) String pageOrderBy) {
Sql sql = Sqls.create("""
SELECT
act.*,
u.loginname bxr_loginname,
u.unionname bxr_unionname,
s.state_name,
s.state_color,
abs.`name` absName
FROM
`activity_bx` act
LEFT JOIN `user` u ON act.user_id = u.id
LEFT JOIN state s ON act.state_id = s.state_id
LEFT JOIN activity_basic_settings abs ON abs.`code` = act.projectJfCode
$condition
""");
Cnd cnd = Cnd.NEW();
if (isAudit == 1) {
cnd.and("act.state_id", ">=", 1020);
} else {
cnd.and("act.state_id", isAudit == 2 ? ">" : "=", 1020);
}
if (ShiroUtil.hasRole("xghng")) {
cnd.and("act.projectTypeCode", "=", 50002);
}
Vi.cndPlus(cnd, "act.year", "=", year);
if (Vi.isNotBlank(searchName, searchKeyword)) {
cnd.where().andLike(searchName, searchKeyword);
}
if (Vi.isNotBlank(pageOrderName, pageOrderBy)) {
cnd.orderBy(pageOrderName, pageOrderBy);
} else {
cnd.asc("act.state_id").desc("act.apply_time");
}
Vi.cndPlus(cnd, "u.unitid", "=", unitId);
Vi.cndPlus(cnd, "u.unionid", "=", unionId);
sql.setCondition(cnd);
return activityBxService.listPageMap(pageNumber, pageSize, sql);
return activityBxService.reviewPage("accounting", new NutMap().addv("year", year).addv("isAudit", isAudit)
.addv("unitId", unitId).addv("unionId", unionId).addv("searchName", searchName)
.addv("searchKeyword", searchKeyword).addv("pageNumber", pageNumber).addv("pageSize", pageSize)
.addv("pageOrderName", pageOrderName).addv("pageOrderBy", pageOrderBy));
}
/** id 为申请主键;返回详情 Map,canReview 表示该申请是否仍处于当前待审节点。 */
@At
@ViReturn
public Object getGoods(String activity_id) {
return baseService.dao().query(Goods.class, Cnd.where("reid", "=", activity_id));
@RequiresPermissions("sys.jf.activity_bx.accounting")
public Object findOne(@Param("id") String id) {
if (Strings.isBlank(id)) throw new IllegalArgumentException("请选择活动申请");
return activityBxService.reviewDetail("accounting", id);
}
/**
* data 为 JSON 对象字符串:id 为申请ID,idea 为1至500字意见,sign 为图片数据或签名路径,flag 为布尔值(true通过/false拒绝)。
* 返回统一结果对象,code=0 为成功,msg 为提示;失败不推进审核状态。
*/
@At
@ViReturn
@Aop(TransAop.READ_COMMITTED)
public Object doReview(String data) {
NutMap nutMap = Json.fromJson(NutMap.class, data);
ActivityBx aid = activityBxService.fetch(nutMap.getString("id"));
aid.setState_id(nutMap.getBoolean("flag") ? "1040" : "1025");
aid.setAccounting_name(ShiroUtil.getPrincipalProperty("id").toString());
aid.setAccounting_time(DateUtil.getDate());
aid.setAccounting_idea(nutMap.getString("idea"));
if (Strings.isNotBlank(nutMap.getString("sign"))) {
aid.setAccounting(sysSignatureService.insert(new Sys_signature(nutMap.getString("sign"))).getId());
}
activityBxService.update(aid);
String nodeName = "";
if (nutMap.getBoolean("flag")) {
nodeName = "校工会主席审核";
Sys_user_role sys_user_role = activityBxService.dao().fetch(Sys_user_role.class, Cnd.where("roleId", "=", Roles.GH_WYH_ZX));
if (Lang.isNotEmpty(sys_user_role)) {
Sys_user user = activityBxService.dao().fetch(Sys_user.class, sys_user_role.getUserId());
msgApi.sendWxMsg("活动申报", "", "text", aid.getUser_name() + "申报了一条活动【" + aid.getActivity_name() + "】,请前往智慧工会进行审核。", List.of(user.getLoginname()), null);
FundsToHandler.CREATE_SCHOOL_TASK.exec(aid.getId(), null, List.of(user.getLoginname()));
}
} else {
nodeName = "校工会负责人拒绝";
Sys_user user = activityBxService.dao().fetch(Sys_user.class, aid.getUser_id());
msgApi.sendWxMsg("活动申报", "", "text", "您申报的活动【" + aid.getActivity_name() + "】被校工会负责人拒绝,审核意见:" + aid.getAccounting_idea(), List.of(user.getLoginname()), null);
FundsToHandler.REFUSE_TASK.exec(aid.getId(), new NutMap().addv("option", nodeName), null);
}
FundsToHandler.COMPLETE_SCHOOL_FZR_TASK.exec(aid.getId(), new NutMap().addv("option", nodeName), null);
@RequiresPermissions("sys.jf.activity_bx.accounting")
public Object doReview(@Param("data") String data) {
if (Strings.isBlank(data)) throw new IllegalArgumentException("请填写审核信息");
NutMap form = Json.fromJson(NutMap.class, data);
if (form == null) throw new IllegalArgumentException("审核参数格式错误");
activityBxService.reviewActivity("accounting", form);
return null;
}
/** activity_id 为原活动申请ID;返回物品明细数组,保留原 PC 查询地址并检查数据范围。 */
@At
@ViReturn
@RequiresPermissions("sys.jf.activity_bx.accounting")
public Object getGoods(@Param("activity_id") String activity_id) {
return activityBxService.reviewGoods("accounting", activity_id);
}
}
@@ -1,21 +1,9 @@
package io.v.nutz.zhgh.jf.controller.activitybx;
import io.v.nutz.base.annontation.ViReturn;
import io.v.nutz.base.utils.DateUtil;
import io.v.nutz.base.utils.MsgApi;
import io.v.nutz.base.utils.Vi;
import io.v.nutz.sys.models.Sys_signature;
import io.v.nutz.sys.models.Sys_user;
import io.v.nutz.sys.services.SysSignatureService;
import io.v.nutz.web.commons.utils.ShiroUtil;
import io.v.nutz.zhgh.jf.handle.funds.FundsToHandler;
import io.v.nutz.zhgh.jf.model.ActivityBx;
import io.v.nutz.zhgh.jf.service.ActivityBxService;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
@@ -25,41 +13,37 @@ import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import java.util.List;
/**
* @author zhf
* @date 2020/12/6 15:23
* @description 协会负责人审核
*/
/** 协会活动申报审核:PC、手机共用查询和审核入口。 */
@IocBean
@Ok("json")
@At("/platform/jf/activity_bx/association")
@RequiresPermissions("sys.jf.activity_bx.association")
public class AssociationCon {
@Inject
private Vi vi;
@Inject
private ActivityBxService activityBxService;
@Inject
private SysSignatureService sysSignatureService;
@Inject
private MsgApi msgApi;
/** PC 审核页面,沿用原访问地址。 */
@At("")
@Ok("beetl:/platform/jf/activitybx/association.html")
@RequiresPermissions("sys.jf.activity_bx.association")
public void index() {
}
/** 手机审核页面,使用独立目录,不依赖旧的 declare 页面。 */
@At
@Ok("beetl:/mobile/jf/activitybx/association/index.html")
public void mobile() {
}
/**
* year 为年度,isAudit 为 1全部/2已审核/3未审核(默认),clubId 为协会ID;
* unitId、unionId、searchName、searchKeyword 保留 PC 筛选,pageNumber/pageSize 为分页,
* pageOrderName/pageOrderBy 为允许的排序字段和方向。返回 Pagination,list 为申请,totalCount 为总数。
*/
@At
@ViReturn
public Object pageData(@Param(value = "year", required = false) Integer year,
@Param(value = "isAudit", required = false) Integer isAudit,
@Param(value = "clubId", required = false) String clubId,
@Param(value = "unitId", required = false) String unitId,
@Param(value = "unionId", required = false) String unionId,
@Param(value = "searchName", required = false) String searchName,
@@ -67,82 +51,46 @@ public class AssociationCon {
@Param("pageNumber") int pageNumber, @Param("pageSize") int pageSize,
@Param(value = "pageOrderName", required = false) String pageOrderName,
@Param(value = "pageOrderBy", required = false) String pageOrderBy) {
Sql sql = Sqls.create("""
SELECT
act.*,
u.loginname bxr_loginname,
u.unionname bxr_unionname,
s.state_name,
s.state_color,
abs.`name` absName
FROM
`activity_bx` act
LEFT JOIN `user` u ON act.user_id = u.id
LEFT JOIN sys_unit un ON u.unitid = un.id
LEFT JOIN state s ON act.state_id = s.state_id
LEFT JOIN activity_basic_settings abs ON abs.`code` = act.projectJfCode
$condition
""");
Cnd cnd = Cnd.NEW();
if (isAudit == 1) {
cnd.and("act.state_id", ">=", 1000);
} else {
cnd.and("act.state_id", isAudit == 2 ? ">" : "=", 1000);
}
cnd.and("act.activity_type", "=", 40003);
if (!ShiroUtil.hasAnyRoles("sysadmin")) {
cnd.and("act.clubId", "in", vi.getMangeClubStr());
}
Vi.cndPlus(cnd, "act.year", "=", year);
if (Vi.isNotBlank(searchName, searchKeyword)) {
cnd.where().andLike(searchName, searchKeyword);
}
if (Vi.isNotBlank(pageOrderName, pageOrderBy)) {
cnd.orderBy(pageOrderName, pageOrderBy);
} else {
cnd.asc("act.state_id").desc("act.apply_time");
}
Vi.cndPlus(cnd, "u.unitid", "=", unitId);
Vi.cndPlus(cnd, "u.unionid", "=", unionId);
sql.setCondition(cnd);
return activityBxService.listPageMap(pageNumber, pageSize, sql);
return activityBxService.associationPage(new NutMap().addv("year", year).addv("isAudit", isAudit)
.addv("clubId", clubId).addv("unitId", unitId).addv("unionId", unionId)
.addv("searchName", searchName).addv("searchKeyword", searchKeyword)
.addv("pageNumber", pageNumber).addv("pageSize", pageSize)
.addv("pageOrderName", pageOrderName).addv("pageOrderBy", pageOrderBy));
}
/** 无参数;返回当前用户可管理的协会列表,每项 id 为筛选值、name 为显示名称。 */
@At
@ViReturn
public Object clubs() {
return activityBxService.associationClubs();
}
/** id 为活动申报主键;返回申请详情 Map,包含预算、附件、审核记录和 canReview 可审核标识。 */
@At
@ViReturn
public Object findOne(@Param("id") String id) {
if (Strings.isBlank(id)) {
throw new IllegalArgumentException("请选择活动申请");
}
return activityBxService.associationDetail(id);
}
/**
* id 为申报主键,idea 为1至500字意见,sign 为签字图片数据或现有签字路径,flag 为true通过/false拒绝。
* 审核人和时间由服务端确定;返回统一结果,code=0 表示成功,msg 为结果提示,data 为空。
*/
@At
@ViReturn
@Aop(TransAop.READ_COMMITTED)
public Object doReview(@Param(value = "id", required = false) String id,
@Param(value = "idea", required = false) String idea,
@Param(value = "sign", required = false) String sign,
@Param(value = "flag", required = false) Boolean flag) {
ActivityBx aid = activityBxService.fetch(id);
aid.setState_id(flag ? "1020" : "1005");
aid.setAssociation_principal_id(ShiroUtil.getPrincipalProperty("id").toString());
aid.setAssociation_principal_time(DateUtil.getDate());
aid.setAssociation_principal_idea(idea);
if (Strings.isNotBlank(sign)) {
aid.setAssociation_principalSign_id(sysSignatureService.insert(new Sys_signature(sign)).getId());
public Object doReview(@Param("id") String id, @Param("idea") String idea,
@Param("sign") String sign, @Param("flag") Boolean flag) {
if (Strings.isBlank(id) || flag == null) {
throw new IllegalArgumentException("请选择活动申请和审核结果");
}
activityBxService.update(aid);
String nodeName = "";
if (flag) {
msgApi.sendWxMsg("活动申报", "", "text", aid.getUser_name() + "申报了一条活动【" + aid.getActivity_name() + "】,请前往智慧工会进行审核。", List.of("1019911404"), null);
FundsToHandler.CREATE_SCHOOL_FZR_TASK.exec(id, new NutMap().addv("userId", aid.getUser_id()), List.of("1019911404"));
nodeName = "校工会负责人审核";
} else {
Sys_user user = activityBxService.dao().fetch(Sys_user.class, aid.getUser_id());
msgApi.sendWxMsg("活动申报", "", "text", "您申报的活动【" + aid.getActivity_name() + "】被协会拒绝,审核意见:" + aid.getAssociation_principal_idea(), List.of(user.getLoginname()), null);
nodeName = "协会审核拒绝";
FundsToHandler.REFUSE_TASK.exec(id, new NutMap().addv("option", nodeName), null);
if (Strings.isBlank(idea) || idea.trim().length() > 500 || Strings.isBlank(sign)) {
throw new IllegalArgumentException("请填写1至500字审核意见并签字");
}
FundsToHandler.COMPLETE_CLUB_TASK.exec(id, new NutMap().addv("option", nodeName), null);
activityBxService.reviewAssociation(id, idea.trim(), sign, flag);
return null;
}
}
@@ -1,67 +1,49 @@
package io.v.nutz.zhgh.jf.controller.activitybx;
import io.v.nutz.base.annontation.ViReturn;
import io.v.nutz.base.utils.DateUtil;
import io.v.nutz.base.utils.MsgApi;
import io.v.nutz.base.utils.Roles;
import io.v.nutz.base.utils.Vi;
import io.v.nutz.sys.models.Sys_signature;
import io.v.nutz.sys.models.Sys_user;
import io.v.nutz.sys.models.Sys_user_role;
import io.v.nutz.sys.services.SysSignatureService;
import io.v.nutz.web.commons.utils.ShiroUtil;
import io.v.nutz.zhgh.jf.handle.funds.FundsToHandler;
import io.v.nutz.zhgh.jf.model.ActivityBx;
import io.v.nutz.zhgh.jf.service.ActivityBxService;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Lang;
import org.nutz.lang.Strings;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import java.util.List;
/**
* @author zhf
* @date 2020/11/14 10:17
* @description 基层工会核批
*/
/** 基层工会主席审核:PC 与手机共用业务接口,审核逻辑统一在 Service 中处理。 */
@IocBean
@Ok("json")
@At("/platform/jf/activity_bx/division")
public class DivisionCon {
@Inject
private Vi vi;
@Inject
private MsgApi msgApi;
@Inject
private ActivityBxService activityBxService;
@Inject
private SysSignatureService sysSignatureService;
@At("")
@Ok("beetl:/platform/jf/activitybx/division.html")
@RequiresPermissions("sys.jf.activity_bx.division")
public void index() {
}
/** 手机审核入口,使用独立页面目录及现有 PC 权限。 */
@At
@Ok("beetl:/mobile/jf/activitybx/division/index.html")
@RequiresPermissions("sys.jf.activity_bx.division")
public void mobile() {
}
/**
* year 为年度,isAudit 为1全部/2已审核/3未审核;unitId、unionId 为筛选组织ID;
* searchName 为 username/loginname,searchKeyword 为姓名或工号(空值不筛选)。
* pageNumber 从1开始,pageSize 为1至100;pageOrderName 为 bxr_unionname/activity_type/absName/apply_type/apply_time/state_id,
* pageOrderBy 为 asc/desc(兼容 ascending/descending),未指定有效排序时按状态、申请时间排序。
* 返回分页对象,list 为申请数组,totalCount 为符合当前权限及筛选的总数。
*/
@At
@ViReturn
@RequiresPermissions("sys.jf.activity_bx.division")
public Object pageData(@Param(value = "year", required = false) Integer year,
@Param(value = "isAudit", required = false) Integer isAudit,
@Param(value = "unitId", required = false) String unitId,
@@ -71,87 +53,31 @@ public class DivisionCon {
@Param("pageNumber") int pageNumber, @Param("pageSize") int pageSize,
@Param(value = "pageOrderName", required = false) String pageOrderName,
@Param(value = "pageOrderBy", required = false) String pageOrderBy) {
Sql sql = Sqls.create("""
SELECT
act.*,
u.loginname bxr_loginname,
u.unionname bxr_unionname,
s.state_name,
s.state_color,
abs.`name` absName
FROM
`activity_bx` act
LEFT JOIN `user` u ON act.user_id = u.id
LEFT JOIN sys_unit su ON su.id = u.unitid
LEFT JOIN sys_union un ON su.unionid = un.id
LEFT JOIN state s ON act.state_id = s.state_id
LEFT JOIN activity_basic_settings abs ON abs.`code` = act.projectJfCode
$condition
""");
Cnd cnd = Cnd.NEW();
if (isAudit == 1) {
cnd.and("act.state_id", ">=", 1010);
} else {
cnd.and("act.state_id", isAudit == 2 ? ">" : "=", 1010);
}
if (!ShiroUtil.hasAnyRoles("sysadmin")) {
if (ShiroUtil.hasAnyRoles(new String[]{"H04", "gh01", "gh02"})) {
cnd.and("un.id", "=", vi.getUnionId());
}
}
Vi.cndPlus(cnd, "act.year", "=", year);
if (Vi.isNotBlank(searchName, searchKeyword)) {
cnd.where().andLike(searchName, searchKeyword);
}
if (Vi.isNotBlank(pageOrderName, pageOrderBy)) {
cnd.orderBy(pageOrderName, pageOrderBy);
} else {
cnd.asc("act.state_id").desc("act.apply_time");
}
Vi.cndPlus(cnd, "u.unitid", "=", unitId);
Vi.cndPlus(cnd, "u.unionid", "=", unionId);
sql.setCondition(cnd);
return activityBxService.listPageMap(pageNumber, pageSize, sql);
return activityBxService.reviewPage("division", new NutMap().addv("year", year).addv("isAudit", isAudit)
.addv("unitId", unitId).addv("unionId", unionId).addv("searchName", searchName)
.addv("searchKeyword", searchKeyword).addv("pageNumber", pageNumber).addv("pageSize", pageSize)
.addv("pageOrderName", pageOrderName).addv("pageOrderBy", pageOrderBy));
}
/** id 为申请主键;返回详情 Map,canReview 表示该申请是否仍处于当前待审节点。 */
@At
@ViReturn
@RequiresPermissions("sys.jf.activity_bx.division")
public Object findOne(@Param("id") String id) {
if (Strings.isBlank(id)) throw new IllegalArgumentException("请选择活动申请");
return activityBxService.reviewDetail("division", id);
}
/** id 为申请ID,idea 为1至500字意见,sign 为图片数据或签名路径,flag 为true通过/false拒绝;返回统一结果,code=0 为成功,msg 为提示。 */
@At
@ViReturn
@Aop(TransAop.READ_COMMITTED)
public Object doReview(@Param(value = "id", required = false) String id,
@Param(value = "idea", required = false) String idea,
@Param(value = "sign", required = false) String sign,
@Param(value = "flag", required = false) Boolean flag) {
ActivityBx aid = activityBxService.fetch(id);
aid.setState_id(flag ? "1020" : "1015");
aid.setDivision_chairman_name(ShiroUtil.getPrincipalProperty("id").toString());
aid.setDivision_chairman_time(DateUtil.getDate());
aid.setDivision_chairman_idea(idea);
if (Strings.isNotBlank(sign)) {
aid.setDivision_chairman(sysSignatureService.insert(new Sys_signature(sign)).getId());
}
activityBxService.update(aid);
String nodeName = "";
if (flag) {
nodeName = "校工会负责人审核";
// Sys_user_role sys_user_role = activityBxService.dao().fetch(Sys_user_role.class, Cnd.where("roleId", "=", Roles.GH_WYH_ZX));
// if (Lang.isNotEmpty(sys_user_role)) {
// Sys_user user = activityBxService.dao().fetch(Sys_user.class, sys_user_role.getUserId());
Sys_user user = activityBxService.dao().fetch(Sys_user.class, Cnd.where("loginname","=","1019911404"));
msgApi.sendWxMsg("活动申报", "", "text", aid.getUser_name() + "申报的【" + aid.getActivity_name() + "】,请前往智慧工会进行审核。", List.of(user.getLoginname()), null);
FundsToHandler.CREATE_SCHOOL_FZR_TASK.exec(id, null, List.of(user.getLoginname()));
// }
} else {
nodeName = "基层工会主席拒绝";
Sys_user user = activityBxService.dao().fetch(Sys_user.class, aid.getUser_id());
msgApi.sendWxMsg("活动申报", "", "text", "您申报的活动【" + aid.getActivity_name() + "】被基层工会主席拒绝,审核意见:" + aid.getDivision_chairman_idea(), List.of(user.getLoginname()), null);
FundsToHandler.REFUSE_TASK.exec(id, new NutMap().addv("option", nodeName), null);
}
FundsToHandler.COMPLETE_UNION_TASK.exec(id, new NutMap().addv("option", nodeName), null);
@RequiresPermissions("sys.jf.activity_bx.division")
public Object doReview(@Param("id") String id, @Param("idea") String idea,
@Param("sign") String sign, @Param("flag") Boolean flag) {
if (Strings.isBlank(id) || flag == null) throw new IllegalArgumentException("请选择申请和审核结果");
activityBxService.reviewActivity("division", new NutMap().addv("id", id).addv("idea", idea)
.addv("sign", sign).addv("flag", flag));
return null;
}
@@ -1,70 +1,50 @@
package io.v.nutz.zhgh.jf.controller.activitybx;
import io.v.nutz.base.annontation.ViReturn;
import io.v.nutz.base.model.Goods;
import io.v.nutz.base.utils.DateUtil;
import io.v.nutz.base.utils.MsgApi;
import io.v.nutz.base.utils.Vi;
import io.v.nutz.sys.models.Sys_signature;
import io.v.nutz.sys.models.Sys_user;
import io.v.nutz.sys.models.User;
import io.v.nutz.sys.services.SysSignatureService;
import io.v.nutz.web.commons.utils.ShiroUtil;
import io.v.nutz.zhgh.activity.models.ActivityTissue;
import io.v.nutz.zhgh.activity.services.ActivityTissueService;
import io.v.nutz.zhgh.jf.handle.funds.FundsToHandler;
import io.v.nutz.zhgh.jf.model.ActivityBx;
import io.v.nutz.zhgh.jf.service.ActivityBxService;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json;
import org.nutz.lang.Strings;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import org.nutz.json.Json;
import java.util.List;
/**
* @author zhf
* @date 2020/11/14 16:03
* @description 校工会主席审核
*/
/** 校工会主席审核:PC 与手机共用业务接口,审核逻辑统一在 Service 中处理。 */
@IocBean
@Ok("json")
@At("/platform/jf/activity_bx/union")
public class UnionCon {
@Inject
private Vi vi;
@Inject
private MsgApi msgApi;
@Inject
private ActivityBxService activityBxService;
@Inject
private SysSignatureService sysSignatureService;
@At("")
@Ok("beetl:/platform/jf/activitybx/union.html")
@RequiresPermissions("sys.jf.activity_bx.union")
public void index() {
}
/** 手机审核入口,使用独立页面目录及现有 PC 权限。 */
@At
@Ok("beetl:/mobile/jf/activitybx/union/index.html")
@RequiresPermissions("sys.jf.activity_bx.union")
public void mobile() {
}
/**
* year 为年度,isAudit 为1全部/2已审核/3未审核;unitId、unionId 为筛选组织ID;
* searchName 为 username/loginname,searchKeyword 为姓名或工号(空值不筛选)。
* pageNumber 从1开始,pageSize 为1至100;pageOrderName 为 bxr_unionname/activity_type/absName/apply_type/apply_time/state_id,
* pageOrderBy 为 asc/desc(兼容 ascending/descending),未指定有效排序时按状态、申请时间排序。
* 返回分页对象,list 为申请数组,totalCount 为符合当前权限及筛选的总数。
*/
@At
@ViReturn
@RequiresPermissions("sys.jf.activity_bx.union")
public Object pageData(@Param(value = "year", required = false) Integer year,
@Param(value = "isAudit", required = false) Integer isAudit,
@Param(value = "unitId", required = false) String unitId,
@@ -74,129 +54,35 @@ public class UnionCon {
@Param("pageNumber") int pageNumber, @Param("pageSize") int pageSize,
@Param(value = "pageOrderName", required = false) String pageOrderName,
@Param(value = "pageOrderBy", required = false) String pageOrderBy) {
Sql sql = Sqls.create("""
SELECT
act.*,
u.loginname bxr_loginname,
u.unionname bxr_unionname,
s.state_name,
s.state_color,
abs.`name` absName
FROM
`activity_bx` act
LEFT JOIN `user` u ON act.user_id = u.id
LEFT JOIN state s ON act.state_id = s.state_id
LEFT JOIN activity_basic_settings abs ON abs.`code` = act.projectJfCode
$condition
""");
Cnd cnd = Cnd.NEW();
if (isAudit == 1) {
cnd.and("act.state_id", ">=", 1040);
} else {
cnd.and("act.state_id", isAudit == 2 ? ">" : "=", 1040);
}
Vi.cndPlus(cnd, "act.year", "=", year);
if (Vi.isNotBlank(searchName, searchKeyword)) {
cnd.where().andLike(searchName, searchKeyword);
}
if (Vi.isNotBlank(pageOrderName, pageOrderBy)) {
cnd.orderBy(pageOrderName, pageOrderBy);
} else {
cnd.asc("act.state_id").desc("act.apply_time");
}
Vi.cndPlus(cnd, "u.unitid", "=", unitId);
Vi.cndPlus(cnd, "u.unionid", "=", unionId);
sql.setCondition(cnd);
return activityBxService.listPageMap(pageNumber, pageSize, sql);
return activityBxService.reviewPage("union", new NutMap().addv("year", year).addv("isAudit", isAudit)
.addv("unitId", unitId).addv("unionId", unionId).addv("searchName", searchName)
.addv("searchKeyword", searchKeyword).addv("pageNumber", pageNumber).addv("pageSize", pageSize)
.addv("pageOrderName", pageOrderName).addv("pageOrderBy", pageOrderBy));
}
/** id 为申请主键;返回详情 Map,canReview 表示该申请是否仍处于当前待审节点。 */
@At
@ViReturn
@RequiresPermissions("sys.jf.activity_bx.union")
public Object findOne(@Param("id") String id) {
if (Strings.isBlank(id)) throw new IllegalArgumentException("请选择活动申请");
return activityBxService.reviewDetail("union", id);
}
@Inject
private io.v.nutz.zhgh.jf.service.jfUseService jfUseService;
@Inject
private io.v.nutz.zhgh.jf.service.jfYjghService jfYjghService;
@Inject
private ActivityTissueService activityTissueService;
@Inject
private io.v.nutz.zhgh.jf.service.jfClubService jfClubService;
/**
* data 为 JSON 对象字符串:id 为申请ID,idea 为1至500字意见,sign 为图片数据或签名路径,flag 为布尔值(true通过/false拒绝)。
* activity_money 为可选的非负实际费用,不提交时保留原费用。
* 返回统一结果对象,code=0 为成功,msg 为提示;失败不推进审核状态。
*/
@At
@ViReturn
@Aop(TransAop.READ_COMMITTED)
public Object doReview(String data) {
NutMap nutMap = Json.fromJson(NutMap.class, data);
ActivityBx aid = activityBxService.fetch(nutMap.getString("id"));
aid.setState_id(nutMap.getBoolean("flag") ? "1060" : "1045");
aid.setUnion_chairman_name(ShiroUtil.getPrincipalProperty("id").toString());
aid.setUnion_chairman_time(DateUtil.getDate());
aid.setUnion_chairman_idea(nutMap.getString("idea"));
aid.setActivity_money(nutMap.getDouble("activity_money"));
List<Goods> goods = nutMap.getAsList("goods", Goods.class);
aid.setGoods(goods);
if (Strings.isNotBlank(nutMap.getString("sign"))) {
aid.setUnion_chairman(sysSignatureService.insert(new Sys_signature(nutMap.getString("sign"))).getId());
}
String nodeName = "";
if (nutMap.getBoolean("flag")) {
User user = activityBxService.dao().fetch(User.class, Cnd.where("id", "=", aid.getUser_id()));
msgApi.sendWxMsg("活动申报", "", "text", "您申报的活动【" + aid.getActivity_name() + "】已审核通过,请前往智慧工会进行查看。", List.of(user.getLoginname()), null);
if (aid.getActivity_id() == null) {
ActivityTissue activityTissue = new ActivityTissue();
activityTissue.setId(aid.getId());
activityTissue.setApplyTime(DateUtil.getDate());
activityTissue.setType(aid.getActivity_type());
activityTissue.setActivityCode(aid.getProjectCode());
activityTissue.setName(aid.getActivity_name());
activityTissue.setUserId(aid.getUser_id());
activityTissue.setStartPlannedDate(aid.getStartPlannedDate());
activityTissue.setEndPlannedDate(aid.getEndPlannedDate());
activityTissue.setStartTime(aid.getStartTime());
activityTissue.setEndTime(aid.getEndTime());
activityTissue.setAddress(aid.getAddress());
if (aid.getActivity_type() == 40002) {
activityTissue.setUnionId(user.getUnionid());
} else if (aid.getActivity_type() == 40003) {
activityTissue.setClubId(aid.getClubId());
}
activityTissue.setProjectTypeCode("50006");
activityBxService.insert(activityTissue);
aid.setActivity_id(activityTissue.getId());
}
nodeName = "审核通过";
} else {
Sys_user user = activityBxService.dao().fetch(Sys_user.class, aid.getUser_id());
msgApi.sendWxMsg("活动申报", "", "text", "您申报的活动【" + aid.getActivity_name() + "】被校工会主席拒绝,审核意见:" + aid.getUnion_chairman_idea(), List.of(user.getLoginname()), null);
FundsToHandler.REFUSE_TASK.exec(aid.getId(), new NutMap().addv("option", nodeName), null);
}
activityBxService.update(aid);
FundsToHandler.COMPLETE_SCHOOL_TASK.exec(aid.getId(), new NutMap().addv("option", nodeName), null);
/*jf_yjgh jfYjgh = jfYjghService.fetch(Cnd.where("unionId", "=", vi.getUnionId(aid.getUser_id())).and("year", "=", Calendar.getInstance().get(Calendar.YEAR)));
if (jfYjgh != null) {
jf_use use = new jf_use();
use.setAdjust_id(jfYjgh.getId());
use.setApply_id(aid.getId());
use.setProject(aid.getActivity_name());
use.setAdjust_money((double) Math.round(aid.getActivity_money()));
use.setAdjust_person(ShiroUtil.getPrincipalProperty("id").toString());
use.setAdjust_time(DateUtil.getDate());
jfUseService.addUse(use, false);
}*/
@RequiresPermissions("sys.jf.activity_bx.union")
public Object doReview(@Param("data") String data) {
if (Strings.isBlank(data)) throw new IllegalArgumentException("请填写审核信息");
NutMap form = Json.fromJson(NutMap.class, data);
if (form == null) throw new IllegalArgumentException("审核参数格式错误");
activityBxService.reviewActivity("union", form);
return null;
}
@@ -1,64 +1,47 @@
package io.v.nutz.zhgh.jf.controller.reimbursement;
import cn.hutool.core.util.StrUtil;
import io.v.nutz.base.annontation.ViReturn;
import io.v.nutz.base.utils.Vi;
import io.v.nutz.zhgh.jf.handle.reimbursement.ReimbursementToHandler;
import io.v.nutz.zhgh.jf.service.ActivityBxService;
import io.v.nutz.base.model.Audit;
import io.v.nutz.base.query.PageForm;
import io.v.nutz.web.commons.utils.ShiroUtil;
import io.v.nutz.zhgh.jf.service.ActivityBxService;
import io.v.nutz.web.commons.slog.annotation.SLog;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Strings;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import java.util.Date;
/**
* @version 1.0
* @Author zzr
* @name:ActivityReimbursementClubChairmanController
* @Date 2024/4/26 14:58
* @注释 活动报销,协会负责人审核
*/
/** 活动报销:协会负责人审核,PC 与手机共用列表、数据范围和审核服务。 */
@IocBean
@Ok("json:full")
@At("/platform/jf/reimbursement/clubChairman")
public class ActivityReimbursementClubChairmanController {
@Inject
private Vi vi;
@Inject
private ActivityBxService activityBxService;
/** 保留现有 PC 页面入口。 */
@At("")
@Ok("beetl:/platform/jf/reimbursement/clubChairman.html")
@RequiresPermissions("sys.jf.reimbursement.clubChairman")
public void index() {
}
/** 手机页面可携带 id 报销主键和 mode=audit/view;权限名称与 PC 一致。 */
@At
@Ok("beetl:/mobile/jf/reimbursement/clubChairman/index.html")
@RequiresPermissions("sys.jf.reimbursement.clubChairman")
public void mobile() {
}
/**
* 活动报销协会审核
* @param pageForm
* @param year
* @param clubId
* @param activityName
* @param isAudit 0,全部;1,已审核;2,未审核
* @return
* pageForm 含 pageNumber/pageSize、searchName(u.username/u.loginname)、searchKeyword 和排序字段/方向;
* year 为年度,clubId/unionId/unitId 为组织ID,activityName 为活动名,isAudit 为0全部/1已审核/2未审核。
* 返回统一结果,code=0 时 data 为 Pagination,list 为本页记录、totalCount 为总数。
*/
@At
@ViReturn
@@ -66,57 +49,38 @@ public class ActivityReimbursementClubChairmanController {
public Object pageData(PageForm pageForm,
@Param(value = "year", required = false) Integer year,
@Param(value = "clubId", required = false) String clubId,
@Param(value = "unionId", required = false) String unionId,
@Param(value = "unitId", required = false) String unitId,
@Param(value = "activityName", required = false) String activityName,
@Param(value = "isAudit", required = false) String isAudit) {
Sql sql = activityBxService.activityReimbursementSql();
Cnd cnd = Cnd.NEW();
cnd.andEX("act.`year`","=",year);
if (StrUtil.isNotBlank(activityName)) {
cnd.and(Cnd.likeEX("act.activity_name", activityName));
}
// 姓名/工号查询由页面通过 PageForm 统一传入,这里按所选字段做模糊匹配。
if (StrUtil.isNotBlank(pageForm.getSearchName()) && StrUtil.isNotBlank(pageForm.getSearchKeyword())) {
cnd.and(Cnd.likeEX(pageForm.getSearchName(), pageForm.getSearchKeyword()));
}
cnd.and("act.reimbursement_state_id","is not",null);
//1已审核;2未审核;0全部
cnd.and("act.reimbursement_state_id", "1".equals(isAudit) ? ">" : "2".equals(isAudit) ? "=" : ">=", 1000);
if (ShiroUtil.hasAnyRoles("sysadmin,A06")){
cnd.andEX("act.clubId","=",clubId);
} else {
cnd.and("act.clubId","=",vi.getClubId());
}
sql.setCondition(cnd);
return activityBxService.listPageMap(pageForm.getPageNumber(),pageForm.getPageSize(),sql);
return activityBxService.reimbursementPage("clubChairman", pageForm, NutMap.NEW()
.addv("year", year).addv("clubId", clubId).addv("unionId", unionId).addv("unitId", unitId)
.addv("activityName", activityName).addv("isAudit", Strings.isBlank(isAudit) ? "2" : isAudit));
}
/** id 为报销主键;返回统一结果,data 为详情 Map,含费用、附件、审核记录、报销状态及 canReview。 */
@At
@ViReturn
@RequiresPermissions("sys.jf.reimbursement.clubChairman")
public Object findOne(@Param("id") String id) {
if (Strings.isBlank(id)) throw new IllegalArgumentException("请选择活动报销申请");
return activityBxService.reimbursementDetail("clubChairman", id);
}
/**
* id 为报销主键;audit 为 JSON 对象,auditOpinion 为1至500字意见,auditSign 为签字图片或历史路径,
* auditPass 为 true通过/false拒绝。人员和时间由服务端确定;返回统一结果,code=0 表示成功,msg 为提示,data 为空。
*/
@At
@ViReturn
@Aop(TransAop.READ_COMMITTED)
@RequiresPermissions("sys.jf.reimbursement.clubChairman")
@SLog(type = "文体活动", tag = "活动报销", msg = "协会负责人审核", param = true, result = true)
public Object doAuditing(@Param("id") String id,@Param("audit") Audit audit){
audit.setAuditor(ShiroUtil.getPrincipalProperty("id").toString());
audit.setUsername(ShiroUtil.getPrincipalProperty("username").toString());
audit.setLoginname(ShiroUtil.getPrincipalProperty("loginname").toString());
audit.setAuditTime(new Date());
activityBxService.dao().insert(audit);
Chain chain = Chain.make("reimbursement_club_audit_id", audit.getId());
chain.add("reimbursement_state_id", audit.getAuditPass() ? 1020 : 1005);
activityBxService.update(chain,Cnd.where("id","=",id));
ReimbursementToHandler.COMPLETE_CLUB_TASK.exec(id, NutMap.NEW().addv("nodeName",audit.getAuditPass() ? "协会负责人通过" : "协会负责人拒绝"));
if (audit.getAuditPass()) {
ReimbursementToHandler.CREATE_SCHOOL_DIRECTOR_TASK.exec(id,null);
} else {
ReimbursementToHandler.REFUSE_UNION_TASK.exec(id,NutMap.NEW().addv("nodeName","协会负责人拒绝"));
public Object doAuditing(@Param("id") String id, @Param("audit") Audit audit) {
if (Strings.isBlank(id) || audit == null || audit.getAuditPass() == null) {
throw new IllegalArgumentException("请选择活动报销申请并填写审核信息");
}
activityBxService.reviewReimbursement("clubChairman", id, audit);
return null;
}
}
@@ -1,105 +1,86 @@
package io.v.nutz.zhgh.jf.controller.reimbursement;
import cn.hutool.core.util.StrUtil;
import io.v.nutz.base.annontation.ViReturn;
import io.v.nutz.base.utils.Vi;
import io.v.nutz.zhgh.jf.handle.reimbursement.ReimbursementToHandler;
import io.v.nutz.zhgh.jf.service.ActivityBxService;
import io.v.nutz.base.model.Audit;
import io.v.nutz.base.query.PageForm;
import io.v.nutz.web.commons.utils.ShiroUtil;
import io.v.nutz.zhgh.jf.service.ActivityBxService;
import io.v.nutz.web.commons.slog.annotation.SLog;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
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.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import java.util.Date;
/**
* @version 1.0
* @Author zzr
* @name:ActivityReimbursementSchoolChairmanController
* @Date 2024/4/26 14:58
* @注释 报销,校工会主席审核
*/
/** 活动报销:校工会主席审核,PC 与手机共用列表、数据范围和审核服务。 */
@IocBean
@Ok("json:full")
@At("/platform/jf/reimbursement/schoolChairman")
public class ActivityReimbursementSchoolChairmanController {
@Inject
private ActivityBxService activityBxService;
/** 保留现有 PC 页面入口。 */
@At("")
@Ok("beetl:/platform/jf/reimbursement/schoolChairman.html")
@RequiresPermissions("sys.jf.reimbursement.schoolChairman")
public void index() {
}
/** 手机页面可携带 id 报销主键和 mode=audit/view;权限名称与 PC 一致。 */
@At
@Ok("beetl:/mobile/jf/reimbursement/schoolChairman/index.html")
@RequiresPermissions("sys.jf.reimbursement.schoolChairman")
public void mobile() {
}
/**
* pageForm 含 pageNumber/pageSize、searchName(u.username/u.loginname)、searchKeyword 和排序字段/方向;
* year 为年度,clubId/unionId/unitId 为组织ID,activityName 为活动名,isAudit 为0全部/1已审核/2未审核。
* 返回统一结果,code=0 时 data 为 Pagination,list 为本页记录、totalCount 为总数。
*/
@At
@ViReturn
@RequiresPermissions("sys.jf.reimbursement.schoolChairman")
public Object pageData(PageForm pageForm,
@Param(value = "year", required = false) Integer year,
@Param(value = "clubId", required = false) String clubId,
@Param(value = "unionId", required = false) String unionId,
@Param(value = "unitId", required = false) String unitId,
@Param(value = "clubId",required = false) String clubId,
@Param(value = "activityName", required = false) String activityName,
@Param(value = "isAudit", required = false) String isAudit) {
Sql sql = activityBxService.activityReimbursementSql();
Cnd cnd = Cnd.NEW();
cnd.andEX("act.`year`","=",year);
if (StrUtil.isNotBlank(activityName)) {
cnd.and(Cnd.likeEX("act.activity_name", activityName));
}
// 姓名/工号查询由页面通过 PageForm 统一传入,这里按所选字段做模糊匹配。
if (StrUtil.isNotBlank(pageForm.getSearchName()) && StrUtil.isNotBlank(pageForm.getSearchKeyword())) {
cnd.and(Cnd.likeEX(pageForm.getSearchName(), pageForm.getSearchKeyword()));
}
//1已审核;2未审核;0全部
cnd.and("act.reimbursement_state_id", "1".equals(isAudit) ? ">" : "2".equals(isAudit) ? "=" : ">=", 1040);
cnd.andEX("u.unionid","=",unionId);
cnd.andEX("u.unitId","=",unitId);
cnd.andEX("act.clubId","=",clubId);
sql.setCondition(cnd);
return activityBxService.listPageMap(pageForm.getPageNumber(),pageForm.getPageSize(),sql);
return activityBxService.reimbursementPage("schoolChairman", pageForm, NutMap.NEW()
.addv("year", year).addv("clubId", clubId).addv("unionId", unionId).addv("unitId", unitId)
.addv("activityName", activityName).addv("isAudit", Strings.isBlank(isAudit) ? "2" : isAudit));
}
/** id 为报销主键;返回统一结果,data 为详情 Map,含费用、附件、审核记录、报销状态及 canReview。 */
@At
@ViReturn
@RequiresPermissions("sys.jf.reimbursement.schoolChairman")
public Object findOne(@Param("id") String id) {
if (Strings.isBlank(id)) throw new IllegalArgumentException("请选择活动报销申请");
return activityBxService.reimbursementDetail("schoolChairman", id);
}
/**
* id 为报销主键;audit 为 JSON 对象,auditOpinion 为1至500字意见,auditSign 为签字图片或历史路径,
* auditPass 为 true通过/false拒绝。人员和时间由服务端确定;返回统一结果,code=0 表示成功,msg 为提示,data 为空。
*/
@At
@ViReturn
@Aop(TransAop.READ_COMMITTED)
@RequiresPermissions("sys.jf.reimbursement.schoolChairman")
@SLog(type = "文体活动", tag = "活动报销", msg = "校工会主席审核", param = true, result = true)
public Object doAuditing(@Param("id") String id,@Param("audit") Audit audit){
audit.setAuditor(ShiroUtil.getPrincipalProperty("id").toString());
audit.setUsername(ShiroUtil.getPrincipalProperty("username").toString());
audit.setLoginname(ShiroUtil.getPrincipalProperty("loginname").toString());
audit.setAuditTime(new Date());
activityBxService.dao().insert(audit);
Chain chain = Chain.make("reimbursement_school_chairman_id", audit.getId());
chain.add("reimbursement_state_id", audit.getAuditPass() ? 1061 : 1045);
activityBxService.update(chain,Cnd.where("id","=",id));
ReimbursementToHandler.COMPLETE_SCHOOL_CHAIRMAN_TASK.exec(id, NutMap.NEW().addv("nodeName",audit.getAuditPass() ? "校工会主席通过" : "校工会主席拒绝"));
if (audit.getAuditPass()) {
ReimbursementToHandler.COMPLETE_PROCESS.exec(id,null);
} else {
ReimbursementToHandler.REFUSE_UNION_TASK.exec(id,NutMap.NEW().addv("nodeName","校工会主席拒绝"));
public Object doAuditing(@Param("id") String id, @Param("audit") Audit audit) {
if (Strings.isBlank(id) || audit == null || audit.getAuditPass() == null) {
throw new IllegalArgumentException("请选择活动报销申请并填写审核信息");
}
activityBxService.reviewReimbursement("schoolChairman", id, audit);
return null;
}
}
@@ -1,104 +1,86 @@
package io.v.nutz.zhgh.jf.controller.reimbursement;
import cn.hutool.core.util.StrUtil;
import io.v.nutz.base.annontation.ViReturn;
import io.v.nutz.base.utils.Vi;
import io.v.nutz.zhgh.jf.handle.reimbursement.ReimbursementToHandler;
import io.v.nutz.zhgh.jf.service.ActivityBxService;
import io.v.nutz.base.model.Audit;
import io.v.nutz.base.query.PageForm;
import io.v.nutz.web.commons.utils.ShiroUtil;
import io.v.nutz.zhgh.jf.service.ActivityBxService;
import io.v.nutz.web.commons.slog.annotation.SLog;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
import org.nutz.dao.sql.Sql;
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.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import java.util.Date;
/**
* @version 1.0
* @Author zzr
* @name:ActivityReimbursementSchoolDirectorController
* @Date 2024/4/26 14:58
* @注释 报销校工会负责人审核
*/
/** 活动报销:校工会负责人审核,PC 与手机共用列表、数据范围和审核服务。 */
@IocBean
@Ok("json:full")
@At("/platform/jf/reimbursement/schoolDirector")
public class ActivityReimbursementSchoolDirectorController {
@Inject
private ActivityBxService activityBxService;
/** 保留现有 PC 页面入口。 */
@At("")
@Ok("beetl:/platform/jf/reimbursement/schoolDirector.html")
@RequiresPermissions("sys.jf.reimbursement.schoolDirector")
public void index() {
}
/** 手机页面可携带 id 报销主键和 mode=audit/view;权限名称与 PC 一致。 */
@At
@Ok("beetl:/mobile/jf/reimbursement/schoolDirector/index.html")
@RequiresPermissions("sys.jf.reimbursement.schoolDirector")
public void mobile() {
}
/**
* pageForm 含 pageNumber/pageSize、searchName(u.username/u.loginname)、searchKeyword 和排序字段/方向;
* year 为年度,clubId/unionId/unitId 为组织ID,activityName 为活动名,isAudit 为0全部/1已审核/2未审核。
* 返回统一结果,code=0 时 data 为 Pagination,list 为本页记录、totalCount 为总数。
*/
@At
@ViReturn
@RequiresPermissions("sys.jf.reimbursement.schoolDirector")
public Object pageData(PageForm pageForm,
@Param(value = "year", required = false) Integer year,
@Param(value = "clubId", required = false) String clubId,
@Param(value = "unionId", required = false) String unionId,
@Param(value = "unitId", required = false) String unitId,
@Param(value = "clubId",required = false) String clubId,
@Param(value = "activityName", required = false) String activityName,
@Param(value = "isAudit", required = false) String isAudit) {
Sql sql = activityBxService.activityReimbursementSql();
Cnd cnd = Cnd.NEW();
cnd.andEX("act.`year`","=",year);
if (StrUtil.isNotBlank(activityName)) {
cnd.and(Cnd.likeEX("act.activity_name", activityName));
}
// 姓名/工号查询由页面通过 PageForm 统一传入,这里按所选字段做模糊匹配。
if (StrUtil.isNotBlank(pageForm.getSearchName()) && StrUtil.isNotBlank(pageForm.getSearchKeyword())) {
cnd.and(Cnd.likeEX(pageForm.getSearchName(), pageForm.getSearchKeyword()));
}
//1已审核;2未审核;0全部
cnd.and("act.reimbursement_state_id", "1".equals(isAudit) ? ">" : "2".equals(isAudit) ? "=" : ">=", 1020);
cnd.andEX("u.unionid","=",unionId);
cnd.andEX("u.unitId","=",unitId);
cnd.andEX("act.clubId","=",clubId);
sql.setCondition(cnd);
return activityBxService.listPageMap(pageForm.getPageNumber(),pageForm.getPageSize(),sql);
return activityBxService.reimbursementPage("schoolDirector", pageForm, NutMap.NEW()
.addv("year", year).addv("clubId", clubId).addv("unionId", unionId).addv("unitId", unitId)
.addv("activityName", activityName).addv("isAudit", Strings.isBlank(isAudit) ? "2" : isAudit));
}
/** id 为报销主键;返回统一结果,data 为详情 Map,含费用、附件、审核记录、报销状态及 canReview。 */
@At
@ViReturn
@RequiresPermissions("sys.jf.reimbursement.schoolDirector")
public Object findOne(@Param("id") String id) {
if (Strings.isBlank(id)) throw new IllegalArgumentException("请选择活动报销申请");
return activityBxService.reimbursementDetail("schoolDirector", id);
}
/**
* id 为报销主键;audit 为 JSON 对象,auditOpinion 为1至500字意见,auditSign 为签字图片或历史路径,
* auditPass 为 true通过/false拒绝。人员和时间由服务端确定;返回统一结果,code=0 表示成功,msg 为提示,data 为空。
*/
@At
@ViReturn
@Aop(TransAop.READ_COMMITTED)
@RequiresPermissions("sys.jf.reimbursement.schoolDirector")
@SLog(type = "文体活动", tag = "活动报销", msg = "校工会负责人审核", param = true, result = true)
public Object doAuditing(@Param("id") String id,@Param("audit") Audit audit){
audit.setAuditor(ShiroUtil.getPrincipalProperty("id").toString());
audit.setUsername(ShiroUtil.getPrincipalProperty("username").toString());
audit.setLoginname(ShiroUtil.getPrincipalProperty("loginname").toString());
audit.setAuditTime(new Date());
activityBxService.dao().insert(audit);
Chain chain = Chain.make("reimbursement_school_director_id", audit.getId());
//通过,校工会主席审核
chain.add("reimbursement_state_id", audit.getAuditPass() ? 1040 : 1025);
activityBxService.update(chain,Cnd.where("id","=",id));
ReimbursementToHandler.COMPLETE_SCHOOL_DIRECTOR_TASK.exec(id, NutMap.NEW().addv("nodeName",audit.getAuditPass() ? "校工会负责人通过" : "校工会负责人拒绝"));
if (audit.getAuditPass()) {
ReimbursementToHandler.CREATE_SCHOOL_CHAIRMAN_TASK.exec(id,null);
} else {
ReimbursementToHandler.REFUSE_UNION_TASK.exec(id,NutMap.NEW().addv("nodeName","校工会负责人拒绝"));
public Object doAuditing(@Param("id") String id, @Param("audit") Audit audit) {
if (Strings.isBlank(id) || audit == null || audit.getAuditPass() == null) {
throw new IllegalArgumentException("请选择活动报销申请并填写审核信息");
}
activityBxService.reviewReimbursement("schoolDirector", id, audit);
return null;
}
}
@@ -1,114 +1,86 @@
package io.v.nutz.zhgh.jf.controller.reimbursement;
import cn.hutool.core.util.StrUtil;
import io.v.nutz.base.annontation.ViReturn;
import io.v.nutz.base.utils.Vi;
import io.v.nutz.zhgh.jf.handle.reimbursement.ReimbursementToHandler;
import io.v.nutz.zhgh.jf.service.ActivityBxService;
import io.v.nutz.base.model.Audit;
import io.v.nutz.base.query.PageForm;
import io.v.nutz.web.commons.utils.ShiroUtil;
import io.v.nutz.zhgh.jf.service.ActivityBxService;
import io.v.nutz.web.commons.slog.annotation.SLog;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
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.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import java.util.Date;
/**
* @version 1.0
* @Author zzr
* @name:ActivityReimbursementUnionChairmanController
* @Date 2024/4/26 14:58
* @注释 活动报销,分工会主席审核
*/
/** 活动报销:分工会主席审核,PC 与手机共用列表、数据范围和审核服务。 */
@IocBean
@Ok("json:full")
@At("/platform/jf/reimbursement/unionChairman")
public class ActivityReimbursementUnionChairmanController {
@Inject
private ActivityBxService activityBxService;
/** 保留现有 PC 页面入口。 */
@At("")
@Ok("beetl:/platform/jf/reimbursement/unionChairman.html")
@RequiresPermissions("sys.jf.reimbursement.unionChairman")
public void index() {
}
/** 手机页面可携带 id 报销主键和 mode=audit/view;权限名称与 PC 一致。 */
@At
@Ok("beetl:/mobile/jf/reimbursement/unionChairman/index.html")
@RequiresPermissions("sys.jf.reimbursement.unionChairman")
public void mobile() {
}
/**
* pageForm 含 pageNumber/pageSize、searchName(u.username/u.loginname)、searchKeyword 和排序字段/方向;
* year 为年度,clubId/unionId/unitId 为组织ID,activityName 为活动名,isAudit 为0全部/1已审核/2未审核。
* 返回统一结果,code=0 时 data 为 Pagination,list 为本页记录、totalCount 为总数。
*/
@At
@ViReturn
@RequiresPermissions("sys.jf.reimbursement.unionChairman")
public Object pageData(PageForm pageForm,
@Param(value = "year", required = false) Integer year,
@Param(value = "clubId", required = false) String clubId,
@Param(value = "unionId", required = false) String unionId,
@Param(value = "unitId", required = false) String unitId,
@Param(value = "activityName", required = false) String activityName,
@Param(value = "isAudit", required = false) String isAudit) {
Sql sql = activityBxService.activityReimbursementSql();
Cnd cnd = Cnd.NEW();
cnd.andEX("act.`year`","=",year);
if (StrUtil.isNotBlank(activityName)) {
cnd.and(Cnd.likeEX("act.activity_name", activityName));
}
// 姓名/工号查询由页面通过 PageForm 统一传入,这里按所选字段做模糊匹配。
if (StrUtil.isNotBlank(pageForm.getSearchName()) && StrUtil.isNotBlank(pageForm.getSearchKeyword())) {
cnd.and(Cnd.likeEX(pageForm.getSearchName(), pageForm.getSearchKeyword()));
}
//1已审核;2未审核;0全部
cnd.and("act.reimbursement_state_id", "1".equals(isAudit) ? ">" : "2".equals(isAudit) ? "=" : ">=", 1010);
if (ShiroUtil.hasAnyRoles("sysadmin,A06")){
cnd.andEX("u.unionid","=",unionId);
} else {
cnd.and("u.unionid","=", Vi.getUnionId());
}
cnd.andEX("u.unitId","=",unitId);
sql.setCondition(cnd);
return activityBxService.listPageMap(pageForm.getPageNumber(),pageForm.getPageSize(),sql);
return activityBxService.reimbursementPage("unionChairman", pageForm, NutMap.NEW()
.addv("year", year).addv("clubId", clubId).addv("unionId", unionId).addv("unitId", unitId)
.addv("activityName", activityName).addv("isAudit", Strings.isBlank(isAudit) ? "2" : isAudit));
}
/**
* 基层工会活动报销
* 基层工会活动——>基层工会主席审核——>校工会主席审核——>流程结束
* @param id
* @param audit
* @return
*/
/** id 为报销主键;返回统一结果,data 为详情 Map,含费用、附件、审核记录、报销状态及 canReview。 */
@At
@ViReturn
@RequiresPermissions("sys.jf.reimbursement.unionChairman")
public Object findOne(@Param("id") String id) {
if (Strings.isBlank(id)) throw new IllegalArgumentException("请选择活动报销申请");
return activityBxService.reimbursementDetail("unionChairman", id);
}
/**
* id 为报销主键;audit 为 JSON 对象,auditOpinion 为1至500字意见,auditSign 为签字图片或历史路径,
* auditPass 为 true通过/false拒绝。人员和时间由服务端确定;返回统一结果,code=0 表示成功,msg 为提示,data 为空。
*/
@At
@ViReturn
@Aop(TransAop.READ_COMMITTED)
@RequiresPermissions("sys.jf.reimbursement.unionChairman")
@SLog(type = "文体活动", tag = "活动报销", msg = "分工会主席审核", param = true, result = true)
public Object doAuditing(@Param("id") String id,@Param("audit") Audit audit){
audit.setAuditor(ShiroUtil.getPrincipalProperty("id").toString());
audit.setUsername(ShiroUtil.getPrincipalProperty("username").toString());
audit.setLoginname(ShiroUtil.getPrincipalProperty("loginname").toString());
audit.setAuditTime(new Date());
activityBxService.dao().insert(audit);
Chain chain = Chain.make("reimbursement_union_audit_id", audit.getId());
//通过,校工会主席审核
chain.add("reimbursement_state_id", audit.getAuditPass() ? 1020 : 1015);
activityBxService.update(chain,Cnd.where("id","=",id));
ReimbursementToHandler.COMPLETE_UNION_TASK.exec(id, NutMap.NEW().addv("nodeName",audit.getAuditPass() ? "基层工会通过" : "基层工会拒绝"));
if (audit.getAuditPass()) {
ReimbursementToHandler.CREATE_SCHOOL_DIRECTOR_TASK.exec(id,null);
} else {
ReimbursementToHandler.REFUSE_UNION_TASK.exec(id,NutMap.NEW().addv("nodeName","基层工会拒绝"));
public Object doAuditing(@Param("id") String id, @Param("audit") Audit audit) {
if (Strings.isBlank(id) || audit == null || audit.getAuditPass() == null) {
throw new IllegalArgumentException("请选择活动报销申请并填写审核信息");
}
activityBxService.reviewReimbursement("unionChairman", id, audit);
return null;
}
}
@@ -107,8 +107,8 @@ public enum FundsToHandler {
loginNames,
"/platform/jf/activity_bx/association",
"/platform/jf/activity_bx/association",
"/platform/jf/activity_bx/association",
"/platform/jf/activity_bx/association"
"/platform/jf/activity_bx/association/mobile?id=" + reimbursementId + "&mode=audit",
"/platform/jf/activity_bx/association/mobile?id=" + reimbursementId + "&mode=view"
);
localProcessService.updateProcessNodeName("activity_reimbursement@" + reimbursementId, "协会负责人审核");
@@ -144,8 +144,8 @@ public enum FundsToHandler {
loginNames,
"/platform/jf/activity_bx/division",
"/platform/jf/activity_bx/division",
"/platform/jf/activity_bx/division",
"/platform/jf/activity_bx/division"
"/platform/jf/activity_bx/division/mobile?id=" + reimbursementId + "&mode=audit",
"/platform/jf/activity_bx/division/mobile?id=" + reimbursementId + "&mode=view"
);
localProcessService.updateProcessNodeName("funds_apply@" + reimbursementId, "基层工会审核");
@@ -181,8 +181,8 @@ public enum FundsToHandler {
loginNames,
"/platform/jf/activity_bx/accounting",
"/platform/jf/activity_bx/accounting",
"/platform/jf/activity_bx/accounting",
"/platform/jf/activity_bx/accounting"
"/platform/jf/activity_bx/accounting/mobile?id=" + reimbursementId + "&mode=audit",
"/platform/jf/activity_bx/accounting/mobile?id=" + reimbursementId + "&mode=view"
);
localProcessService.updateProcessNodeName("funds_apply@" + reimbursementId, "校工会负责人审核");
@@ -217,8 +217,8 @@ public enum FundsToHandler {
loginNames,
"/platform/jf/activity_bx/union",
"/platform/jf/activity_bx/union",
"/platform/jf/activity_bx/union",
"/platform/jf/activity_bx/union"
"/platform/jf/activity_bx/union/mobile?id=" + reimbursementId + "&mode=audit",
"/platform/jf/activity_bx/union/mobile?id=" + reimbursementId + "&mode=view"
);
localProcessService.updateProcessNodeName("funds_apply@" + reimbursementId, "校工会主席审核");
@@ -129,8 +129,8 @@ public enum ReimbursementToHandler {
unionLeader,
"/platform/jf/reimbursement/unionChairman",
"/platform/jf/reimbursement/unionChairman",
"/platform/jf/reimbursement/unionChairman",
"/platform/jf/reimbursement/unionChairman"
"/platform/jf/reimbursement/unionChairman/mobile?id=" + reimbursementId + "&mode=audit",
"/platform/jf/reimbursement/unionChairman/mobile?id=" + reimbursementId + "&mode=view"
);
localProcessService.updateProcessNodeName("activity_reimbursement@" + reimbursementId, "基层工会审核");
@@ -173,8 +173,8 @@ public enum ReimbursementToHandler {
clubLeader,
"/platform/jf/reimbursement/clubChairman",
"/platform/jf/reimbursement/clubChairman",
"/platform/jf/reimbursement/clubChairman",
"/platform/jf/reimbursement/clubChairman"
"/platform/jf/reimbursement/clubChairman/mobile?id=" + reimbursementId + "&mode=audit",
"/platform/jf/reimbursement/clubChairman/mobile?id=" + reimbursementId + "&mode=view"
);
localProcessService.updateProcessNodeName("activity_reimbursement@" + reimbursementId, "协会负责人审核");
@@ -214,8 +214,8 @@ public enum ReimbursementToHandler {
List.of("1019911404"),
"/platform/jf/reimbursement/schoolDirector",
"/platform/jf/reimbursement/schoolDirector",
"/platform/jf/reimbursement/schoolDirector",
"/platform/jf/reimbursement/schoolDirector"
"/platform/jf/reimbursement/schoolDirector/mobile?id=" + reimbursementId + "&mode=audit",
"/platform/jf/reimbursement/schoolDirector/mobile?id=" + reimbursementId + "&mode=view"
);
localProcessService.updateProcessNodeName("activity_reimbursement@" + reimbursementId, "校工会负责人审核");
@@ -257,8 +257,8 @@ public enum ReimbursementToHandler {
schoolChairmanLeader,
"/platform/jf/reimbursement/schoolChairman",
"/platform/jf/reimbursement/schoolChairman",
"/platform/jf/reimbursement/schoolChairman",
"/platform/jf/reimbursement/schoolChairman"
"/platform/jf/reimbursement/schoolChairman/mobile?id=" + reimbursementId + "&mode=audit",
"/platform/jf/reimbursement/schoolChairman/mobile?id=" + reimbursementId + "&mode=view"
);
localProcessService.updateProcessNodeName("activity_reimbursement@" + reimbursementId, "校工会主席审核");
@@ -2,11 +2,15 @@ package io.v.nutz.zhgh.jf.service;
import io.v.nutz.zhgh.jf.model.ActivityBx;
import io.v.nutz.base.service.ViService;
import io.v.nutz.base.model.Audit;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.lang.util.NutMap;
import java.io.OutputStream;
import java.util.List;
import cn.wizzer.framework.page.Pagination;
import io.v.nutz.sys.models.Sys_club;
/**
* @author zhf
@@ -20,6 +24,39 @@ public interface ActivityBxService extends ViService<ActivityBx> {
NutMap queryOne(String id);
/** 按年度、协会、审核状态及 PC 筛选条件分页查询;始终限制为当前用户管理的协会活动。 */
Pagination associationPage(NutMap filters);
/** 返回当前用户管理的协会,供手机筛选;管理员可查看全部协会。 */
List<Sys_club> associationClubs();
/** 校验协会数据权限后返回详情,canReview 标识当前是否仍可审核。 */
NutMap associationDetail(String id);
/** 校验待审状态,记录意见和签字,并处理下一环节待办及通知。 */
void reviewAssociation(String id, String idea, String sign, boolean flag);
/** stage 为 Controller 固定的 division/accounting/union;filters 包含 PC 兼容筛选和分页参数。 */
Pagination reviewPage(String stage, NutMap filters);
/** 按审核节点及登录用户数据范围读取申请,返回详情及 canReview 标识。 */
NutMap reviewDetail(String stage, String id);
/** 校验申请可访问后返回原 PC 所需物品明细。 */
List<io.v.nutz.base.model.Goods> reviewGoods(String stage, String id);
/** form 包含 id/idea/sign/flag,校验当前待审状态后记录审核并执行原节点流转。 */
void reviewActivity(String stage, NutMap form);
/** stage 为服务端固定的报销节点;pageForm 为分页/姓名工号/排序,filters 为年度、组织、活动名和0全部/1已审/2未审。返回 list/totalCount。 */
Pagination reimbursementPage(String stage, io.v.nutz.base.query.PageForm pageForm, NutMap filters);
/** id 为活动报销主键;按节点数据范围校验,返回详情、报销状态名称及 canReview 是否可审核。 */
NutMap reimbursementDetail(String stage, String id);
/** id 为报销主键,audit 仅接收 auditOpinion/auditSign/auditPass;服务端记录身份、事务更新状态并流转,无返回值。 */
void reviewReimbursement(String stage, String id, Audit audit);
void exportByView(String id, OutputStream outputStream);
void exportByCon(String id, OutputStream outputStream);
@@ -13,6 +13,18 @@ import io.v.nutz.zhgh.activity.models.ActivityTissue;
import io.v.nutz.base.utils.DateUtil;
import io.v.nutz.base.utils.Html2Text;
import io.v.nutz.base.utils.Vi;
import io.v.nutz.base.utils.MsgApi;
import io.v.nutz.base.utils.Roles;
import cn.wizzer.framework.page.Pagination;
import io.v.nutz.sys.models.Sys_club;
import io.v.nutz.sys.models.Sys_signature;
import io.v.nutz.sys.models.Sys_user;
import io.v.nutz.sys.models.Sys_user_role;
import io.v.nutz.sys.models.User;
import io.v.nutz.sys.services.SysSignatureService;
import io.v.nutz.zhgh.jf.handle.funds.FundsToHandler;
import io.v.nutz.zhgh.jf.handle.reimbursement.ReimbursementToHandler;
import io.v.nutz.base.query.PageForm;
import io.v.nutz.base.model.Goods;
import io.v.nutz.zhgh.jf.model.ActivityBx;
import io.v.nutz.zhgh.jf.model.ActivityFile;
@@ -24,6 +36,7 @@ import io.v.nutz.web.commons.base.Globals;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.boot.starter.ftp.FtpService;
import org.nutz.dao.Cnd;
import org.nutz.dao.Chain;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
@@ -43,6 +56,7 @@ import java.util.Base64;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
@@ -61,6 +75,492 @@ public class ActivityBxServiceImpl extends ViServiceImpl<ActivityBx> implements
@Inject
private FtpService ftpService;
@Inject
private SysSignatureService sysSignatureService;
@Inject
private MsgApi msgApi;
/** 报销节点由各 Controller 固定,不能由客户端将申请送往其他审核环节。 */
private int reimbursementState(String stage) {
return switch (stage) {
case "clubChairman" -> 1000;
case "unionChairman" -> 1010;
case "schoolDirector" -> 1020;
case "schoolChairman" -> 1040;
default -> throw new IllegalArgumentException("不支持的报销审核环节");
};
}
/** 沿用四个 PC 节点的数据范围,列表、详情和审核共用;未关联组织时拒绝访问。 */
private Cnd reimbursementScope(String stage) {
Cnd scope = Cnd.where("act.reimbursement_state_id", ">=", reimbursementState(stage));
if (!ShiroUtil.hasAnyRoles("sysadmin,A06")) {
if ("clubChairman".equals(stage)) {
String clubId = vi.getClubId();
if (Strings.isBlank(clubId)) throw new IllegalArgumentException("当前用户未关联负责的协会");
scope.and("act.clubId", "=", clubId);
} else if ("unionChairman".equals(stage)) {
String unionId = Vi.getUnionId();
if (Strings.isBlank(unionId)) throw new IllegalArgumentException("当前用户未关联基层工会");
scope.and("u.unionid", "=", unionId);
}
}
return scope;
}
/** PC 与手机共用报销筛选口径,字段白名单避免把客户端输入作为 SQL 列名执行。 */
@Override
public Pagination reimbursementPage(String stage, PageForm pageForm, NutMap filters) {
Cnd cnd = reimbursementScope(stage);
int audit = filters.getInt("isAudit", 2);
if (audit < 0 || audit > 2) throw new IllegalArgumentException("审核状态筛选无效");
cnd.and("act.reimbursement_state_id", audit == 1 ? ">" : audit == 2 ? "=" : ">=", reimbursementState(stage));
Vi.cndPlus(cnd, "act.year", "=", filters.get("year"));
String activityName = filters.getString("activityName");
if (Strings.isNotBlank(activityName)) cnd.and(Cnd.likeEX("act.activity_name", activityName.trim()));
// 组织筛选仍遵循原节点:协会页不加单位条件,基层工会页不加协会条件。
if (!"clubChairman".equals(stage)) {
Vi.cndPlus(cnd, "u.unitId", "=", filters.getString("unitId"));
if (!"unionChairman".equals(stage) || ShiroUtil.hasAnyRoles("sysadmin,A06")) {
Vi.cndPlus(cnd, "u.unionid", "=", filters.getString("unionId"));
}
}
if (!"unionChairman".equals(stage) && (!"clubChairman".equals(stage) || ShiroUtil.hasAnyRoles("sysadmin,A06"))) {
Vi.cndPlus(cnd, "act.clubId", "=", filters.getString("clubId"));
}
Map<String, String> searchColumns = Map.of("u.username", "u.username", "u.loginname", "u.loginname");
String searchColumn = searchColumns.get(Strings.sNull(pageForm.getSearchName()));
if (searchColumn != null && Strings.isNotBlank(pageForm.getSearchKeyword())) {
cnd.and(Cnd.likeEX(searchColumn, pageForm.getSearchKeyword().trim()));
}
Map<String, String> orderColumns = Map.ofEntries(
Map.entry("activity_name", "act.activity_name"), Map.entry("user_name", "act.user_name"),
Map.entry("bxr_loginname", "u.loginname"), Map.entry("unionname", "u.unionname"),
Map.entry("unitname", "u.unitname"), Map.entry("clubName", "sc.name"),
Map.entry("activity_type", "act.activity_type"), Map.entry("absName", "abs.name"),
Map.entry("reimbursement_apply_time", "act.reimbursement_apply_time"),
Map.entry("reimbursement_state_id", "act.reimbursement_state_id"));
String orderColumn = orderColumns.get(Strings.sNull(pageForm.getPageOrderName()));
String direction = Strings.sNull(pageForm.getPageOrderBy());
if (orderColumn != null && List.of("asc", "desc", "ascending", "descending").contains(direction)) {
cnd.orderBy(orderColumn, direction.startsWith("asc") ? "asc" : "desc");
} else {
cnd.asc("act.reimbursement_state_id").desc("act.reimbursement_apply_time");
}
// 相同状态和时间也有固定顺序,防止手机连续分页时重复或遗漏记录。
cnd.desc("act.id");
Sql sql = activityReimbursementSql();
sql.setCondition(cnd);
int page = pageForm.getPageNumber() == null ? 1 : Math.max(1, pageForm.getPageNumber());
int size = pageForm.getPageSize() == null ? 10 : Math.min(100, Math.max(1, pageForm.getPageSize()));
return listPageMap(page, size, sql);
}
/** 按节点校验可访问记录,禁止凭 ID 跨协会/工会读取收款信息或提交审核。 */
private NutMap requireReimbursement(String stage, String id) {
if (Strings.isBlank(id)) throw new IllegalArgumentException("请选择活动报销申请");
Sql sql = activityReimbursementSql();
sql.setCondition(reimbursementScope(stage).and("act.id", "=", id));
sql.setCallback(Sqls.callback.map());
dao().execute(sql);
NutMap record = (NutMap) sql.getResult();
if (record == null || record.isEmpty()) throw new IllegalArgumentException("报销申请不存在或无权访问");
return record;
}
/** 复用现有附件和审核记录组装,仅为报销页覆盖状态名称,不改变申报详情接口。 */
@Override
public NutMap reimbursementDetail(String stage, String id) {
NutMap record = requireReimbursement(stage, id);
NutMap detail = queryOne(id);
if (detail == null) throw new IllegalArgumentException("报销申请不存在");
detail.setv("application_state_name", detail.get("state_name"));
detail.setv("state_name", record.get("state_name"));
detail.setv("state_color", record.get("state_color"));
detail.setv("clubName", record.get("clubName"));
detail.setv("bxr_unionname", record.get("unionname"));
detail.setv("bxr_unitname", record.get("unitname"));
detail.setv("canReview", detail.getInt("reimbursement_state_id") == reimbursementState(stage));
return detail;
}
/** 校验意见、签字和当前状态;只接收业务字段,审核身份、时间与主键由服务端生成。 */
@Override
@Aop(TransAop.READ_COMMITTED)
public void reviewReimbursement(String stage, String id, Audit input) {
if (input == null || input.getAuditPass() == null || Strings.isBlank(input.getAuditOpinion())
|| input.getAuditOpinion().trim().length() > 500 || Strings.isBlank(input.getAuditSign())) {
throw new IllegalArgumentException("请填写1至500字审核意见并签字,选择审核结果");
}
int state = reimbursementState(stage);
NutMap record = requireReimbursement(stage, id);
if (record.getInt("reimbursement_state_id") != state) throw new IllegalArgumentException("该报销已被处理,请刷新列表");
boolean pass = input.getAuditPass();
int nextState = pass ? (state <= 1010 ? 1020 : state == 1020 ? 1040 : 1061) : state + 5;
// 条件更新先取得审核权;PC、手机同时提交时只有一方能写审核记录及创建后续待办。
int changed = dao().update(ActivityBx.class, Chain.make("reimbursement_state_id", nextState),
Cnd.where("id", "=", id).and("reimbursement_state_id", "=", state));
if (changed != 1) throw new IllegalArgumentException("该报销已被处理,请刷新列表");
Audit audit = new Audit().setAuditPass(pass).setAuditOpinion(input.getAuditOpinion().trim())
.setAuditSign(input.getAuditSign()).setAuditor(ShiroUtil.getPrincipalProperty("id").toString())
.setUsername(ShiroUtil.getPrincipalProperty("username").toString())
.setLoginname(ShiroUtil.getPrincipalProperty("loginname").toString()).setAuditTime(new java.util.Date());
dao().insert(audit);
String auditField = switch (stage) {
case "clubChairman" -> "reimbursement_club_audit_id";
case "unionChairman" -> "reimbursement_union_audit_id";
case "schoolDirector" -> "reimbursement_school_director_id";
default -> "reimbursement_school_chairman_id";
};
dao().update(ActivityBx.class, Chain.make(auditField, audit.getId()), Cnd.where("id", "=", id));
String title = switch (stage) {
case "clubChairman" -> "协会负责人";
case "unionChairman" -> "基层工会";
case "schoolDirector" -> "校工会负责人";
default -> "校工会主席";
};
String nodeName = title + (pass ? "通过" : "拒绝");
ReimbursementToHandler complete = switch (stage) {
case "clubChairman" -> ReimbursementToHandler.COMPLETE_CLUB_TASK;
case "unionChairman" -> ReimbursementToHandler.COMPLETE_UNION_TASK;
case "schoolDirector" -> ReimbursementToHandler.COMPLETE_SCHOOL_DIRECTOR_TASK;
default -> ReimbursementToHandler.COMPLETE_SCHOOL_CHAIRMAN_TASK;
};
// 保持原报销待办流转及节点文案;拒绝先完成当前任务,再结束流程。
complete.exec(id, NutMap.NEW().addv("nodeName", nodeName));
if (!pass) {
ReimbursementToHandler.REFUSE_UNION_TASK.exec(id, NutMap.NEW().addv("nodeName", nodeName));
} else if (state <= 1010) {
ReimbursementToHandler.CREATE_SCHOOL_DIRECTOR_TASK.exec(id, null);
} else if (state == 1020) {
ReimbursementToHandler.CREATE_SCHOOL_CHAIRMAN_TASK.exec(id, null);
} else {
ReimbursementToHandler.COMPLETE_PROCESS.exec(id, null);
}
}
/** 节点由服务端 Controller 固定传入,不接受客户端自行指定审核环节。 */
private int reviewState(String stage) {
return switch (stage) {
case "division" -> 1010;
case "accounting" -> 1020;
case "union" -> 1040;
default -> throw new IllegalArgumentException("不支持的审核环节");
};
}
/** 与原 PC 列表保持一致:基层工会限定归属,女工角色在负责人节点仅查看女工项目。 */
private Cnd reviewScope(String stage) {
reviewState(stage);
Cnd scope = Cnd.NEW();
if ("division".equals(stage) && !ShiroUtil.hasAnyRoles("sysadmin")
&& ShiroUtil.hasAnyRoles(new String[]{"H04", "gh01", "gh02"})) {
String unionId = Vi.getUnionId();
if (Strings.isBlank(unionId)) throw new IllegalArgumentException("当前用户未关联基层工会");
scope.and("un.id", "=", unionId);
}
if ("accounting".equals(stage) && ShiroUtil.hasRole("xghng")) {
scope.and("act.projectTypeCode", "=", 50002);
}
return scope;
}
/** 三个节点共享查询结构;保留 PC 字段名,同时提供手机详情需要的组织名称。 */
private Sql reviewSql() {
return Sqls.create("""
SELECT act.*, u.loginname bxr_loginname, u.unionname bxr_unionname,
u.unitname bxr_unitname, s.state_name, s.state_color,
abs.name absName, cl.name clubName
FROM activity_bx act
LEFT JOIN `user` u ON act.user_id = u.id
LEFT JOIN sys_unit su ON su.id = u.unitid
LEFT JOIN sys_union un ON su.unionid = un.id
LEFT JOIN state s ON act.state_id = s.state_id
LEFT JOIN activity_basic_settings abs ON abs.code = act.projectJfCode
LEFT JOIN sys_club cl ON cl.id = act.clubId
$condition
""");
}
@Override
public Pagination reviewPage(String stage, NutMap filters) {
Cnd cnd = reviewScope(stage);
int audit = filters.getInt("isAudit", 3);
cnd.and("act.state_id", audit == 1 ? ">=" : audit == 2 ? ">" : "=", reviewState(stage));
Vi.cndPlus(cnd, "act.year", "=", filters.get("year"));
Vi.cndPlus(cnd, "u.unitid", "=", filters.getString("unitId"));
Vi.cndPlus(cnd, "u.unionid", "=", filters.getString("unionId"));
Map<String, String> searches = Map.of("username", "u.username", "loginname", "u.loginname");
String search = searches.get(filters.getString("searchName", ""));
if (search != null && Strings.isNotBlank(filters.getString("searchKeyword"))) {
cnd.where().andLike(search, filters.getString("searchKeyword").trim());
}
// 排序仅允许 PC 已提供的字段,避免将客户端任意字符串当成 SQL 字段或方向。
Map<String, String> orders = Map.of("bxr_unionname", "u.unionname", "activity_type", "act.activity_type",
"absName", "abs.name", "apply_type", "act.apply_type", "apply_time", "act.apply_time", "state_id", "act.state_id");
String orderColumn = orders.get(filters.getString("pageOrderName", ""));
String direction = filters.getString("pageOrderBy", "");
if (orderColumn != null && List.of("asc", "desc", "ascending", "descending").contains(direction)) {
cnd.orderBy(orderColumn, direction.startsWith("asc") ? "asc" : "desc");
} else {
cnd.asc("act.state_id").desc("act.apply_time");
}
cnd.desc("act.id");
Sql sql = reviewSql();
sql.setCondition(cnd);
return listPageMap(Math.max(1, filters.getInt("pageNumber", 1)),
Math.min(100, Math.max(1, filters.getInt("pageSize", 10))), sql);
}
/** id 为申请ID;列表、详情及提交使用同一节点范围,防止通过替换 ID 跨范围读取或审批。 */
private NutMap requireReviewRecord(String stage, String id) {
if (Strings.isBlank(id)) throw new IllegalArgumentException("请选择活动申请");
Sql sql = reviewSql();
sql.setCondition(reviewScope(stage).and("act.id", "=", id).and("act.state_id", ">=", reviewState(stage)));
sql.setCallback(Sqls.callback.map());
dao().execute(sql);
NutMap record = (NutMap) sql.getResult();
if (record == null || record.isEmpty()) throw new IllegalArgumentException("申请不存在或无权访问");
return record;
}
@Override
public NutMap reviewDetail(String stage, String id) {
NutMap record = requireReviewRecord(stage, id);
NutMap detail = queryOne(id);
if (detail == null) throw new IllegalArgumentException("申请不存在");
detail.setv("canReview", detail.getInt("state_id") == reviewState(stage));
detail.setv("clubName", record.getString("clubName"));
detail.setv("bxr_unionname", record.getString("bxr_unionname"));
detail.setv("bxr_unitname", record.getString("bxr_unitname"));
return detail;
}
@Override
public List<Goods> reviewGoods(String stage, String id) {
requireReviewRecord(stage, id);
return dao().query(Goods.class, Cnd.where("reid", "=", id));
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void reviewActivity(String stage, NutMap form) {
int state = reviewState(stage);
String id = form.getString("id");
String idea = form.getString("idea");
String sign = form.getString("sign");
if (!(form.get("flag") instanceof Boolean) || Strings.isBlank(id) || Strings.isBlank(idea)
|| idea.trim().length() > 500 || Strings.isBlank(sign)) {
throw new IllegalArgumentException("请填写1至500字审核意见并签字,选择审核结果");
}
boolean flag = form.getBoolean("flag");
NutMap record = requireReviewRecord(stage, id);
if (record.getInt("state_id") != state) throw new IllegalArgumentException("该申请已被处理,请刷新列表");
ActivityBx activity = fetch(id);
if (activity == null) throw new IllegalArgumentException("申请不存在");
String field = "division".equals(stage) ? "division_chairman" : "accounting".equals(stage) ? "accounting" : "union_chairman";
String title = "division".equals(stage) ? "基层工会主席" : "accounting".equals(stage) ? "校工会负责人" : "校工会主席";
String next = "division".equals(stage) ? "校工会负责人审核" : "accounting".equals(stage) ? "校工会主席审核" : "审核通过";
String nodeName = flag ? next : title + "拒绝";
int nextState = flag ? (state == 1010 ? 1020 : state == 1020 ? 1040 : 1060) : state + 5;
Chain changes = Chain.make("state_id", String.valueOf(nextState)).add(field + "_name", ShiroUtil.getPrincipalProperty("id").toString())
.add(field + "_time", DateUtil.getDate()).add(field + "_idea", idea.trim());
// PC 已取消费用编辑;没有提交该字段时保留原值,不能用缺省空值覆盖实际费用。
if ("union".equals(stage) && form.containsKey("activity_money") && form.get("activity_money") != null) {
Double money = form.getDouble("activity_money");
if (money == null || !Double.isFinite(money) || money < 0) throw new IllegalArgumentException("实际费用格式错误");
changes.add("activity_money", money);
}
// 先用待审状态条件取得本次审核权,并发失败不写签字、不创建活动、不发送通知。
int changed = dao().update(ActivityBx.class, changes, Cnd.where("id", "=", id).and("state_id", "=", state));
if (changed != 1) throw new IllegalArgumentException("该申请已被处理,请刷新列表");
String signatureId = sysSignatureService.insert(new Sys_signature(sign)).getId();
dao().update(ActivityBx.class, Chain.make(field, signatureId), Cnd.where("id", "=", id));
// 完成当前任务后再拒绝流程,确保审核人、审核意见及完成时间能够写入当前待办。
FundsToHandler complete = "division".equals(stage) ? FundsToHandler.COMPLETE_UNION_TASK
: "accounting".equals(stage) ? FundsToHandler.COMPLETE_SCHOOL_FZR_TASK : FundsToHandler.COMPLETE_SCHOOL_TASK;
complete.exec(id, new NutMap().addv("option", nodeName), null);
if (!flag) {
FundsToHandler.REFUSE_TASK.exec(id, new NutMap().addv("nodeName", nodeName), null);
Sys_user applicant = dao().fetch(Sys_user.class, activity.getUser_id());
if (applicant != null) {
msgApi.sendWxMsg("活动申报", "", "text", "您申报的活动【" + activity.getActivity_name()
+ "】被" + title + "拒绝,审核意见:" + idea.trim(), List.of(applicant.getLoginname()), null);
}
return;
}
if ("union".equals(stage)) {
createApprovedActivity(activity);
Sys_user applicant = dao().fetch(Sys_user.class, activity.getUser_id());
if (applicant != null) msgApi.sendWxMsg("活动申报", "", "text", "您申报的活动【" + activity.getActivity_name()
+ "】已审核通过,请前往智慧工会进行查看。", List.of(applicant.getLoginname()), null);
return;
}
Sys_user reviewer;
if ("division".equals(stage)) {
// 沿用 PC 指定的校工会负责人账号,不改变待办接收规则。
reviewer = dao().fetch(Sys_user.class, Cnd.where("loginname", "=", "1019911404"));
} else {
Sys_user_role role = dao().fetch(Sys_user_role.class, Cnd.where("roleId", "=", Roles.GH_WYH_ZX));
reviewer = role == null ? null : dao().fetch(Sys_user.class, role.getUserId());
}
if (reviewer == null || Strings.isBlank(reviewer.getLoginname())) {
throw new IllegalArgumentException("未配置下一环节审核人,请联系管理员");
}
List<String> reviewers = List.of(reviewer.getLoginname());
FundsToHandler create = "division".equals(stage) ? FundsToHandler.CREATE_SCHOOL_FZR_TASK : FundsToHandler.CREATE_SCHOOL_TASK;
create.exec(id, null, reviewers);
msgApi.sendWxMsg("活动申报", "", "text", activity.getUser_name() + "申报了一条活动【" + activity.getActivity_name()
+ "】,请前往智慧工会进行审核。", reviewers, null);
}
/** 主席通过后沿用 PC 的活动生成字段;已有活动关联时不重复生成,事务随审核一起提交。 */
@Aop(TransAop.READ_COMMITTED)
protected void createApprovedActivity(ActivityBx activity) {
if (Strings.isNotBlank(activity.getActivity_id())) return;
User applicant = dao().fetch(User.class, Cnd.where("id", "=", activity.getUser_id()));
if (applicant == null) throw new IllegalArgumentException("申请人信息不存在");
ActivityTissue tissue = new ActivityTissue();
tissue.setId(activity.getId());
tissue.setApplyTime(DateUtil.getDate());
tissue.setType(activity.getActivity_type());
tissue.setActivityCode(activity.getProjectCode());
tissue.setName(activity.getActivity_name());
tissue.setUserId(activity.getUser_id());
tissue.setStartPlannedDate(activity.getStartPlannedDate());
tissue.setEndPlannedDate(activity.getEndPlannedDate());
tissue.setStartTime(activity.getStartTime());
tissue.setEndTime(activity.getEndTime());
tissue.setAddress(activity.getAddress());
if (Integer.valueOf(40002).equals(activity.getActivity_type())) tissue.setUnionId(applicant.getUnionid());
else if (Integer.valueOf(40003).equals(activity.getActivity_type())) tissue.setClubId(activity.getClubId());
tissue.setProjectTypeCode("50006");
dao().insert(tissue);
dao().update(ActivityBx.class, Chain.make("activity_id", tissue.getId()), Cnd.where("id", "=", activity.getId()));
}
@Override
public Pagination associationPage(NutMap filters) {
Sql sql = Sqls.create("""
SELECT act.*, u.loginname bxr_loginname, u.unionname bxr_unionname,
s.state_name, s.state_color, abs.name absName, cl.name clubName
FROM activity_bx act
LEFT JOIN `user` u ON act.user_id = u.id
LEFT JOIN state s ON act.state_id = s.state_id
LEFT JOIN activity_basic_settings abs ON abs.code = act.projectJfCode
LEFT JOIN sys_club cl ON cl.id = act.clubId
$condition
""");
Cnd cnd = associationScope("act.");
int audit = filters.getInt("isAudit", 3);
// “已审核”沿用 PC 的环节口径,包括拒绝和进入后续环节的申请。
cnd.and("act.state_id", audit == 1 ? ">=" : audit == 2 ? ">" : "=", 1000);
Vi.cndPlus(cnd, "act.year", "=", filters.get("year"));
Vi.cndPlus(cnd, "act.clubId", "=", filters.getString("clubId"));
Vi.cndPlus(cnd, "u.unitid", "=", filters.getString("unitId"));
Vi.cndPlus(cnd, "u.unionid", "=", filters.getString("unionId"));
Map<String, String> searchColumns = Map.of("username", "u.username", "loginname", "u.loginname");
String searchColumn = searchColumns.get(filters.getString("searchName", ""));
if (searchColumn != null && Strings.isNotBlank(filters.getString("searchKeyword"))) {
cnd.where().andLike(searchColumn, filters.getString("searchKeyword"));
}
// 不将客户端字段名直接拼入 SQL;兼容 PC 表格已有排序字段。
Map<String, String> orderColumns = Map.of(
"bxr_unionname", "u.unionname", "activity_type", "act.activity_type",
"absName", "abs.name", "apply_type", "act.apply_type",
"apply_time", "act.apply_time", "state_id", "act.state_id");
String orderColumn = orderColumns.get(filters.getString("pageOrderName", ""));
String order = filters.getString("pageOrderBy", "");
if (orderColumn != null && List.of("asc", "desc", "ascending", "descending").contains(order)) {
cnd.orderBy(orderColumn, order.startsWith("asc") ? "asc" : "desc");
} else {
cnd.asc("act.state_id").desc("act.apply_time");
}
cnd.desc("act.id");
sql.setCondition(cnd);
return listPageMap(Math.max(1, filters.getInt("pageNumber", 1)),
Math.min(100, Math.max(1, filters.getInt("pageSize", 10))), sql);
}
/** alias 为查询表别名前缀(实体查询传空串);协会范围来自登录用户角色,不能由前端放宽。 */
private Cnd associationScope(String alias) {
Cnd cnd = Cnd.where(alias + "activity_type", "=", 40003);
if (!ShiroUtil.hasAnyRoles("sysadmin")) {
cnd.and(alias + "clubId", "in", vi.getMangeClubStr());
}
return cnd;
}
@Override
public List<Sys_club> associationClubs() {
Cnd cnd = Cnd.NEW();
if (!ShiroUtil.hasAnyRoles("sysadmin")) {
cnd.and("id", "in", vi.getMangeClubStr());
}
return dao().query(Sys_club.class, cnd.asc("name"));
}
/** 详情和提交均校验数据归属,禁止仅凭业务 ID 读取或审核其他协会的申请。 */
private ActivityBx requireAssociation(String id) {
ActivityBx activity = fetch(associationScope("").and("id", "=", id).and("state_id", ">=", 1000));
if (activity == null) {
throw new IllegalArgumentException("申请不存在或无权访问");
}
return activity;
}
@Override
public NutMap associationDetail(String id) {
requireAssociation(id);
NutMap detail = queryOne(id);
if (detail == null) {
throw new IllegalArgumentException("申请不存在");
}
detail.setv("canReview", "1000".equals(detail.getString("state_id")));
Sys_club club = dao().fetch(Sys_club.class, detail.getString("clubId"));
detail.setv("clubName", club == null ? detail.getString("fundsUnitName") : club.getName());
return detail;
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void reviewAssociation(String id, String idea, String sign, boolean flag) {
if (Strings.isBlank(id) || Strings.isBlank(idea) || idea.trim().length() > 500 || Strings.isBlank(sign)) {
throw new IllegalArgumentException("请填写完整的审核信息");
}
ActivityBx activity = requireAssociation(id);
// 带状态条件的更新取得本次审核权;并发请求只有一个能成功,失败请求不产生签字、待办或通知。
int changed = dao().update(ActivityBx.class, Chain.make("state_id", flag ? "1020" : "1005")
.add("association_principal_id", ShiroUtil.getUserId())
.add("association_principal_time", DateUtil.getDate())
.add("association_principal_idea", idea.trim()),
associationScope("").and("id", "=", id).and("state_id", "=", 1000));
if (changed != 1) {
throw new IllegalArgumentException("该申请已被处理,请刷新列表");
}
String signatureId = sysSignatureService.insert(new Sys_signature(sign)).getId();
dao().update(ActivityBx.class, Chain.make("association_principalSign_id", signatureId),
Cnd.where("id", "=", id));
String nodeName = flag ? "校工会负责人审核" : "协会审核拒绝";
// 先完成协会任务并记录处理人,再进入下一环节或结束流程,避免拒绝时先关闭任务丢失处理记录。
FundsToHandler.COMPLETE_CLUB_TASK.exec(id, new NutMap().addv("option", nodeName), null);
if (flag) {
List<String> reviewers = List.of("1019911404");
FundsToHandler.CREATE_SCHOOL_FZR_TASK.exec(id,
new NutMap().addv("userId", activity.getUser_id()), reviewers);
msgApi.sendWxMsg("活动申报", "", "text", activity.getUser_name() + "申报了一条活动【"
+ activity.getActivity_name() + "】,请前往智慧工会进行审核。", reviewers, null);
} else {
FundsToHandler.REFUSE_TASK.exec(id, new NutMap().addv("nodeName", nodeName), null);
Sys_user user = dao().fetch(Sys_user.class, activity.getUser_id());
if (user != null) {
msgApi.sendWxMsg("活动申报", "", "text", "您申报的活动【" + activity.getActivity_name()
+ "】被协会拒绝,审核意见:" + idea.trim(), List.of(user.getLoginname()), null);
}
}
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void doAdd(ActivityBx activityBx) {
@@ -0,0 +1,128 @@
/**
* H5 弹框统一历史栈。页面注册一次 sync 回调;每次 open 增加一层历史,
* close/clear 等待浏览器回退后再同步界面,避免返回键越过弹框直接离开业务页。
* 同一窗口只安装一个 popstate 监听器;未接入的页面不受影响。
*/
window.mobilePopupHistory = window.mobilePopupHistory || (() => {
let current = null
let sequence = 0
const marker = '__mobilePopup'
const snapshot = (session, depth) => Object.assign({}, window.history.state || {}, {
[marker]: {id: session.id, depth}
})
const publish = (session) => {
session.sync(session.layers.slice())
session.listeners.forEach((sync, name) => sync(session.layers.includes(name)))
}
const onPop = (event) => {
const session = current
if (!session) return
const state = event.state && event.state[marker]
const depth = state && state.id === session.id ? state.depth : 0
// 浏览器前进到已关闭的弹框记录时不恢复旧业务内容,而将该条记录标记为当前深度。
if (depth > session.layers.length) {
window.history.replaceState(snapshot(session, session.layers.length), '')
} else {
session.layers = session.layers.slice(0, depth)
}
publish(session)
const resolve = session.pending
session.pending = null
if (resolve) resolve()
// 普通表单的公共弹框自行创建临时会话,最后一层关闭后释放,不占用下一业务页。
if (session.automatic && !session.layers.length) session.api.dispose()
}
window.addEventListener('popstate', onPop)
const manager = {
/** 公共导航先关闭最上层弹框;返回 true 表示已消费本次返回操作,不应跳转业务页。 */
closeTop() {
if (!current || !current.layers.length) return false
current.api.close()
return true
},
/**
* key 为页面名称,sync 接收从底层到顶层的弹框名称数组。
* 返回 open/close/clear/dispose;close、clear 返回 Promise,完成后可安全跳转。
*/
register(key, sync) {
if (current) {
throw new Error('请先注销上一页面的弹框历史回调')
}
const session = {id: key + '-' + Date.now() + '-' + (++sequence), sync, layers: [], pending: null, waiting: null, listeners: new Map()}
current = session
window.history.replaceState(snapshot(session, 0), '')
const back = (count) => {
if (session.pending) return session.waiting
if (!count || current !== session) return Promise.resolve(true)
session.waiting = new Promise((resolve) => {
session.pending = resolve
window.history.go(-count)
})
return session.waiting
}
session.back = back
session.api = {
open(name) {
if (current !== session || session.pending || session.layers.includes(name)) return
session.layers.push(name)
window.history.pushState(snapshot(session, session.layers.length), '')
publish(session)
},
close() {
return back(session.layers.length ? 1 : 0)
},
clear() {
// 清理可能恰逢手机返回事件尚未落地,先等该次回退完成,再清除剩余层。
if (session.pending) return session.waiting.then(() => back(session.layers.length))
return back(session.layers.length)
},
dispose() {
if (current !== session) return
session.sync = () => {}
session.listeners.clear()
current = null
const resolve = session.pending
session.pending = null
if (resolve) resolve(false)
}
}
return session.api
},
/**
* 公共组件创建独立层;key 为组件名称,sync(boolean) 同步显隐。
* open 加入当前页面栈,无页面栈时自动创建;close 返回回退完成的 Promise。
* dispose 解除回调并清除本层及其上层记录,供组件销毁或页面跳转前调用。
*/
createLayer(key, sync) {
const name = key + '-' + (++sequence)
let owner = null
const close = () => {
if (!owner || owner !== current) return Promise.resolve()
const session = owner
if (session.pending) return session.waiting.then(close)
const index = session.layers.indexOf(name)
return session.back(index < 0 ? 0 : session.layers.length - index)
}
return {
open() {
if (!current) {
manager.register(key, () => {})
current.automatic = true
}
if (owner && owner !== current) owner.listeners.delete(name)
owner = current
owner.listeners.set(name, sync)
owner.api.open(name)
},
close,
dispose() {
if (owner) owner.listeners.delete(name)
return close()
}
}
}
}
return manager
})()
@@ -1,192 +1,131 @@
<template>
<div class="signature-wrap">
<canvas id="canvas"></canvas>
<div ref="surface" class="signature-surface">
<canvas ref="canvas" aria-label="手写签字区域"></canvas>
</div>
<div class="action-buttons">
<button @click="clear" class="action-button-danger" type="button">清空</button>
<button @click="undo" class="action-button-warning" type="button">撤销</button>
<button @click="save" class="action-button-primary" type="button">确定</button>
<button @click="clear" type="button" :disabled="busy">清空</button>
<button @click="undo" type="button" :disabled="busy">撤销</button>
<button @click="save" class="action-button-primary" type="button" :disabled="busy">{{ busy ? '保存中' : '确定' }}</button>
</div>
</div>
</template>
<script>
// function a() {
// // 检查当前屏幕方向
// if (window.orientation === 0 || window.orientation === 180) {
// // 竖屏状态
// console.log("竖屏状态")
// alert("当前不支持竖屏,请将设备调整为横屏!")
// } else {
// // 横屏状态,执行禁止旋转的操作
// alert("当前不支持横屏,请将设备调整为竖屏!")
// // 可以在这里使内容固定在竖屏方向
// const innerWidth = window.innerWidth
// const innerHeight = window.innerHeight
// alert(innerHeight)
// alert(innerWidth)
// }
// }
//
// window.addEventListener("orientationchange", a)
// a()
let signature = null
module.exports = {
name: "sysSignature",
props: {
busy: {type: Boolean, default: false}
},
data() {
return {
content: null,
initialized: false
}
return {initialized: false}
},
methods: {
clear() {
signature.clear()
if (this.$options.signaturePad && !this.busy) this.$options.signaturePad.clear()
},
undo() {
signature.undo()
},
rotate() {
signature.getRotateCanvas(90)
if (this.$options.signaturePad && !this.busy) this.$options.signaturePad.undo()
},
/** save 事件返回与竖屏画布方向一致的 PNG Base64,不旋转签字图片。 */
save() {
if (signature.isEmpty()) {
alert("请签字后再保存")
if (this.busy) return
const signature = this.$options.signaturePad
if (!signature || signature.isEmpty()) {
this.$toast("请签字后再保存")
return
}
// const png = signature.getPNG()
//旋转一下 横过来
const rotateCanvas = signature.getRotateCanvas(-90)
this.$emit("save", rotateCanvas.toDataURL())
this.$emit("save", signature.toDataURL())
},
/** 使用弹框内实际可用尺寸;每个组件独立持有画布,高分屏按像素比绘制。 */
initSignature() {
if (this.initialized) return;
const canvas = document.getElementById("canvas");
if (!canvas) return;
// 检查 canvas 是否已有有效尺寸
const rect = canvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
// 尺寸无效,稍后重试
setTimeout(() => this.initSignature(), 50);
return;
if (this.initialized || this._isDestroyed) return
const canvas = this.$refs.canvas
const surface = this.$refs.surface
if (!canvas || !surface) return
const width = surface.clientWidth
const height = surface.clientHeight
// 隐藏或尚未完成布局时不创建零尺寸画布,销毁时会取消本次重试。
if (width <= 0 || height <= 0) {
this.$options.retryTimer = setTimeout(() => this.initSignature(), 50)
return
}
const padding = 30;
const width = window.innerWidth - padding;
const height = window.innerHeight - padding;
// 设置 CSS 尺寸
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
// 高清绘制
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
// 销毁旧实例(防止重复)
if (signature) {
signature.clear();
}
signature = new SmoothSignature(canvas, {
width: width,
height: height,
scale: dpr,
minWidth: 4,
maxWidth: 10,
color: "#000000"
});
this.initialized = true;
this.$options.signaturePad = new SmoothSignature(canvas, {
width,
height,
scale: window.devicePixelRatio || 1,
minWidth: 2,
maxWidth: 5,
color: "#222222"
})
this.initialized = true
}
},
mounted() {
// 延迟初始化,确保 DOM 渲染完成
this.$nextTick(() => {
this.initSignature();
});
this.$nextTick(() => this.initSignature())
},
beforeDestroy() {
signature = null;
this.initialized = false;
// SmoothSignature 在 document 上绑定结束事件,反复打开时必须解除旧实例监听。
clearTimeout(this.$options.retryTimer)
if (this.$options.signaturePad) this.$options.signaturePad.removeListener()
this.$options.signaturePad = null
this.initialized = false
}
}
</script>
<style scoped>
.signature-wrap {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 15px;
background: #ffffff;
}
.signature-wrap .action {
width: 50px;
display: flex;
justify-content: center;
align-items: center;
}
.signature-wrap .action-buttons {
white-space: nowrap;
transform: rotate(90deg);
display: flex;
column-gap: 10px;
position: fixed;
bottom: 0px;
left: 60px;
transform: rotate(90deg);
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
row-gap: 20px;
padding: 12px 16px;
background: #fff;
}
.signature-surface {
flex: 1;
min-height: 0;
overflow: hidden;
border: 1px dashed #c8c9cc;
border-radius: 8px;
background: #fafbfc;
}
.signature-surface canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.action-buttons {
display: flex;
gap: 12px;
margin-top: 12px;
flex-shrink: 0;
}
.action-buttons button {
color: #ffffff;
position: relative;
display: inline-block;
box-sizing: border-box;
height: auto;
padding: 3px 12px;
margin: 0;
font-size: 15px;
line-height: 1.4;
text-align: center;
border-radius: 4px;
cursor: pointer;
transition: opacity 0.15s ease;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.action-button-primary {
background-color: #1677ff;
border: 1px solid #1677ff;
}
.action-button-danger {
background-color: #ff3141;
border: 1px solid #ff3141;
}
.action-button-warning {
background-color: #ff8f1f;
border: 1px solid #ff8f1f;
}
.signature-wrap canvas {
flex: 1;
border-radius: 10px;
border: 2px dashed #ccc;
height: 36px;
padding: 0;
border: 1px solid #ebedf0;
border-radius: 6px;
background: #fff;
color: #323233;
font-size: 14px;
text-align: center;
}
.action-buttons .action-button-primary {
background: #1989fa;
border-color: #1989fa;
color: #fff;
}
.action-buttons button:disabled {
opacity: .5;
}
</style>
@@ -1,121 +1,173 @@
<template>
<div class="h5-signature">
<div v-if="!showSignaturePanel" class="main-panel">
<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">打开签字板</van-button>
<van-image :src="signatureUrl">
<van-button @click="openSignature" class="open-button" size="middle" plain native-type="button" :disabled="disabled">打开签字板</van-button>
<van-image :src="signatureUrl" fit="contain">
<template v-slot:error>请点击下方打开签字板按钮进行签字</template>
</van-image>
</template>
</div>
<van-popup v-model="showSignaturePanel" :style="{ height: '100vh', width: '100vw', display: 'flex', 'align-items': 'center' }">
<signature v-if="showSignaturePanel" @save="save"></signature>
<van-popup :value="signaturePanelVisible" @input="onPanelInput" :close-on-popstate="false"
position="bottom" round get-container="body" class="h5-signature-sheet"
:close-on-click-overlay="false" @opened="onPanelOpened">
<div class="h5-signature-heading">
<button type="button" :disabled="saving || disabled" @click="closeSignature">取消</button>
<span>{{ panelTitle }}</span>
<span aria-hidden="true"></span>
</div>
<signature v-if="signaturePanelVisible && boardReady" :busy="saving || disabled" @save="save"></signature>
</van-popup>
</div>
</template>
<script>
module.exports = {
name: "h5",
name: "mobileSignature",
components: {
signature: httpVueLoader("/components/sign/index.vue?v=" + new Date().getTime())
},
props: {
// 协会启用路径模式;其他业务仍使用原来的个人 Base64 签名。
// 协会路径模式优先于 saveToProfile,仅上传本次签字,不覆盖个人 Base64 签名。
pathMode: {type: Boolean, default: false},
value: {
type: String,
default: ""
}
},
// 兼容已有 :my_sign.sync 页面;与 v-model 同时传入时优先使用 value。
my_sign: {type: String, default: ""},
// 不传 popupVisible 时保持组件自行开关;传入布尔值时由页面统一历史栈管理。
popupVisible: {type: Boolean, default: null},
// 默认仍保存个人签名;业务只需本次签字时传 false,通过 input 回传 Base64。
saveToProfile: {type: Boolean, default: true},
disabled: {type: Boolean, default: false},
panelTitle: {type: String, default: "签字"}
},
computed: {
signatureUrl() {
if (!this.signatureContent) return ''
return this.pathMode ? '/signature/getSignData?path=' + encodeURIComponent(this.signatureContent) : this.signatureContent
if (!this.signatureContent) return ""
return this.pathMode ? "/signature/getSignData?path=" + encodeURIComponent(this.signatureContent) : this.signatureContent
},
controlled() {
return this.popupVisible !== null
},
signaturePanelVisible() {
return this.controlled ? this.popupVisible : this.showSignaturePanel
},
boundSignature() {
return Object.prototype.hasOwnProperty.call(this.$options.propsData || {}, "value") ? this.value : this.my_sign
}
},
watch: {
value: {
boundSignature: {
handler(val) {
this.signatureContent = val
},
immediate: true
},
signaturePanelVisible(visible) {
// 每次打开重新创建画布;等待弹出动画完成后再按真实可用区域计算尺寸。
if (!visible) this.boardReady = false
}
},
data() {
return {
signatureContent: null,
showSignaturePanel: false,
formLoading: false
boardReady: false,
saving: false
}
},
methods: {
openSignature() {
this.showSignaturePanel = true
if (!this.disabled && !this.saving) this.changePanelVisible(true)
},
/** 受控模式仅发出开关请求,实际显隐由父页面在历史栈同步回调中更新。 */
changePanelVisible(visible) {
if (this.controlled) this.$emit(visible ? "open" : "close")
else if (visible) this.$options.popupLayer.open()
else this.$options.popupLayer.close()
},
closeSignature() {
if (!this.saving && !this.disabled) this.changePanelVisible(false)
},
onPanelInput(visible) {
// 受控弹框的 value 回落也会触发 input,不得再次请求关闭,否则会多退一层详情。
if (!this.controlled && !visible && this.showSignaturePanel) this.closeSignature()
},
onPanelOpened() {
this.boardReady = this.signaturePanelVisible
},
/** data 为签名图片字符串,同时通知两种已有绑定方式,父表单提交字段保持不变。 */
updateSignature(data) {
this.signatureContent = data
this.$emit("input", data)
this.$emit("update:my_sign", data)
},
/** signature 为公共签字板生成的 Base64;本次签字模式不调用个人签名保存接口。 */
save(signature) {
if (this.saving || this.disabled) return
if (this.pathMode) {
if (this.formLoading) return
this.$set(this, 'formLoading', true)
// data 为画板生成的 PNG Base64;成功响应 data 为文件路径,仅将路径回传审核表单。
$.post('/signature/uploadSignData', {data: signature}).then((res) => {
if (res.code === 0 && res.data) {
this.$set(this, 'signatureContent', res.data)
this.$emit('input', res.data)
this.$set(this, 'showSignaturePanel', false)
this.$toast.success('保存成功')
} else {
this.$toast.fail(res.msg || '签字上传失败,请重试')
}
}).fail(() => {
this.$toast.fail('签字上传失败,请重试')
}).always(() => {
this.$set(this, 'formLoading', false)
})
this.$set(this, "saving", true)
// data 参数为画板 PNG Base64;成功响应 data 为文件路径,统一回传两种签字绑定。
return $.post("/signature/uploadSignData", {data: signature})
.then((res) => {
if (this._isDestroyed) return
if (res.code === 0 && res.data) {
this.updateSignature(res.data)
// 上传期间系统返回可能已收起签字板,不能再关闭下一层申请详情。
if (this.signaturePanelVisible) this.changePanelVisible(false)
this.$toast.success("保存成功")
} else {
this.$toast.fail(res.msg || "签字上传失败,请重试")
}
})
.fail(() => {
if (!this._isDestroyed) this.$toast.fail("签字上传失败,请重试")
})
.always(() => { this.$set(this, "saving", false) })
}
if (!this.saveToProfile) {
this.updateSignature(signature)
this.changePanelVisible(false)
return
}
$.post("/mobile/common/signing/update", {sign: signature})
this.saving = true
return $.post("/mobile/common/signing/update", {sign: signature})
.then((res) => {
if (res.code === 0) {
this.$toast.success("保存成功")
this.showSignaturePanel = false
this.getMySign()
this.changePanelVisible(false)
return this.getMySign()
} else {
this.$toast.fail("保存失败")
}
})
.fail(() => this.$toast.fail("签字保存失败,请重试"))
.always(() => { this.saving = false })
},
base64ToFile(base64Data, filename) {
// 将base64的数据部分提取出来
const parts = base64Data.split(";base64,")
const contentType = parts[0].split(":")[1]
const raw = window.atob(parts[1])
const rawLength = raw.length
const uInt8Array = new Uint8Array(rawLength)
for (let i = 0; i < rawLength; ++i) {
uInt8Array[i] = raw.charCodeAt(i)
}
// 使用Blob对象创建File对象
const blob = new Blob([uInt8Array], { type: contentType })
blob.lastModifiedDate = new Date()
blob.name = filename
return new File([blob], filename, { type: contentType })
},
async getMySign() {
const data = await $.get("/mobile/common/signing/getMySign")
if (data) {
this.signatureContent = data
this.$emit("input", data)
}
/** 沿用个人签名查询接口,返回图片数据并同步给已有 v-model 调用方。 */
getMySign() {
return $.get("/mobile/common/signing/getMySign").then((data) => {
if (data) {
this.updateSignature(data)
}
})
}
},
async created() {
//await this.getMySign()
created() {
// 受控审核页已经维护 sign 层;普通页面由公共组件接入同一个全局历史管理器。
this.$options.popupLayer = window.mobilePopupHistory.createLayer("signature", (visible) => {
this.showSignaturePanel = visible
})
// 旧表单通过 PJAX 替换 DOM 时不会自动销毁 Vue,主动释放签字实例和公共栈回调。
$(document).one("pjax:beforeReplace.mobileSignature" + this._uid, () => this.$destroy())
},
beforeDestroy() {
$(document).off("pjax:beforeReplace.mobileSignature" + this._uid)
if (this.$options.popupLayer) this.$options.popupLayer.dispose()
}
}
</script>
@@ -124,14 +176,51 @@ module.exports = {
width: 100%;
}
.main-panel {
.h5-signature-sheet.van-popup {
display: flex;
flex-direction: column;
height: 35vh;
min-height: 260px;
max-height: 90vh;
padding-bottom: env(safe-area-inset-bottom);
box-sizing: border-box;
overflow: hidden;
background: #fff;
}
.h5-signature-heading {
flex: 0 0 44px;
display: grid;
grid-template-columns: 64px 1fr 64px;
align-items: center;
text-align: center;
border-bottom: 1px solid #f2f3f5;
color: #323233;
font-size: 16px;
font-weight: 500;
}
.h5-signature-heading button {
height: 44px;
padding: 0;
border: 0;
background: transparent;
color: #1989fa;
font-size: 14px;
}
.h5-signature-heading button:disabled {
opacity: .5;
}
.h5-signature .main-panel {
position: relative;
flex-direction: column-reverse;
display: flex;
row-gap: 10px;
}
.main-panel .van-image {
.h5-signature .van-image {
min-height: 150px;
display: block;
width: 90%;
@@ -140,7 +229,7 @@ module.exports = {
border-radius: 8px;
}
.main-panel .open-button {
.h5-signature .open-button {
margin: 0 auto;
width: 90%;
height: 36px;
@@ -203,7 +203,7 @@ layout("/mobile/platform.html"){
></van-empty>
</div>
<van-popup v-model="infoShow" :close-on-click-overlay="false" 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
@@ -243,7 +243,9 @@ layout("/mobile/platform.html"){
<div class="mb10">审核签字</div>
</template>
<template #input>
<mobile-sign :path-mode="true" ref="signature" v-model="formData.auditSign" @hook:mounted="bindSignatureHistory"></mobile-sign>
<mobile-sign :path-mode="true" ref="signature" v-model="formData.auditSign"
:popup-visible="signatureShow" :disabled="formLoading"
@open="openSignature" @close="closePopup"></mobile-sign>
</template>
</van-field>
@@ -280,42 +282,35 @@ 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.4'),
'mobile-sign': httpVueLoader('/components/sign/mobileSign.vue?v=1.0.1'),
'mobile-sign': httpVueLoader('/components/sign/mobileSign.vue?v=1.0.2'),
},
methods: {
// 使用项目统一历史栈;先关闭签字板,再关闭申请详情。
syncPopup(key, visible) {
if (key === 'info') this.$set(this, 'infoShow', visible)
if (key === 'signature' && this.$refs.signature) {
this.$set(this.$refs.signature, 'showSignaturePanel', visible)
}
// 使用远端统一历史会话;签字层和详情层由同一回调驱动,避免重复压栈。
syncPopup(layers) {
this.$set(this, 'infoShow', layers.includes('info'))
this.$set(this, 'signatureShow', layers.includes('signature'))
},
closePopup() {
return mobilePopupHistory.close()
return this.$options.popupSession.close()
},
openSignature() {
this.$options.popupSession.open('signature')
},
leavePage() {
if (mobilePopupHistory.stack.length) return this.closePopup()
if (this.infoShow || this.signatureShow) return this.closePopup()
this.disposePopup()
pjaxReplace('/mobile/index')
},
disposePopup() {
if (this.stopSignatureWatch) this.stopSignatureWatch()
mobilePopupHistory.unregister(this.syncPopup)
if (this.$options.popupSession) this.$options.popupSession.dispose()
$(document).off('pjax:beforeSend.clubAudit')
},
// 仅在本页适配现有签字组件,不修改其他业务共用的签字实现。
bindSignatureHistory() {
if (this.stopSignatureWatch) this.stopSignatureWatch()
const signature = this.$refs.signature
this.stopSignatureWatch = signature.$watch('showSignaturePanel', visible => {
if (visible) mobilePopupHistory.open('signature')
else if (mobilePopupHistory.stack.includes('signature')) this.closePopup()
})
$(document).off('pjax:beforeReplace.clubAudit')
},
openAudit(row) {
this.$set(this, 'formData', {
@@ -327,12 +322,12 @@ layout("/mobile/platform.html"){
})
this.$set(this, 'applyId', row.id)
this.$set(this, 'handle', true)
mobilePopupHistory.open('info')
this.$options.popupSession.open('info')
},
openView(row) {
this.$set(this, 'applyId', row.id)
this.$set(this, 'handle', false)
mobilePopupHistory.open('info')
this.$options.popupSession.open('info')
},
// id 为申请记录,flag 为是否通过;响应 code 为 0 时清理弹框历史并刷新列表。
doReview(flag) {
@@ -348,7 +343,7 @@ layout("/mobile/platform.html"){
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) mobilePopupHistory.clear().then(() => this.doSearch())
if (resp.code === 0) this.$options.popupSession.clear().then(() => this.doSearch())
vant.Toast(resp.msg)
}).fail(() => vant.Toast('审核提交失败,请重试'))
.always(() => { this.$set(this, 'formLoading', false) })
@@ -377,12 +372,12 @@ layout("/mobile/platform.html"){
},
},
created() {
mobilePopupHistory.register(this.syncPopup)
this.$options.popupSession = window.mobilePopupHistory.register('clubAudit', this.syncPopup)
// PJAX 离开前清理本页历史,再执行原跳转,防止返回到已关闭弹框。
$(document).on('pjax:beforeSend.clubAudit', (event, xhr, options) => {
if (mobilePopupHistory.stack.length) {
if (this.infoShow || this.signatureShow) {
event.preventDefault()
mobilePopupHistory.clear().then(() => {
this.$options.popupSession.clear().then(() => {
this.disposePopup()
pjaxReplace(options.url)
})
@@ -390,6 +385,8 @@ layout("/mobile/platform.html"){
}
this.disposePopup()
})
// 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}))))
@@ -407,7 +404,12 @@ layout("/mobile/platform.html"){
this.onLoad()
},
beforeDestroy() {
mobilePopupHistory.clear().then(() => this.disposePopup())
// 空栈同步注销,防止下一页注册时旧会话尚未释放。
if (this.infoShow || this.signatureShow) {
this.$options.popupSession.clear().then(() => this.disposePopup())
} else {
this.disposePopup()
}
}
})
</script>
@@ -0,0 +1,18 @@
<!--#
layout("/mobile/platform.html"){
#-->
<!--#include('/mobile/jf/activitybx/review/index.html'){}#-->
<script>
// 节点配置由当前页面固定提供,业务权限及状态仍由服务端校验。
const activityReviewConfig = {
stage: 'accounting', title: '校工会负责人审核', state: 1020,
confirmMessage: '通过后将转交校工会主席审核。',
canChooseUnion: ${@shiro.hasRole('sysadmin')||@shiro.hasRole('A06')||@shiro.hasRole('H03')},
currentUnionId: '${@io.v.nutz.base.utils.Vi.getUnionId()}'
}
<!--#include('/mobile/jf/activitybx/review/info.js'){}#-->
<!--#include('/mobile/jf/activitybx/review/page.js'){}#-->
</script>
<!--#
}
#-->
@@ -0,0 +1,135 @@
<!--#
layout("/mobile/platform.html"){
#-->
<style>
.association-page { min-height: 100vh; background: #f6f7f9; color: #323233; font-size: 13px; }
.association-page .van-nav-bar__title { font-size: 15px; font-weight: 400; }
.association-filters .van-dropdown-menu__bar { box-shadow: none; border-top: 1px solid #f8f8f8; border-bottom: 1px solid #f8f8f8; }
.association-filters .van-dropdown-menu__item { flex: 1; min-width: 0; }
.association-filters .van-dropdown-menu__title { max-width: calc(100% - 40px); padding: 0 8px; font-size: 14px; }
.association-filters .van-dropdown-item__content { max-height: 70vh; border-radius: 0 0 15px 15px; }
.association-list { padding: 2px 0 12px; }
.association-card { margin: 12px 13px 0; padding: 11px 12px 10px; background: white; border-radius: 10px; box-shadow: 0 5px 12px rgba(30,50,70,.04); }
.association-card h3 { position: relative; margin: 0 0 7px; color: #086bc3; font-size: 15px; line-height: 21px; font-weight: 600; overflow-wrap: anywhere; }
.association-card h3:before { content: ''; position: absolute; left: -10px; top: 3px; height: 15px; border-left: 2px solid #1989fa; }
.association-card p { margin: 0; line-height: 26px; color: #85878d; overflow-wrap: anywhere; }
.association-card label { color: #242424; }
.association-card .association-pair { display: flex; gap: 10px; justify-content: space-between; }
.association-pair span:last-child { flex-shrink: 0; }
.association-card-footer { display: flex; align-items: center; gap: 8px; margin-top: 7px; padding-top: 7px; border-top: 1px solid #f5f5f5; }
.association-state { flex: 1; min-width: 0; font-size: 12px; color: #b7790c; overflow-wrap: anywhere; }
.association-card-footer .van-button { height: 25px; padding: 0 8px; font-size: 13px; border-radius: 6px; flex-shrink: 0; }
.association-popup { display: flex; flex-direction: column; overflow: hidden; background: #f6f7f9; height: 100%; width: 100%; }
.association-popup > .van-nav-bar { flex-shrink: 0; }
.association-detail-body { flex: 1; overflow-y: auto; min-height: 0; padding-bottom: 12px; }
.association-section { margin: 12px; padding: 10px 0; border-radius: 10px; background: white; overflow: hidden; }
.association-section h3 { margin: 4px 14px 8px; font-size: 15px; color: #1768b0; }
.association-section .van-cell { font-size: 13px; padding: 8px 14px; }
.association-section .van-cell__title { flex: 0 0 88px; }
.association-section .van-cell__value { white-space: pre-wrap; overflow-wrap: anywhere; }
.association-section .association-file .van-cell__title { flex: 1; overflow-wrap: anywhere; }
.association-section .association-file .van-cell__value { flex: 0 0 40px; }
.association-content-image { display: block; max-width: calc(100% - 28px); margin: 10px 14px; }
.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-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; }
.association-section .association-sign-field { display: block; }
.association-sign-field .van-field__label { width: auto; margin: 0 0 10px; }
.association-sign-field .main-panel .van-image { width: 100%; height: 112px; min-height: 112px; overflow: hidden; border-radius: 6px; }
.association-sign-field .main-panel .open-button { width: 100%; border-radius: 6px; }
.association-preview { display: block; width: 100%; height: calc(100% - 46px); border: none; background: white; }
.association-preview-image { width: 100%; height: calc(100% - 46px); object-fit: contain; }
.association-loading { padding: 30px; text-align: center; }
</style>
<div id="app" class="association-page" v-cloak>
<van-sticky>
<van-nav-bar title="协会活动审核" left-arrow @click-left="back"></van-nav-bar>
<van-dropdown-menu class="association-filters" active-color="#1867b0"
:close-on-click-outside="false" :close-on-click-overlay="false">
<van-dropdown-item ref="yearFilter" :options="yearOptions" :value="pageForm.year" :disabled="filterClosing"
@change="selectFilter('year', $event)"
@open="setFilterOpen('year', true)" @close="setFilterOpen('year', false)"></van-dropdown-item>
<van-dropdown-item ref="clubIdFilter" :options="clubs" :value="pageForm.clubId" :disabled="filterClosing"
@change="selectFilter('clubId', $event)"
@open="setFilterOpen('clubId', true)" @close="setFilterOpen('clubId', false)">
<van-loading v-if="clubsLoading" class="association-loading">加载协会中</van-loading>
</van-dropdown-item>
<van-dropdown-item ref="isAuditFilter" :options="auditOptions" :value="pageForm.isAudit" :disabled="filterClosing"
@change="selectFilter('isAudit', $event)"
@open="setFilterOpen('isAudit', true)" @close="setFilterOpen('isAudit', false)"></van-dropdown-item>
</van-dropdown-menu>
</van-sticky>
<van-pull-refresh :value="refreshing" @input="setRefreshing" @refresh="doSearch">
<van-list :value="tableLoading" :finished="finished" :error="listError" error-text="加载失败,点击重试"
:finished-text="tableData.length ? '没有更多了' : ''" :immediate-check="false"
@input="setTableLoading" @update:error="setListError" @load="pageData" class="association-list">
<article v-for="item in tableData" :key="item.id" class="association-card">
<h3>{{ item.activity_name }}</h3>
<p><label>申请人:</label>{{ item.user_name }}<span v-if="item.bxr_loginname">({{ item.bxr_loginname }})</span></p>
<p><label>所属协会:</label>{{ item.clubName || item.fundsUnitName || '—' }}</p>
<p class="association-pair"><span><label>预算:</label>¥{{ money(item.funds_money) }}</span><span><label>申请:</label>{{ shortDate(item.apply_time) }}</span></p>
<div class="association-card-footer">
<span class="association-state" :style="{color: item.state_color || '#b7790c'}">{{ item.state_name || '待协会负责人审核' }}</span>
<van-button type="info" size="small" :disabled="formLoading" @click="openDetail(item.id, false)">查看</van-button>
<van-button v-if="Number(item.state_id) === 1000" type="primary" size="small" :disabled="formLoading" @click="openDetail(item.id, true)">审核</van-button>
</div>
</article>
<van-empty v-if="!tableLoading && !listError && !tableData.length" description="暂无申请"></van-empty>
</van-list>
</van-pull-refresh>
<van-popup :value="popups.detail" position="right" class="association-popup" :close-on-popstate="false" :close-on-click-overlay="false">
<van-nav-bar :title="auditMode ? '协会负责人审核' : '申请详情'" left-arrow @click-left="closePopup"></van-nav-bar>
<div class="association-detail-body">
<van-loading v-if="detailLoading" class="association-loading">加载申请中</van-loading>
<van-empty v-else-if="!detail.id" description="详情加载失败">
<van-button size="small" type="info" @click="loadDetail(detailId)">重新加载</van-button>
</van-empty>
<association-info v-else :data="detail" @preview="previewFile">
<section v-if="auditMode && detail.canReview" class="association-section">
<h3>审核信息</h3>
<van-field label="审核人" :value="reviewerName" readonly></van-field>
<van-field label="审核时间" :value="reviewDate" readonly></van-field>
<van-field label="审核意见" :value="formData.idea" @input="setIdea" type="textarea" rows="3"
maxlength="500" show-word-limit required placeholder="请填写审核意见" :disabled="formLoading"></van-field>
<van-field label="审核签字" required class="association-sign-field">
<template #input>
<mobile-sign :value="formData.sign" @input="setSignature" :disabled="formLoading"
:save-to-profile="false" :popup-visible="popups.sign" panel-title="审核签字"
@open="openSignature" @close="closePopup"></mobile-sign>
</template>
</van-field>
</section>
</association-info>
</div>
<div v-if="detail.id && !detailLoading" class="association-actions">
<template v-if="auditMode && detail.canReview">
<van-button type="danger" plain :disabled="formLoading" @click="confirmReview(false)">拒绝</van-button>
<van-button type="primary" :loading="formLoading" :disabled="formLoading" @click="confirmReview(true)">通过</van-button>
</template>
<van-button v-else-if="detail.canReview" type="primary" @click="startAudit">去审核</van-button>
<van-button v-else @click="closePopup">关闭</van-button>
</div>
</van-popup>
<van-dialog :value="popups.confirm" :title="reviewFlag ? '确认通过' : '确认拒绝'"
:message="reviewFlag ? '通过后将转交校工会负责人审核。' : '拒绝后将通知申请人,请核对审核意见。'"
show-cancel-button :allow-html="false" :close-on-click-overlay="false" :close-on-popstate="false"
:before-close="beforeReviewClose"></van-dialog>
<van-popup :value="popups.preview" position="right" class="association-popup" :close-on-popstate="false" :close-on-click-overlay="false">
<van-nav-bar title="附件查看" left-arrow @click-left="closePopup"></van-nav-bar>
<img v-if="popups.preview && previewIsImage" :src="previewUrl" class="association-preview-image" alt="申请附件">
<iframe v-else-if="popups.preview" :src="previewUrl" title="申请附件" class="association-preview"></iframe>
</van-popup>
</div>
<script>
<!--#include('info.js'){}#-->
<!--#include('page.js'){}#-->
</script>
<!--#
}
#-->
@@ -0,0 +1,104 @@
/** 协会活动申报详情:仅展示服务端已校验权限的数据,审核表单通过末尾插槽传入。 */
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 || '')
}
}
}
@@ -0,0 +1,272 @@
// 独立手机审核页面;不复用旧 declare 页面及其有缺陷的滚动分页。
const vue = new Vue({
el: '#app',
components: {'association-info': associationInfo},
data() {
return {
baseUrl: '/platform/jf/activity_bx/association',
pageForm: {year: new Date().getFullYear(), clubId: '', isAudit: 3, pageNumber: 1, pageSize: 10},
tableData: [], tableLoading: false, refreshing: false, finished: false, listError: false,
listSerial: 0, listBusy: false, detailSerial: 0,
clubs: [{text: '全部协会', value: ''}], clubsLoading: false,
auditOptions: [{text: '未审核', value: 3}, {text: '已审核', value: 2}, {text: '全部', value: 1}],
filterOpen: {year: false, clubId: false, isAudit: false}, filterClosing: false,
popups: {filter: false, detail: false, sign: false, confirm: false, preview: false},
detail: {}, detailId: '', detailLoading: false, auditMode: false,
formData: {id: '', idea: '', sign: ''}, formLoading: false, reviewFlag: true,
reviewerName: '', reviewDate: '', previewUrl: '', previewIsImage: false
}
},
computed: {
yearOptions() {
return Array.from({length: 11}, (_, index) => {
const year = new Date().getFullYear() - index
return {text: year + '年', value: year}
})
}
},
methods: {
money(value) {
return value == null || !Number.isFinite(Number(value)) ? '—' : Number(value).toLocaleString('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2})
},
shortDate(value) {
return value ? String(value).slice(5, 10) : '—'
},
setRefreshing(value) { this.refreshing = value },
setTableLoading(value) { this.tableLoading = value },
setListError(value) { this.listError = value },
setIdea(value) { this.$set(this.formData, 'idea', value) },
/** 筛选切换或提交后,从第一页替换数据;旧请求晚到时不能混入当前筛选结果。 */
doSearch() {
this.listSerial++
this.listBusy = false
this.$set(this.pageForm, 'pageNumber', 1)
this.tableData = []
this.finished = false
this.listError = false
this.pageData()
},
/** 列表返回 data.list 和 totalCount;失败保留页码供重试,成功后才推进下一页。 */
pageData() {
if (this.listBusy || this.finished) return
const serial = this.listSerial
this.listBusy = true
this.tableLoading = true
this.listError = false
$.post(this.baseUrl + '/pageData', Object.assign({}, this.pageForm))
.then((res) => {
if (serial !== this.listSerial || this._isDestroyed) return
if (res.code !== 0 || !res.data) {
this.listError = true
this.$toast(res.msg || '列表加载失败')
return
}
const list = res.data.list || []
this.tableData = this.tableData.concat(list)
this.finished = !list.length || this.tableData.length >= Number(res.data.totalCount)
this.$set(this.pageForm, 'pageNumber', this.pageForm.pageNumber + 1)
})
.fail(() => {
if (serial === this.listSerial && !this._isDestroyed) this.listError = true
})
.always(() => {
if (serial !== this.listSerial || this._isDestroyed) return
this.listBusy = false
this.tableLoading = false
this.refreshing = false
})
},
loadClubs() {
this.clubsLoading = true
$.post(this.baseUrl + '/clubs')
.then((res) => {
if (res.code === 0) {
this.clubs = [{text: '全部协会', value: ''}].concat((res.data || []).map((club) => ({text: club.name, value: club.id})))
} else {
this.$toast(res.msg || '协会加载失败')
}
})
.fail(() => this.$toast('协会加载失败,请重新打开筛选'))
.always(() => { this.clubsLoading = false })
},
/** key 为年度/协会/状态字段,open 为菜单展开状态;三个菜单切换时共用一层历史。 */
setFilterOpen(key, open) {
this.$set(this.filterOpen, key, open)
if (open && key === 'clubId' && this.clubs.length === 1 && !this.clubsLoading) this.loadClubs()
// Vant 切换菜单会同时触发旧菜单 close 和新菜单 open,等本轮更新结束后统一判断。
this.$nextTick(() => {
if (this._isDestroyed || this.filterClosing) return
if (Object.values(this.filterOpen).some(Boolean)) {
if (!this.popups.filter) this.$options.popupSession.open('filter')
} else if (this.popups.filter) {
this.filterClosing = true
this.$options.popupSession.close().then(() => { this.filterClosing = false })
}
})
},
/** key 为筛选字段,value 为对应选项值;仅值改变时刷新,菜单收起事件负责回退历史。 */
selectFilter(key, value) {
this.$set(this.pageForm, key, value)
this.doSearch()
},
/** 每次打开申请清空上次的意见和签字,防止在不同申请之间串用审核数据。 */
openDetail(id, audit) {
if (this.formLoading) return
this.detailId = id
this.detail = {}
this.auditMode = audit
this.formData = {id, idea: '', sign: ''}
this.reviewDate = moment().format('YYYY-MM-DD')
this.$options.popupSession.open('detail')
this.loadDetail(id)
},
loadDetail(id) {
const serial = ++this.detailSerial
this.detailLoading = true
$.post(this.baseUrl + '/findOne', {id})
.then((res) => {
if (serial !== this.detailSerial || !this.popups.detail || this._isDestroyed) return
if (res.code === 0 && res.data) {
this.detail = res.data
if (!this.detail.canReview) this.auditMode = false
} else {
this.$toast(res.msg || '详情加载失败')
}
})
.fail(() => {
if (serial === this.detailSerial && !this._isDestroyed) this.$toast('详情加载失败,请重试')
})
.always(() => {
if (serial === this.detailSerial && !this._isDestroyed) this.detailLoading = false
})
},
startAudit() {
this.auditMode = true
this.$nextTick(() => {
const body = this.$el.querySelector('.association-detail-body')
if (body) body.scrollTop = body.scrollHeight
})
},
openSignature() {
if (!this.formLoading) this.$options.popupSession.open('sign')
},
/** signature 是 mobile-sign 回传的本次签字 Base64,随审核请求一起保存。 */
setSignature(signature) {
this.$set(this.formData, 'sign', signature)
},
confirmReview(flag) {
if (this.formLoading || !this.detail.canReview) return
if (!this.formData.idea.trim() || !this.formData.sign) {
this.$toast('请填写审核意见并签字')
return
}
this.reviewFlag = flag
this.$options.popupSession.open('confirm')
},
/** Vant 用 done 结束按钮 loading;显隐仍由历史栈驱动,避免组件自行关闭后留下历史层。 */
beforeReviewClose(action, done) {
if (this.formLoading) {
done(false)
return
}
if (action === 'confirm') {
this.submitReview().always(() => done(false))
} else {
this.$options.popupSession.close().then(() => done(false))
}
},
/** id/idea/sign/flag 与 PC 共用;审核时间和人员由服务器取值,不信任前端身份字段。 */
submitReview() {
if (this.formLoading) return
this.formLoading = true
return $.post(this.baseUrl + '/doReview', Object.assign({}, this.formData, {flag: this.reviewFlag}))
.then((res) => {
if (this._isDestroyed) return
if (res.code === 0) {
this.$toast.success('审核成功')
this.$options.popupSession.clear().then(() => {
this.formData = {id: '', idea: '', sign: ''}
this.doSearch()
})
} else {
this.$toast(res.msg || '审核失败')
// 关闭确认层后重读状态;若已被 PC 处理则切换为只读,保留未提交的意见便于核对。
if (this.popups.confirm) this.$options.popupSession.close().then(() => this.loadDetail(this.detailId))
}
})
.fail(() => this.$toast('提交失败,请检查网络后重试'))
.always(() => { this.formLoading = false })
},
/** 图片使用文件流,文档沿用项目预览接口;预览自身同样占一层历史。 */
previewFile(file) {
if (file.imageUrl) {
this.previewIsImage = true
this.previewUrl = file.imageUrl
this.$options.popupSession.open('preview')
return
}
if (!file.id) {
this.$toast('附件信息不完整')
return
}
this.previewIsImage = /\.(png|jpe?g|gif|webp|bmp)$/i.test(file.filename || file.filepath || '')
const streamUrl = '/file_server/fileStreamPreview?id=' + encodeURIComponent(file.id)
this.previewUrl = this.previewIsImage
? streamUrl
: '/assets/platform/plugins/pdfJs/web/viewer.html?file=' + encodeURIComponent(streamUrl)
this.$options.popupSession.open('preview')
},
closePopup() {
if (this.formLoading) {
this.$toast('正在提交,请稍候')
return
}
this.$options.popupSession.close()
},
back() {
if (Object.values(this.popups).some(Boolean)) {
this.closePopup()
return
}
this.$options.popupSession.clear().then(() => {
this.$destroy()
pjaxReplace('/mobile/index')
})
}
},
created() {
// 仅注册状态同步回调;popstate 由公共管理器统一监听。
this.$options.popupSession = window.mobilePopupHistory.register('associationAudit', (layers) => {
Object.keys(this.popups).forEach((name) => this.$set(this.popups, name, layers.includes(name)))
// 系统返回或页面返回先收起下拉菜单;由公共历史栈驱动,页面不另绑 popstate。
if (!layers.includes('filter')) {
Object.keys(this.filterOpen).forEach((key) => {
this.$set(this.filterOpen, key, false)
const dropdown = this.$refs[key + 'Filter']
if (dropdown) dropdown.toggle(false)
})
}
})
const user = JSON.parse(window.sessionStorage.getItem('user') || '{}')
this.reviewerName = user.username || ''
this.loadClubs()
this.doSearch()
const query = new URLSearchParams(window.location.search)
const id = query.get('id')
if (id) this.openDetail(id, query.get('mode') === 'audit')
// PJAX 不会自动销毁旧 Vue 实例,在替换容器前注销页面回调。
$(document).on('pjax:beforeReplace.associationAudit', () => this.$destroy())
},
beforeDestroy() {
this.listSerial++
this.detailSerial++
const session = this.$options.popupSession
if (Object.values(this.popups).some(Boolean)) {
session.clear().then(() => session.dispose())
} else {
// PJAX 替换后可能立即创建新页面;空栈必须同步注销,不能等 Promise 回调再释放注册。
session.dispose()
}
$(document).off('pjax:beforeReplace.associationAudit')
}
})
@@ -0,0 +1,18 @@
<!--#
layout("/mobile/platform.html"){
#-->
<!--#include('/mobile/jf/activitybx/review/index.html'){}#-->
<script>
// 节点配置由当前页面固定提供,业务权限及状态仍由服务端校验。
const activityReviewConfig = {
stage: 'division', title: '基层工会主席审核', state: 1010,
confirmMessage: '通过后将转交校工会负责人审核。',
canChooseUnion: ${@shiro.hasRole('sysadmin')||@shiro.hasRole('A06')||@shiro.hasRole('H03')},
currentUnionId: '${@io.v.nutz.base.utils.Vi.getUnionId()}'
}
<!--#include('/mobile/jf/activitybx/review/info.js'){}#-->
<!--#include('/mobile/jf/activitybx/review/page.js'){}#-->
</script>
<!--#
}
#-->
@@ -0,0 +1,223 @@
<style>
.review-page { min-height: 100vh; background: #f6f7f9; color: #323233; font-size: 13px; }
.review-page .van-nav-bar__title { font-size: 15px; font-weight: 400; }
.review-filters .van-dropdown-menu__bar { box-shadow: none; border-top: 1px solid #f8f8f8; border-bottom: 1px solid #f8f8f8; }
.review-filters .van-dropdown-menu__item { flex: 1; min-width: 0; }
.review-filters .van-dropdown-menu__title { max-width: calc(100% - 40px); padding: 0 8px; font-size: 14px; }
.review-filters .van-dropdown-item__content { max-height: 70vh; border-radius: 0 0 15px 15px; }
.review-list { padding: 2px 0 12px; }
.review-card { margin: 12px 13px 0; padding: 11px 12px 10px; background: white; border-radius: 10px; box-shadow: 0 5px 12px rgba(30,50,70,.04); }
.review-card h3 { position: relative; margin: 0 0 7px; color: #086bc3; font-size: 15px; line-height: 21px; font-weight: 600; overflow-wrap: anywhere; }
.review-card h3:before { content: ''; position: absolute; left: -10px; top: 3px; height: 15px; border-left: 2px solid #1989fa; }
.review-card p { margin: 0; line-height: 26px; color: #85878d; overflow-wrap: anywhere; }
.review-card label { color: #242424; }
.review-card .review-pair { display: flex; gap: 10px; justify-content: space-between; }
.review-pair span:last-child { flex-shrink: 0; }
.review-card-footer { display: flex; align-items: center; gap: 8px; margin-top: 7px; padding-top: 7px; border-top: 1px solid #f5f5f5; }
.review-state { flex: 1; min-width: 0; font-size: 12px; color: #b7790c; overflow-wrap: anywhere; }
.review-card-footer .van-button { height: 25px; padding: 0 8px; font-size: 13px; border-radius: 6px; flex-shrink: 0; }
.review-popup { display: flex; flex-direction: column; overflow: hidden; background: #f6f7f9; height: 100%; width: 100%; }
.review-popup > .van-nav-bar { flex-shrink: 0; }
.review-detail-body { flex: 1; overflow-y: auto; min-height: 0; padding-bottom: 12px; }
.review-section { margin: 12px; padding: 10px 0; border-radius: 10px; background: white; overflow: hidden; }
.review-section h3 { margin: 4px 14px 8px; font-size: 15px; color: #1768b0; }
.review-section .van-cell { font-size: 13px; padding: 8px 14px; }
.review-section .van-cell__title { flex: 0 0 88px; }
.review-section .van-cell__value { white-space: pre-wrap; overflow-wrap: anywhere; }
.review-section .review-file .van-cell__title { flex: 1; overflow-wrap: anywhere; }
.review-section .review-file .van-cell__value { flex: 0 0 40px; }
.review-content-image { display: block; max-width: calc(100% - 28px); margin: 10px 14px; }
.review-description { padding: 0 14px; line-height: 1.8; white-space: pre-wrap; overflow-wrap: anywhere; }
.review-muted { margin: 10px 14px; color: #969799; }
.review-total .van-cell__value { color: #086bc3; font-weight: 600; }
.review-sign-image { display: block; max-width: calc(100% - 28px); width: 240px; height: 100px; object-fit: contain; margin: 8px 14px; }
.review-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; }
.review-actions .van-button { flex: 1; border-radius: 6px; height: 40px; }
.review-section .review-sign-field { display: block; }
.review-sign-field .van-field__label { width: auto; margin: 0 0 10px; }
.review-sign-field .main-panel .van-image { width: 100%; height: 112px; min-height: 112px; overflow: hidden; border-radius: 6px; }
.review-sign-field .main-panel .open-button { width: 100%; border-radius: 6px; }
.review-preview { display: block; width: 100%; height: calc(100% - 46px); border: none; background: white; }
.review-preview-image { width: 100%; height: calc(100% - 46px); object-fit: contain; }
.review-loading { padding: 30px; text-align: center; }
.review-more-bar { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 7px 13px; background: white; color: #969799; font-size: 12px; }
.review-more-bar > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.review-more-popup, .review-choice-popup { display: flex; flex-direction: column; max-height: 80vh; background: #f7f8fa; }
.review-choice-popup { height: 60vh; }
.review-more-body { flex: 1; overflow-y: auto; min-height: 0; }
/* 筛选面板独立限定样式,避免影响详情、审核按钮和其他公共弹框。 */
.review-more-popup { background: #fff; border-radius: 18px 18px 0 0; overflow: hidden; }
.review-more-handle { flex-shrink: 0; width: 38px; height: 4px; margin: 8px auto 0; border-radius: 4px; background: #d5d8df; }
.review-more-header { display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; padding: 4px 12px 8px 18px; }
.review-more-header h2 { margin: 0; font-size: 18px; line-height: 32px; font-weight: 600; color: #20242d; }
.review-more-close.van-button { width: 36px; height: 36px; padding: 0; border: 0; color: #92969f; background: transparent; font-size: 20px; }
.review-more-popup > .review-more-body { flex: 0 1 auto; padding: 0 18px 14px; }
.review-filter-group + .review-filter-group { margin-top: 16px; }
.review-filter-label { margin: 0 0 8px; color: #252a34; font-size: 14px; line-height: 20px; font-weight: 600; }
.review-filter-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.review-filter-row .review-filter-label { margin: 0; }
.review-unit-select.van-cell { align-items: center; padding: 8px 12px; border-radius: 8px; background: #f5f6f8; font-size: 13px; }
.review-unit-select .van-cell__title { min-width: 0; overflow-wrap: anywhere; }
.review-unit-select:after, .review-filter-input:after { display: none; }
.review-filter-input.van-cell { padding: 8px 12px; border-radius: 8px; background: #f5f6f8; font-size: 13px; }
.review-filter-input .van-field__left-icon { color: #969ba4; margin-right: 8px; }
.review-segment { display: flex; flex-shrink: 0; gap: 2px; padding: 2px; border-radius: 8px; background: #f5f6f8; }
.review-filter-option.van-button { min-width: 0; height: 30px; padding: 0 8px; border: 1px solid transparent; border-radius: 7px; background: #f5f6f8; color: #333943; font-size: 12px; }
.review-segment .review-filter-option { min-width: 58px; height: 28px; }
.review-filter-option.is-selected { background: #eaf3ff; border-color: #1989fa; color: #1989fa; font-weight: 500; }
.review-segment .review-filter-option.is-selected { border-color: transparent; }
.review-sort-options { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
.review-sort-options .review-filter-option { position: relative; width: 100%; height: 30px; padding: 0 4px; white-space: nowrap; }
.review-sort-options .is-selected { padding-right: 15px; }
.review-sort-check { position: absolute; right: 3px; top: 50%; transform: translateY(-50%); font-size: 12px; }
.review-sort-direction { margin: 14px 0 0; }
.review-sort-direction .review-segment { background: transparent; gap: 6px; padding: 0; }
.review-more-footer { display: flex; flex-shrink: 0; gap: 10px; padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); border-top: 1px solid #f1f2f5; background: #fff; }
.review-more-footer .van-button { flex: 1; height: 40px; border-radius: 8px; font-size: 14px; }
.review-more-footer .van-button--info { flex: 2; }
.review-more-footer .van-button--default { color: #60656e; border-color: #e4e7ed; }
.review-choice-popup { background: #fff; }
.review-choice-popup .van-search { padding: 8px 16px; }
.review-choice-popup .van-search__content { border-radius: 8px; background: #f5f6f8; }
.review-choice-popup .van-cell { padding: 12px 18px; font-size: 13px; }
.review-choice-popup .review-choice-selected { color: #1989fa; background: #f0f6ff; }
.review-budget-item + .review-budget-item { border-top: 1px solid #f2f3f5; }
</style>
<div id="app" class="review-page" v-cloak>
<van-sticky>
<van-nav-bar :title="config.title" left-arrow @click-left="back"></van-nav-bar>
<van-dropdown-menu class="review-filters" active-color="#1867b0"
:close-on-click-outside="false" :close-on-click-overlay="false">
<van-dropdown-item ref="yearFilter" :options="yearOptions" :value="pageForm.year" :disabled="filterClosing"
@change="selectFilter('year', $event)"
@open="setFilterOpen('year', true)" @close="setFilterOpen('year', false)"></van-dropdown-item>
<van-dropdown-item ref="organizationFilter" :options="organizationOptions" :value="pageForm[organizationKey]" :disabled="filterClosing"
@change="selectFilter(organizationKey, $event)"
@open="setFilterOpen('organization', true)" @close="setFilterOpen('organization', false)">
<van-loading v-if="organizationsLoading" class="review-loading">加载中</van-loading>
</van-dropdown-item>
<van-dropdown-item ref="isAuditFilter" :options="auditOptions" :value="pageForm.isAudit" :disabled="filterClosing"
@change="selectFilter('isAudit', $event)"
@open="setFilterOpen('isAudit', true)" @close="setFilterOpen('isAudit', false)"></van-dropdown-item>
</van-dropdown-menu>
<div class="review-more-bar"><span>{{ searchSummary }}</span><van-button size="mini" plain type="info" @click="openMore">更多筛选</van-button></div>
</van-sticky>
<van-pull-refresh :value="refreshing" @input="setRefreshing" @refresh="doSearch">
<van-list :value="tableLoading" :finished="finished" :error="listError" error-text="加载失败,点击重试"
:finished-text="tableData.length ? '没有更多了' : ''" :immediate-check="false"
@input="setTableLoading" @update:error="setListError" @load="pageData" class="review-list">
<article v-for="item in tableData" :key="item.id" class="review-card">
<h3>{{ item.activity_name }}</h3>
<p><label>申请人:</label>{{ item.user_name }}<span v-if="item.bxr_loginname">({{ item.bxr_loginname }})</span></p>
<p><label>所属工会:</label>{{ item.bxr_unionname || item.fundsUnitName || '—' }}</p>
<p class="review-pair"><span><label>预算:</label>¥{{ money(item.funds_money) }}</span><span><label>申请:</label>{{ shortDate(item.apply_time) }}</span></p>
<div class="review-card-footer">
<span class="review-state" :style="{color: item.state_color || '#b7790c'}">{{ item.state_name || config.title }}</span>
<van-button type="info" size="small" :disabled="formLoading" @click="openDetail(item.id, false)">查看</van-button>
<van-button v-if="Number(item.state_id) === config.state" type="primary" size="small" :disabled="formLoading" @click="openDetail(item.id, true)">审核</van-button>
</div>
</article>
<van-empty v-if="!tableLoading && !listError && !tableData.length" description="暂无申请"></van-empty>
</van-list>
</van-pull-refresh>
<van-popup :value="popups.detail" position="right" class="review-popup" :close-on-popstate="false" :close-on-click-overlay="false">
<van-nav-bar :title="auditMode ? config.title : '申请详情'" left-arrow @click-left="closePopup"></van-nav-bar>
<div class="review-detail-body">
<van-loading v-if="detailLoading" class="review-loading">加载申请中</van-loading>
<van-empty v-else-if="!detail.id" description="详情加载失败">
<van-button size="small" type="info" @click="loadDetail(detailId)">重新加载</van-button>
</van-empty>
<review-info v-else :data="detail" @preview="previewFile">
<section v-if="auditMode && detail.canReview" class="review-section">
<h3>审核信息</h3>
<van-field label="审核人" :value="reviewerName" readonly></van-field>
<van-field label="审核时间" :value="reviewDate" readonly></van-field>
<van-field label="审核意见" :value="formData.idea" @input="setIdea" type="textarea" rows="3"
maxlength="500" show-word-limit required placeholder="请填写审核意见" :disabled="formLoading"></van-field>
<van-field label="审核签字" required class="review-sign-field">
<template #input>
<mobile-sign :value="formData.sign" @input="setSignature" :disabled="formLoading"
:save-to-profile="false" :popup-visible="popups.sign" panel-title="审核签字"
@open="openSignature" @close="closePopup"></mobile-sign>
</template>
</van-field>
</section>
</review-info>
</div>
<div v-if="detail.id && !detailLoading" class="review-actions">
<template v-if="auditMode && detail.canReview">
<van-button type="danger" plain :disabled="formLoading" @click="confirmReview(false)">拒绝</van-button>
<van-button type="primary" :loading="formLoading" :disabled="formLoading" @click="confirmReview(true)">通过</van-button>
</template>
<van-button v-else-if="detail.canReview" type="primary" @click="startAudit">去审核</van-button>
<van-button v-else @click="closePopup">关闭</van-button>
</div>
</van-popup>
<van-dialog :value="popups.confirm" :title="reviewFlag ? '确认通过' : '确认拒绝'"
:message="reviewFlag ? config.confirmMessage : '拒绝后将通知申请人,请核对审核意见。'"
show-cancel-button :allow-html="false" :close-on-click-overlay="false" :close-on-popstate="false"
:before-close="beforeReviewClose"></van-dialog>
<van-popup :value="popups.preview" position="right" class="review-popup" :close-on-popstate="false" :close-on-click-overlay="false">
<van-nav-bar title="附件查看" left-arrow @click-left="closePopup"></van-nav-bar>
<img v-if="popups.preview && previewIsImage" :src="previewUrl" class="review-preview-image" alt="申请附件">
<iframe v-else-if="popups.preview" :src="previewUrl" title="申请附件" class="review-preview"></iframe>
</van-popup>
<van-popup :value="popups.more" position="bottom" round class="review-more-popup" :close-on-popstate="false" :close-on-click-overlay="false">
<div class="review-more-handle" aria-hidden="true"></div>
<header class="review-more-header">
<h2>更多筛选</h2>
<van-button icon="cross" class="review-more-close" aria-label="关闭更多筛选" @click="closePopup"></van-button>
</header>
<div class="review-more-body">
<section v-if="config.canChooseUnion" class="review-filter-group">
<h3 class="review-filter-label">所属单位</h3>
<van-cell :title="optionText('unitId', moreForm.unitId)" class="review-unit-select" is-link @click="openChoice('unitId')"></van-cell>
</section>
<section class="review-filter-group">
<div class="review-filter-row">
<h3 class="review-filter-label">查询人员</h3>
<div class="review-segment" role="group" aria-label="查询类型">
<van-button v-for="option in choiceOptions.searchName" :key="option.value" class="review-filter-option"
:class="{'is-selected': moreForm.searchName === option.value}" :aria-pressed="moreForm.searchName === option.value"
@click="setMoreField('searchName', option.value)">{{ option.text }}</van-button>
</div>
</div>
<van-field class="review-filter-input" left-icon="search" :value="moreForm.searchKeyword"
@input="setMoreField('searchKeyword', $event)" maxlength="100" clearable
:placeholder="moreSearchPlaceholder" :aria-label="moreSearchPlaceholder"></van-field>
</section>
<section class="review-filter-group">
<h3 class="review-filter-label">排序方式</h3>
<div class="review-sort-options" role="group" aria-label="排序字段">
<van-button v-for="option in choiceOptions.pageOrderName" :key="option.value" class="review-filter-option"
:class="{'is-selected': moreForm.pageOrderName === option.value}" :aria-pressed="moreForm.pageOrderName === option.value"
@click="setMoreField('pageOrderName', option.value)">
{{ option.text }}<van-icon v-if="moreForm.pageOrderName === option.value" name="success" class="review-sort-check"></van-icon>
</van-button>
</div>
<div v-if="moreForm.pageOrderName" class="review-filter-row review-sort-direction">
<h3 class="review-filter-label">排列顺序</h3>
<div class="review-segment" role="group" aria-label="排列顺序">
<van-button v-for="option in choiceOptions.pageOrderBy" :key="option.value" class="review-filter-option"
:class="{'is-selected': moreForm.pageOrderBy === option.value}" :aria-pressed="moreForm.pageOrderBy === option.value"
@click="setMoreField('pageOrderBy', option.value)">{{ option.text }}</van-button>
</div>
</div>
</section>
</div>
<div class="review-more-footer"><van-button @click="resetMore">重置</van-button><van-button type="info" @click="applyMore">确定筛选</van-button></div>
</van-popup>
<van-popup :value="popups.choice" position="bottom" round class="review-choice-popup" :close-on-popstate="false" :close-on-click-overlay="false">
<van-nav-bar :title="choiceTitle" left-text="返回" @click-left="closePopup"></van-nav-bar>
<van-search v-if="choiceKey === 'unitId'" :value="choiceQuery" @input="setChoiceQuery" placeholder="搜索单位"></van-search>
<div class="review-more-body">
<van-cell v-for="option in filteredChoices" :key="option.value" :title="option.text" :class="{'review-choice-selected': moreForm[choiceKey] === option.value}" clickable @click="selectChoice(option.value)">
<van-icon v-if="moreForm[choiceKey] === option.value" name="success" color="#1989fa"></van-icon>
</van-cell>
<van-empty v-if="!filteredChoices.length" description="暂无选项"></van-empty>
</div>
</van-popup>
</div>
@@ -0,0 +1,146 @@
/** 活动申报审核详情:仅展示服务端已校验权限的数据,审核表单通过末尾插槽传入。 */
const activityReviewInfo = {
template: /*language=HTML*/ `
<div class="review-info">
<section class="review-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 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.startTime ? data.startTime + ' 至 ' + (data.endTime || '—') : '暂无'"></van-cell>
<van-cell 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">
<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})">
</section>
<section class="review-section">
<h3>预算明细</h3>
<div v-for="(item, index) in budgets" :key="index" class="review-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>
</template>
</div>
<van-cell title="预算合计" :value="'¥' + money(data.funds_money)" class="review-total"></van-cell>
</section>
<section class="review-section">
<h3>申请附件</h3>
<van-cell v-for="(file, index) in 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="!files.length" class="review-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>
</section>
<section v-for="group in reimbursementFiles" :key="group.key" class="review-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>
</section>
<section v-for="audit in audits" :key="audit.key" class="review-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 + '签字'">
</section>
<slot></slot>
</div>
`,
props: {
data: {type: Object, default: () => ({})}
},
computed: {
activityType() {
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])}))
},
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
const records = [
{key: 'division', title: '基层工会主席审核', id: data.division_chairman_name, name: data.dc_username, time: data.division_chairman_time, idea: data.division_chairman_idea, sign: data.dc_s},
{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: 'confirm', title: '校工会副主席审核', id: data.confirm_trade_name, name: data.ct_username, time: data.confirm_trade_time, idea: data.confirm_trade_idea, sign: data.ct_s},
{key: 'cashier', title: '校工会出纳审核', id: data.union_cashier_name, name: data.uc_username, time: data.union_cashier_time, idea: data.union_cashier_idea, sign: data.uc_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)
// PC 详情同时展示已有报销审批记录;只读呈现,不把报销流程混入当前申请审核。
const reimbursementAudits = [
{key: 'reimbursement_club_audit', title: '协会负责人审核(报销)'},
{key: 'reimbursement_union_audit', title: '基层工会主席审核(报销)'},
{key: 'reimbursement_school_director', title: '校工会负责人审核(报销)'},
{key: 'reimbursement_school_chairman', title: '校工会主席审核(报销)'}
].filter((item) => data[item.key]).map((item) => {
const audit = data[item.key]
return {key: item.key, title: item.title, name: audit.username, time: audit.auditTime, idea: audit.auditOpinion, sign: audit.auditSign}
})
return records.concat(reimbursementAudits)
}
},
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 || '')
}
}
}
@@ -0,0 +1,353 @@
// 三个节点共用协会页的手机交互,节点与提示文案由独立入口配置。
const vue = new Vue({
el: '#app',
components: {'review-info': activityReviewInfo},
data() {
return {
config: activityReviewConfig,
baseUrl: '/platform/jf/activity_bx/' + activityReviewConfig.stage,
pageForm: {year: new Date().getFullYear(), unionId: '', unitId: '', isAudit: 3, pageNumber: 1, pageSize: 10, searchName: 'username', searchKeyword: '', pageOrderName: '', pageOrderBy: 'desc'},
tableData: [], tableLoading: false, refreshing: false, finished: false, listError: false,
listSerial: 0, listBusy: false, detailSerial: 0,
unions: [{text: '全部工会', value: ''}], units: [{text: '全部单位', value: ''}], organizationsLoading: false, unitsSerial: 0,
moreForm: {}, choiceKey: '', choiceQuery: '',
auditOptions: [{text: '未审核', value: 3}, {text: '已审核', value: 2}, {text: '全部', value: 1}],
filterOpen: {year: false, organization: false, isAudit: false}, filterClosing: false,
popups: {filter: false, detail: false, sign: false, confirm: false, preview: false, more: false, choice: false},
detail: {}, detailId: '', detailLoading: false, auditMode: false,
formData: {id: '', idea: '', sign: ''}, formLoading: false, reviewFlag: true,
reviewerName: '', reviewDate: '', previewUrl: '', previewIsImage: false
}
},
computed: {
organizationKey() { return this.config.canChooseUnion ? 'unionId' : 'unitId' },
organizationOptions() { return this.config.canChooseUnion ? this.unions : this.units },
/** 查询类型直接切换草稿字段,输入提示同步变化;确定筛选前不触发列表请求。 */
moreSearchPlaceholder() { return this.moreForm.searchName === 'loginname' ? '请输入工号' : '请输入姓名' },
searchSummary() {
const parts = []
if (this.pageForm.searchKeyword) parts.push(this.pageForm.searchKeyword)
if (this.config.canChooseUnion && this.pageForm.unitId) parts.push(this.optionText('unitId', this.pageForm.unitId))
if (this.pageForm.pageOrderName) parts.push(this.optionText('pageOrderName', this.pageForm.pageOrderName))
return parts.join(' · ') || '按姓名、工号、单位筛选'
},
choiceOptions() {
return {
unitId: this.units,
searchName: [{text: '姓名', value: 'username'}, {text: '工号', value: 'loginname'}],
pageOrderName: [{text: '默认排序', value: ''}, {text: '所属工会', value: 'bxr_unionname'}, {text: '活动主体类型', value: 'activity_type'}, {text: '经费类型', value: 'absName'}, {text: '申请类型', value: 'apply_type'}, {text: '申请时间', value: 'apply_time'}, {text: '申请状态', value: 'state_id'}],
pageOrderBy: [{text: '升序', value: 'asc'}, {text: '降序', value: 'desc'}]
}
},
choiceTitle() { return {unitId: '所属单位', searchName: '查询类型', pageOrderName: '排序字段', pageOrderBy: '排序方式'}[this.choiceKey] || '请选择' },
filteredChoices() {
return (this.choiceOptions[this.choiceKey] || []).filter((item) => item.text.includes(this.choiceQuery.trim()))
},
yearOptions() {
return [{text: '全部年份', value: ''}].concat(Array.from({length: 11}, (_, index) => {
const year = new Date().getFullYear() - index
return {text: year + '年', value: year}
}))
}
},
methods: {
money(value) {
return value == null || !Number.isFinite(Number(value)) ? '—' : Number(value).toLocaleString('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2})
},
shortDate(value) {
return value ? String(value).slice(5, 10) : '—'
},
setRefreshing(value) { this.refreshing = value },
setTableLoading(value) { this.tableLoading = value },
setListError(value) { this.listError = value },
setIdea(value) { this.$set(this.formData, 'idea', value) },
/** 筛选切换或提交后,从第一页替换数据;旧请求晚到时不能混入当前筛选结果。 */
doSearch() {
this.listSerial++
this.listBusy = false
this.$set(this.pageForm, 'pageNumber', 1)
this.tableData = []
this.finished = false
this.listError = false
this.pageData()
},
/** 列表返回 data.list 和 totalCount;失败保留页码供重试,成功后才推进下一页。 */
pageData() {
if (this.listBusy || this.finished) return
const serial = this.listSerial
this.listBusy = true
this.tableLoading = true
this.listError = false
$.post(this.baseUrl + '/pageData', Object.assign({}, this.pageForm))
.then((res) => {
if (serial !== this.listSerial || this._isDestroyed) return
if (res.code !== 0 || !res.data) {
this.listError = true
this.$toast(res.msg || '列表加载失败')
return
}
const list = res.data.list || []
this.tableData = this.tableData.concat(list)
this.finished = !list.length || this.tableData.length >= Number(res.data.totalCount)
this.$set(this.pageForm, 'pageNumber', this.pageForm.pageNumber + 1)
})
.fail(() => {
if (serial === this.listSerial && !this._isDestroyed) this.listError = true
})
.always(() => {
if (serial !== this.listSerial || this._isDestroyed) return
this.listBusy = false
this.tableLoading = false
this.refreshing = false
})
},
/** 工会和单位沿用 PC 的公共选项接口;接口返回 data 数组,组织权限仍由服务端控制。 */
loadOrganizations() {
this.organizationsLoading = true
$.post('/platform/vi/common/unions', {})
.then((res) => {
if (this._isDestroyed) return
if (res.code === 0) this.unions = [{text: '全部工会', value: ''}].concat((res.data || []).map((item) => ({text: item.unionname, value: item.id})))
else this.$toast(res.msg || '工会加载失败')
})
.fail(() => this.$toast('工会加载失败,请重试'))
.always(() => { this.organizationsLoading = false })
this.loadUnits()
},
/** 更换工会后清空旧单位;序号避免前一次请求晚返回而覆盖新工会的选项。 */
loadUnits() {
const serial = ++this.unitsSerial
return $.post('/platform/vi/common/units', {unionId: this.config.canChooseUnion ? this.pageForm.unionId : this.config.currentUnionId})
.then((res) => {
if (serial !== this.unitsSerial || this._isDestroyed) return
if (res.code === 0) this.units = [{text: '全部单位', value: ''}].concat((res.data || []).map((item) => ({text: item.name, value: item.id})))
else this.$toast(res.msg || '单位加载失败')
})
.fail(() => { if (serial === this.unitsSerial && !this._isDestroyed) this.$toast('单位加载失败,请重试') })
},
openMore() {
this.moreForm = Object.assign({}, this.pageForm)
this.loadUnits()
this.$options.popupSession.open('more')
},
setMoreField(key, value) { this.$set(this.moreForm, key, value) },
setChoiceQuery(value) { this.choiceQuery = value },
/** 选择项弹框压在更多筛选上层,返回时只退出当前选择,不丢弃其他筛选值。 */
openChoice(key) {
this.choiceKey = key
this.choiceQuery = ''
this.$options.popupSession.open('choice')
},
selectChoice(value) {
this.$set(this.moreForm, this.choiceKey, value)
this.$options.popupSession.close()
},
optionText(key, value) {
const option = (this.choiceOptions[key] || []).find((item) => item.value === value)
return option ? option.text : '请选择'
},
resetMore() {
this.moreForm = Object.assign({}, this.moreForm, {unitId: this.config.canChooseUnion ? '' : this.pageForm.unitId, searchName: 'username', searchKeyword: '', pageOrderName: '', pageOrderBy: 'desc'})
},
applyMore() {
const values = this.moreForm
this.$options.popupSession.close().then(() => {
if (this._isDestroyed) return
const keys = ['searchName', 'searchKeyword', 'pageOrderName', 'pageOrderBy']
if (this.config.canChooseUnion) keys.push('unitId')
keys.forEach((key) => this.$set(this.pageForm, key, values[key]))
this.doSearch()
})
},
/** key 为年度/组织/状态字段,open 为菜单展开状态;三个菜单切换时共用一层历史。 */
setFilterOpen(key, open) {
this.$set(this.filterOpen, key, open)
if (open && key === 'organization') {
if (this.organizationOptions.length === 1 && !this.organizationsLoading) this.loadOrganizations()
}
// Vant 切换菜单会同时触发旧菜单 close 和新菜单 open,等本轮更新结束后统一判断。
this.$nextTick(() => {
if (this._isDestroyed || this.filterClosing) return
if (Object.values(this.filterOpen).some(Boolean)) {
if (!this.popups.filter) this.$options.popupSession.open('filter')
} else if (this.popups.filter) {
this.filterClosing = true
this.$options.popupSession.close().then(() => { this.filterClosing = false })
}
})
},
/** key 为筛选字段,value 为对应选项值;仅值改变时刷新,菜单收起事件负责回退历史。 */
selectFilter(key, value) {
this.$set(this.pageForm, key, value)
if (key === 'unionId') {
this.$set(this.pageForm, 'unitId', '')
this.units = [{text: '全部单位', value: ''}]
this.loadUnits()
}
this.doSearch()
},
/** 每次打开申请清空上次的意见和签字,防止在不同申请之间串用审核数据。 */
openDetail(id, audit) {
if (this.formLoading) return
this.detailId = id
this.detail = {}
this.auditMode = audit
this.formData = {id, idea: '', sign: ''}
this.reviewDate = moment().format('YYYY-MM-DD')
this.$options.popupSession.open('detail')
this.loadDetail(id)
},
loadDetail(id) {
const serial = ++this.detailSerial
this.detailLoading = true
$.post(this.baseUrl + '/findOne', {id})
.then((res) => {
if (serial !== this.detailSerial || !this.popups.detail || this._isDestroyed) return
if (res.code === 0 && res.data) {
this.detail = res.data
if (!this.detail.canReview) this.auditMode = false
} else {
this.$toast(res.msg || '详情加载失败')
}
})
.fail(() => {
if (serial === this.detailSerial && !this._isDestroyed) this.$toast('详情加载失败,请重试')
})
.always(() => {
if (serial === this.detailSerial && !this._isDestroyed) this.detailLoading = false
})
},
startAudit() {
this.auditMode = true
this.$nextTick(() => {
const body = this.$el.querySelector('.review-detail-body')
if (body) body.scrollTop = body.scrollHeight
})
},
openSignature() {
if (!this.formLoading) this.$options.popupSession.open('sign')
},
/** signature 是 mobile-sign 回传的本次签字 Base64,随审核请求一起保存。 */
setSignature(signature) {
this.$set(this.formData, 'sign', signature)
},
confirmReview(flag) {
if (this.formLoading || !this.detail.canReview) return
if (!this.formData.idea.trim() || !this.formData.sign) {
this.$toast('请填写审核意见并签字')
return
}
this.reviewFlag = flag
this.$options.popupSession.open('confirm')
},
/** Vant 用 done 结束按钮 loading;显隐仍由历史栈驱动,避免组件自行关闭后留下历史层。 */
beforeReviewClose(action, done) {
if (this.formLoading) {
done(false)
return
}
if (action === 'confirm') {
this.submitReview().always(() => done(false))
} else {
this.$options.popupSession.close().then(() => done(false))
}
},
/** id/idea/sign/flag 与 PC 共用;审核时间和人员由服务器取值,不信任前端身份字段。 */
submitReview() {
if (this.formLoading) return
this.formLoading = true
const form = Object.assign({}, this.formData, {flag: this.reviewFlag})
const params = this.config.stage === 'division' ? form : {data: JSON.stringify(form)}
return $.post(this.baseUrl + '/doReview', params)
.then((res) => {
if (this._isDestroyed) return
if (res.code === 0) {
this.$toast.success('审核成功')
this.$options.popupSession.clear().then(() => {
if (this._isDestroyed) return
this.formData = {id: '', idea: '', sign: ''}
this.doSearch()
})
} else {
this.$toast(res.msg || '审核失败')
// 关闭确认层后重读状态;若已被 PC 处理则切换为只读,保留未提交的意见便于核对。
if (this.popups.confirm) this.$options.popupSession.close().then(() => this.loadDetail(this.detailId))
}
})
.fail(() => this.$toast('提交失败,请检查网络后重试'))
.always(() => { this.formLoading = false })
},
/** 图片使用文件流,文档沿用项目预览接口;预览自身同样占一层历史。 */
previewFile(file) {
if (file.imageUrl) {
this.previewIsImage = true
this.previewUrl = file.imageUrl
this.$options.popupSession.open('preview')
return
}
if (!file.id) {
this.$toast('附件信息不完整')
return
}
this.previewIsImage = /\.(png|jpe?g|gif|webp|bmp)$/i.test(file.filename || file.filepath || '')
const streamUrl = '/file_server/fileStreamPreview?id=' + encodeURIComponent(file.id)
this.previewUrl = this.previewIsImage
? streamUrl
: '/assets/platform/plugins/pdfJs/web/viewer.html?file=' + encodeURIComponent(streamUrl)
this.$options.popupSession.open('preview')
},
closePopup() {
if (this.formLoading) {
this.$toast('正在提交,请稍候')
return
}
this.$options.popupSession.close()
},
back() {
if (Object.values(this.popups).some(Boolean)) {
this.closePopup()
return
}
this.$options.popupSession.clear().then(() => {
this.$destroy()
pjaxReplace('/mobile/index')
})
}
},
created() {
// 仅注册状态同步回调;popstate 由公共管理器统一监听。
this.$options.popupSession = window.mobilePopupHistory.register('activityBxReview-' + this.config.stage, (layers) => {
Object.keys(this.popups).forEach((name) => this.$set(this.popups, name, layers.includes(name)))
// 系统返回或页面返回先收起下拉菜单;由公共历史栈驱动,页面不另绑 popstate。
if (!layers.includes('filter')) {
Object.keys(this.filterOpen).forEach((key) => {
this.$set(this.filterOpen, key, false)
const dropdown = this.$refs[key + 'Filter']
if (dropdown) dropdown.toggle(false)
})
}
})
const user = JSON.parse(window.sessionStorage.getItem('user') || '{}')
this.reviewerName = user.username || ''
this.loadOrganizations()
this.doSearch()
const query = new URLSearchParams(window.location.search)
const id = query.get('id')
if (id) this.openDetail(id, query.get('mode') === 'audit')
// PJAX 不会自动销毁旧 Vue 实例,在替换容器前注销页面回调。
$(document).on('pjax:beforeReplace.activityBxReview', () => this.$destroy())
},
beforeDestroy() {
this.listSerial++
this.detailSerial++
this.unitsSerial++
const session = this.$options.popupSession
if (Object.values(this.popups).some(Boolean)) {
session.clear().then(() => session.dispose())
} else {
// PJAX 替换后可能立即创建新页面;空栈必须同步注销,不能等 Promise 回调再释放注册。
session.dispose()
}
$(document).off('pjax:beforeReplace.activityBxReview')
}
})
@@ -0,0 +1,18 @@
<!--#
layout("/mobile/platform.html"){
#-->
<!--#include('/mobile/jf/activitybx/review/index.html'){}#-->
<script>
// 节点配置由当前页面固定提供,业务权限及状态仍由服务端校验。
const activityReviewConfig = {
stage: 'union', title: '校工会主席审核', state: 1040,
confirmMessage: '确认通过后,将按原流程生成活动并通知申请人。',
canChooseUnion: ${@shiro.hasRole('sysadmin')||@shiro.hasRole('A06')||@shiro.hasRole('H03')},
currentUnionId: '${@io.v.nutz.base.utils.Vi.getUnionId()}'
}
<!--#include('/mobile/jf/activitybx/review/info.js'){}#-->
<!--#include('/mobile/jf/activitybx/review/page.js'){}#-->
</script>
<!--#
}
#-->
@@ -0,0 +1,18 @@
<!--#
layout("/mobile/platform.html"){
#-->
<!--#include('/mobile/jf/reimbursement/review/index.html'){}#-->
<script>
// 本页固定审核节点;服务端按相同节点校验权限、归属及报销状态。
const reimbursementReviewConfig = {
stage: 'clubChairman', title: '协会负责人审核(报销)', state: 1000,
confirmMessage: '通过后将转交校工会负责人审核。', showClub: true, showUnit: false,
canChooseUnion: false,
currentUnionId: '${@io.v.nutz.base.utils.Vi.getUnionId()}'
}
<!--#include('/mobile/jf/reimbursement/review/info.js'){}#-->
<!--#include('/mobile/jf/reimbursement/review/page.js'){}#-->
</script>
<!--#
}
#-->
@@ -0,0 +1,233 @@
<style>
.reimbursement-review-page { min-height: 100vh; background: #f6f7f9; color: #323233; font-size: 13px; }
.reimbursement-review-page .van-nav-bar__title { font-size: 15px; font-weight: 400; }
.reimbursement-review-filters .van-dropdown-menu__bar { box-shadow: none; border-top: 1px solid #f8f8f8; border-bottom: 1px solid #f8f8f8; }
.reimbursement-review-filters .van-dropdown-menu__item { flex: 1; min-width: 0; }
.reimbursement-review-filters .van-dropdown-menu__title { max-width: calc(100% - 40px); padding: 0 8px; font-size: 14px; }
.reimbursement-review-filters .van-dropdown-item__content { max-height: 70vh; border-radius: 0 0 15px 15px; }
.reimbursement-review-list { padding: 2px 0 12px; }
.reimbursement-review-card { margin: 12px 13px 0; padding: 11px 12px 10px; background: white; border-radius: 10px; box-shadow: 0 5px 12px rgba(30,50,70,.04); }
.reimbursement-review-card h3 { position: relative; margin: 0 0 7px; color: #086bc3; font-size: 15px; line-height: 21px; font-weight: 600; overflow-wrap: anywhere; }
.reimbursement-review-card h3:before { content: ''; position: absolute; left: -10px; top: 3px; height: 15px; border-left: 2px solid #1989fa; }
.reimbursement-review-card p { margin: 0; line-height: 26px; color: #85878d; overflow-wrap: anywhere; }
.reimbursement-review-card label { color: #242424; }
.reimbursement-review-card .reimbursement-review-pair { display: flex; gap: 10px; justify-content: space-between; }
.reimbursement-review-pair span:last-child { flex-shrink: 0; }
.reimbursement-review-card-footer { display: flex; align-items: center; gap: 8px; margin-top: 7px; padding-top: 7px; border-top: 1px solid #f5f5f5; }
.reimbursement-review-state { flex: 1; min-width: 0; font-size: 12px; color: #b7790c; overflow-wrap: anywhere; }
.reimbursement-review-card-footer .van-button { height: 25px; padding: 0 8px; font-size: 13px; border-radius: 6px; flex-shrink: 0; }
.reimbursement-review-popup { display: flex; flex-direction: column; overflow: hidden; background: #f6f7f9; height: 100%; width: 100%; }
.reimbursement-review-popup > .van-nav-bar { flex-shrink: 0; }
.reimbursement-review-detail-body { flex: 1; overflow-y: auto; min-height: 0; padding-bottom: 12px; }
.reimbursement-review-section { margin: 12px; padding: 10px 0; border-radius: 10px; background: white; overflow: hidden; }
.reimbursement-review-section h3 { margin: 4px 14px 8px; font-size: 15px; color: #1768b0; }
.reimbursement-review-section .van-cell { font-size: 13px; padding: 8px 14px; }
.reimbursement-review-section .van-cell__title { flex: 0 0 88px; }
.reimbursement-review-section .van-cell__value { white-space: pre-wrap; overflow-wrap: anywhere; }
.reimbursement-review-section .reimbursement-review-file .van-cell__title { flex: 1; overflow-wrap: anywhere; }
.reimbursement-review-section .reimbursement-review-file .van-cell__value { flex: 0 0 40px; }
.reimbursement-review-content-image { display: block; max-width: calc(100% - 28px); margin: 10px 14px; }
.reimbursement-review-description { padding: 0 14px; line-height: 1.8; white-space: pre-wrap; overflow-wrap: anywhere; }
.reimbursement-review-muted { margin: 10px 14px; color: #969799; }
.reimbursement-review-total .van-cell__value { color: #086bc3; font-weight: 600; }
.reimbursement-review-sign-image { display: block; max-width: calc(100% - 28px); width: 240px; height: 100px; object-fit: contain; margin: 8px 14px; }
.reimbursement-review-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; }
.reimbursement-review-actions .van-button { flex: 1; border-radius: 6px; height: 40px; }
.reimbursement-review-section .reimbursement-review-sign-field { display: block; }
.reimbursement-review-sign-field .van-field__label { width: auto; margin: 0 0 10px; }
.reimbursement-review-sign-field .main-panel .van-image { width: 100%; height: 112px; min-height: 112px; overflow: hidden; border-radius: 6px; }
.reimbursement-review-sign-field .main-panel .open-button { width: 100%; border-radius: 6px; }
.reimbursement-review-preview { display: block; width: 100%; height: calc(100% - 46px); border: none; background: white; }
.reimbursement-review-preimbursement-review-image { width: 100%; height: calc(100% - 46px); object-fit: contain; }
.reimbursement-review-loading { padding: 30px; text-align: center; }
.reimbursement-review-more-bar { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 7px 13px; background: white; color: #969799; font-size: 12px; }
.reimbursement-review-more-bar > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reimbursement-review-more-popup, .reimbursement-review-choice-popup { display: flex; flex-direction: column; max-height: 80vh; background: #f7f8fa; }
.reimbursement-review-choice-popup { height: 60vh; }
.reimbursement-review-more-body { flex: 1; overflow-y: auto; min-height: 0; }
/* 筛选面板独立限定样式,避免影响详情、审核按钮和其他公共弹框。 */
.reimbursement-review-more-popup { background: #fff; border-radius: 18px 18px 0 0; overflow: hidden; }
.reimbursement-review-more-handle { flex-shrink: 0; width: 38px; height: 4px; margin: 8px auto 0; border-radius: 4px; background: #d5d8df; }
.reimbursement-review-more-header { display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; padding: 4px 12px 8px 18px; }
.reimbursement-review-more-header h2 { margin: 0; font-size: 18px; line-height: 32px; font-weight: 600; color: #20242d; }
.reimbursement-review-more-close.van-button { width: 36px; height: 36px; padding: 0; border: 0; color: #92969f; background: transparent; font-size: 20px; }
.reimbursement-review-more-popup > .reimbursement-review-more-body { flex: 0 1 auto; padding: 0 18px 14px; }
.reimbursement-review-filter-group + .reimbursement-review-filter-group { margin-top: 16px; }
.reimbursement-review-filter-label { margin: 0 0 8px; color: #252a34; font-size: 14px; line-height: 20px; font-weight: 600; }
.reimbursement-review-filter-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.reimbursement-review-filter-row .reimbursement-review-filter-label { margin: 0; }
.reimbursement-review-unit-select.van-cell { align-items: center; padding: 8px 12px; border-radius: 8px; background: #f5f6f8; font-size: 13px; }
.reimbursement-review-unit-select .van-cell__title { min-width: 0; overflow-wrap: anywhere; }
.reimbursement-review-unit-select:after, .reimbursement-review-filter-input:after { display: none; }
.reimbursement-review-filter-input.van-cell { padding: 8px 12px; border-radius: 8px; background: #f5f6f8; font-size: 13px; }
.reimbursement-review-filter-input .van-field__left-icon { color: #969ba4; margin-right: 8px; }
.reimbursement-review-segment { display: flex; flex-shrink: 0; gap: 2px; padding: 2px; border-radius: 8px; background: #f5f6f8; }
.reimbursement-review-filter-option.van-button { min-width: 0; height: 30px; padding: 0 8px; border: 1px solid transparent; border-radius: 7px; background: #f5f6f8; color: #333943; font-size: 12px; }
.reimbursement-review-segment .reimbursement-review-filter-option { min-width: 58px; height: 28px; }
.reimbursement-review-filter-option.is-selected { background: #eaf3ff; border-color: #1989fa; color: #1989fa; font-weight: 500; }
.reimbursement-review-segment .reimbursement-review-filter-option.is-selected { border-color: transparent; }
.reimbursement-review-sort-options { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
.reimbursement-review-sort-options .reimbursement-review-filter-option { position: relative; width: 100%; height: 30px; padding: 0 4px; white-space: nowrap; }
.reimbursement-review-sort-options .is-selected { padding-right: 15px; }
.reimbursement-review-sort-check { position: absolute; right: 3px; top: 50%; transform: translateY(-50%); font-size: 12px; }
.reimbursement-review-sort-direction { margin: 14px 0 0; }
.reimbursement-review-sort-direction .reimbursement-review-segment { background: transparent; gap: 6px; padding: 0; }
.reimbursement-review-more-footer { display: flex; flex-shrink: 0; gap: 10px; padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); border-top: 1px solid #f1f2f5; background: #fff; }
.reimbursement-review-more-footer .van-button { flex: 1; height: 40px; border-radius: 8px; font-size: 14px; }
.reimbursement-review-more-footer .van-button--info { flex: 2; }
.reimbursement-review-more-footer .van-button--default { color: #60656e; border-color: #e4e7ed; }
.reimbursement-review-choice-popup { background: #fff; }
.reimbursement-review-choice-popup .van-search { padding: 8px 16px; }
.reimbursement-review-choice-popup .van-search__content { border-radius: 8px; background: #f5f6f8; }
.reimbursement-review-choice-popup .van-cell { padding: 12px 18px; font-size: 13px; }
.reimbursement-review-choice-popup .reimbursement-review-choice-selected { color: #1989fa; background: #f0f6ff; }
.reimbursement-review-budget-item + .reimbursement-review-budget-item { border-top: 1px solid #f2f3f5; }
</style>
<div id="app" class="reimbursement-review-page" v-cloak>
<van-sticky>
<van-nav-bar :title="config.title" left-arrow @click-left="back"></van-nav-bar>
<van-dropdown-menu class="reimbursement-review-filters" active-color="#1867b0"
:close-on-click-outside="false" :close-on-click-overlay="false">
<van-dropdown-item ref="yearFilter" :options="yearOptions" :value="pageForm.year" :disabled="filterClosing"
@change="selectFilter('year', $event)"
@open="setFilterOpen('year', true)" @close="setFilterOpen('year', false)"></van-dropdown-item>
<van-dropdown-item ref="organizationFilter" :options="organizationOptions" :value="pageForm[organizationKey]" :disabled="filterClosing"
@change="selectFilter(organizationKey, $event)"
@open="setFilterOpen('organization', true)" @close="setFilterOpen('organization', false)">
<van-loading v-if="organizationLoading" class="reimbursement-review-loading">加载中</van-loading>
</van-dropdown-item>
<van-dropdown-item ref="isAuditFilter" :options="auditOptions" :value="pageForm.isAudit" :disabled="filterClosing"
@change="selectFilter('isAudit', $event)"
@open="setFilterOpen('isAudit', true)" @close="setFilterOpen('isAudit', false)"></van-dropdown-item>
</van-dropdown-menu>
<div class="reimbursement-review-more-bar"><span>{{ searchSummary }}</span><van-button size="mini" plain type="info" @click="openMore">更多筛选</van-button></div>
</van-sticky>
<van-pull-refresh :value="refreshing" @input="setRefreshing" @refresh="doSearch">
<van-list :value="tableLoading" :finished="finished" :error="listError" error-text="加载失败,点击重试"
:finished-text="tableData.length ? '没有更多了' : ''" :immediate-check="false"
@input="setTableLoading" @update:error="setListError" @load="pageData" class="reimbursement-review-list">
<article v-for="item in tableData" :key="item.id" class="reimbursement-review-card">
<h3>{{ item.activity_name }}</h3>
<p><label>申请人:</label>{{ item.user_name }}<span v-if="item.bxr_loginname">({{ item.bxr_loginname }})</span></p>
<p><label>{{ config.stage === 'clubChairman' ? '所属协会:' : '所属工会:' }}</label>{{ config.stage === 'clubChairman' ? (item.clubName || item.fundsUnitName || '—') : (item.unionname || item.fundsUnitName || '—') }}</p>
<p class="reimbursement-review-pair"><span><label>报销:</label>¥{{ money(item.activity_money) }}</span><span><label>申请:</label>{{ shortDate(item.reimbursement_apply_time) }}</span></p>
<div class="reimbursement-review-card-footer">
<span class="reimbursement-review-state" :style="{color: item.state_color || '#b7790c'}">{{ item.state_name || config.title }}</span>
<van-button type="info" size="small" :disabled="formLoading" @click="openDetail(item.id, false)">查看</van-button>
<van-button v-if="Number(item.reimbursement_state_id) === config.state" type="primary" size="small" :disabled="formLoading" @click="openDetail(item.id, true)">审核</van-button>
</div>
</article>
<van-empty v-if="!tableLoading && !listError && !tableData.length" description="暂无报销申请"></van-empty>
</van-list>
</van-pull-refresh>
<van-popup :value="popups.detail" position="right" class="reimbursement-review-popup" :close-on-popstate="false" :close-on-click-overlay="false">
<van-nav-bar :title="auditMode ? config.title : '报销详情'" left-arrow @click-left="closePopup"></van-nav-bar>
<div class="reimbursement-review-detail-body">
<van-loading v-if="detailLoading" class="reimbursement-review-loading">加载报销中</van-loading>
<van-empty v-else-if="!detail.id" description="详情加载失败">
<van-button size="small" type="info" @click="loadDetail(detailId)">重新加载</van-button>
</van-empty>
<reimbursement-info v-else :data="detail" @preview="previewFile">
<section v-if="auditMode && detail.canReview" class="reimbursement-review-section">
<h3>审核信息</h3>
<van-field label="审核人" :value="reviewerName" readonly></van-field>
<van-field label="审核时间" :value="reviewDate" readonly></van-field>
<van-field label="审核意见" :value="formData.auditOpinion" @input="setIdea" type="textarea" rows="3"
maxlength="500" show-word-limit required placeholder="请填写审核意见" :disabled="formLoading"></van-field>
<van-field label="审核签字" required class="reimbursement-review-sign-field">
<template #input>
<mobile-sign :value="formData.auditSign" @input="setSignature" :disabled="formLoading"
:save-to-profile="false" :popup-visible="popups.sign" panel-title="审核签字"
@open="openSignature" @close="closePopup"></mobile-sign>
</template>
</van-field>
</section>
</reimbursement-info>
</div>
<div v-if="detail.id && !detailLoading" class="reimbursement-review-actions">
<template v-if="auditMode && detail.canReview">
<van-button type="danger" plain :disabled="formLoading" @click="confirmReview(false)">拒绝</van-button>
<van-button type="primary" :loading="formLoading" :disabled="formLoading" @click="confirmReview(true)">通过</van-button>
</template>
<van-button v-else-if="detail.canReview" type="primary" @click="startAudit">去审核</van-button>
<van-button v-else @click="closePopup">关闭</van-button>
</div>
</van-popup>
<van-dialog :value="popups.confirm" :title="reviewFlag ? '确认通过' : '确认拒绝'"
:message="reviewFlag ? config.confirmMessage : '拒绝后将结束当前报销流程,请核对审核意见。'"
show-cancel-button :allow-html="false" :close-on-click-overlay="false" :close-on-popstate="false"
:before-close="beforeReviewClose"></van-dialog>
<van-popup :value="popups.preview" position="right" class="reimbursement-review-popup" :close-on-popstate="false" :close-on-click-overlay="false">
<van-nav-bar title="附件查看" left-arrow @click-left="closePopup"></van-nav-bar>
<img v-if="popups.preview && previewIsImage" :src="previewUrl" class="reimbursement-review-preimbursement-review-image" alt="报销附件">
<iframe v-else-if="popups.preview" :src="previewUrl" title="报销附件" class="reimbursement-review-preview"></iframe>
</van-popup>
<van-popup :value="popups.more" position="bottom" round class="reimbursement-review-more-popup" :close-on-popstate="false" :close-on-click-overlay="false">
<div class="reimbursement-review-more-handle" aria-hidden="true"></div>
<header class="reimbursement-review-more-header">
<h2>更多筛选</h2>
<van-button icon="cross" class="reimbursement-review-more-close" aria-label="关闭更多筛选" @click="closePopup"></van-button>
</header>
<div class="reimbursement-review-more-body">
<section v-if="config.canChooseUnion" class="reimbursement-review-filter-group">
<h3 class="reimbursement-review-filter-label">所属单位</h3>
<van-cell :title="optionText('unitId', moreForm.unitId)" class="reimbursement-review-unit-select" is-link @click="openChoice('unitId')"></van-cell>
</section>
<section v-if="config.showClub && config.stage !== 'clubChairman'" class="reimbursement-review-filter-group">
<h3 class="reimbursement-review-filter-label">所属协会</h3>
<van-cell :title="optionText('clubId', moreForm.clubId)" class="reimbursement-review-unit-select" is-link @click="openChoice('clubId')"></van-cell>
</section>
<section class="reimbursement-review-filter-group">
<h3 class="reimbursement-review-filter-label">活动名称</h3>
<van-field class="reimbursement-review-filter-input" left-icon="search" :value="moreForm.activityName"
@input="setMoreField('activityName', $event)" maxlength="100" clearable placeholder="请输入活动名称"></van-field>
</section>
<section class="reimbursement-review-filter-group">
<div class="reimbursement-review-filter-row">
<h3 class="reimbursement-review-filter-label">查询人员</h3>
<div class="reimbursement-review-segment" role="group" aria-label="查询类型">
<van-button v-for="option in choiceOptions.searchName" :key="option.value" class="reimbursement-review-filter-option"
:class="{'is-selected': moreForm.searchName === option.value}" :aria-pressed="moreForm.searchName === option.value"
@click="setMoreField('searchName', option.value)">{{ option.text }}</van-button>
</div>
</div>
<van-field class="reimbursement-review-filter-input" left-icon="search" :value="moreForm.searchKeyword"
@input="setMoreField('searchKeyword', $event)" maxlength="100" clearable
:placeholder="moreSearchPlaceholder" :aria-label="moreSearchPlaceholder"></van-field>
</section>
<section class="reimbursement-review-filter-group">
<h3 class="reimbursement-review-filter-label">排序方式</h3>
<div class="reimbursement-review-sort-options" role="group" aria-label="排序字段">
<van-button v-for="option in choiceOptions.pageOrderName" :key="option.value" class="reimbursement-review-filter-option"
:class="{'is-selected': moreForm.pageOrderName === option.value}" :aria-pressed="moreForm.pageOrderName === option.value"
@click="setMoreField('pageOrderName', option.value)">
{{ option.text }}<van-icon v-if="moreForm.pageOrderName === option.value" name="success" class="reimbursement-review-sort-check"></van-icon>
</van-button>
</div>
<div v-if="moreForm.pageOrderName" class="reimbursement-review-filter-row reimbursement-review-sort-direction">
<h3 class="reimbursement-review-filter-label">排列顺序</h3>
<div class="reimbursement-review-segment" role="group" aria-label="排列顺序">
<van-button v-for="option in choiceOptions.pageOrderBy" :key="option.value" class="reimbursement-review-filter-option"
:class="{'is-selected': moreForm.pageOrderBy === option.value}" :aria-pressed="moreForm.pageOrderBy === option.value"
@click="setMoreField('pageOrderBy', option.value)">{{ option.text }}</van-button>
</div>
</div>
</section>
</div>
<div class="reimbursement-review-more-footer"><van-button @click="resetMore">重置</van-button><van-button type="info" @click="applyMore">确定筛选</van-button></div>
</van-popup>
<van-popup :value="popups.choice" position="bottom" round class="reimbursement-review-choice-popup" :close-on-popstate="false" :close-on-click-overlay="false">
<van-nav-bar :title="choiceTitle" left-text="返回" @click-left="closePopup"></van-nav-bar>
<van-search v-if="choiceKey === 'unitId' || choiceKey === 'clubId'" :value="choiceQuery" @input="setChoiceQuery" :placeholder="choiceKey === 'clubId' ? '搜索协会' : '搜索单位'"></van-search>
<div class="reimbursement-review-more-body">
<van-loading v-if="choiceKey === 'unitId' ? unitsLoading : clubsLoading" class="reimbursement-review-loading">加载选项中</van-loading>
<van-cell v-for="option in filteredChoices" :key="option.value" :title="option.text" :class="{'reimbursement-review-choice-selected': moreForm[choiceKey] === option.value}" clickable @click="selectChoice(option.value)">
<van-icon v-if="moreForm[choiceKey] === option.value" name="success" color="#1989fa"></van-icon>
</van-cell>
<van-empty v-if="!filteredChoices.length" description="暂无选项"></van-empty>
</div>
</van-popup>
</div>
@@ -0,0 +1,147 @@
/** 活动报销审核详情:仅展示服务端已校验权限的数据,审核表单通过末尾插槽传入。 */
const reimbursementInfo = {
template: /*language=HTML*/ `
<div class="reimbursement-review-info">
<section class="reimbursement-review-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 title="联系方式" :value="data.mobile || '—'"></van-cell>
<van-cell title="报销申请时间" :value="data.reimbursement_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.startTime ? data.startTime + ' 至 ' + (data.endTime || '—') : '暂无'"></van-cell>
<van-cell 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="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})">
</section>
<section class="reimbursement-review-section">
<h3>费用及收款信息</h3>
<div v-for="(item, index) in budgets" :key="index" class="reimbursement-review-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>
</template>
</div>
<van-cell title="报销合计" :value="'¥' + money(data.activity_money)" class="reimbursement-review-total"></van-cell>
<van-cell title="预算合计" :value="'¥' + money(data.funds_money)" class="reimbursement-review-total"></van-cell>
</section>
<section class="reimbursement-review-section">
<h3>申请附件</h3>
<van-cell v-for="(file, index) in files" :key="file.id || index" :title="file.filename || file.name || '附件'" class="reimbursement-review-file"
value="查看" is-link @click="$emit('preview', file)"></van-cell>
<p v-if="!files.length" class="reimbursement-review-muted">暂无附件</p>
<h3>申请人签字</h3>
<img v-if="data.us_s" class="reimbursement-review-sign-image" :src="signUrl(data.us_s)" alt="申请人签字">
<p v-else class="reimbursement-review-muted">暂无签字</p>
</section>
<section v-for="group in reimbursementFiles" :key="group.key" class="reimbursement-review-section">
<h3>{{ group.title }}</h3>
<van-cell v-for="(file, index) in group.files" :key="file.id || index" :title="file.filename || file.name || '附件'"
class="reimbursement-review-file" value="查看" is-link @click="$emit('preview', file)"></van-cell>
<p v-if="!group.files.length" class="reimbursement-review-muted">暂无附件</p>
</section>
<section v-for="audit in audits" :key="audit.key" class="reimbursement-review-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="reimbursement-review-sign-image" :src="signUrl(audit.sign)" :alt="audit.title + '签字'">
</section>
<slot></slot>
</div>
`,
props: {
data: {type: Object, default: () => ({})}
},
computed: {
activityType() {
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])}))
},
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
const records = [
{key: 'division', title: '基层工会主席审核(申报)', id: data.division_chairman_name, name: data.dc_username, time: data.division_chairman_time, idea: data.division_chairman_idea, sign: data.dc_s},
{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: 'confirm', title: '校工会副主席审核(申报)', id: data.confirm_trade_name, name: data.ct_username, time: data.confirm_trade_time, idea: data.confirm_trade_idea, sign: data.ct_s},
{key: 'cashier', title: '校工会出纳审核(申报)', id: data.union_cashier_name, name: data.uc_username, time: data.union_cashier_time, idea: data.union_cashier_idea, sign: data.uc_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)
// 按 PC 详情展示报销审核记录,并与前期申报记录明确区分。
const reimbursementAudits = [
{key: 'reimbursement_club_audit', title: '协会负责人审核(报销)'},
{key: 'reimbursement_union_audit', title: '基层工会主席审核(报销)'},
{key: 'reimbursement_school_director', title: '校工会负责人审核(报销)'},
{key: 'reimbursement_school_chairman', title: '校工会主席审核(报销)'}
].filter((item) => data[item.key]).map((item) => {
const audit = data[item.key]
return {key: item.key, title: item.title, name: audit.username, time: audit.auditTime, idea: audit.auditOpinion, sign: audit.auditSign}
})
return records.concat(reimbursementAudits)
}
},
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 || '')
}
}
}
@@ -0,0 +1,381 @@
// 四个报销节点沿用活动申报手机页交互;入口固定节点,报销状态与审核字段独立。
const vue = new Vue({
el: '#app',
components: {'reimbursement-info': reimbursementInfo},
data() {
return {
config: reimbursementReviewConfig,
baseUrl: '/platform/jf/reimbursement/' + reimbursementReviewConfig.stage,
pageForm: {year: new Date().getFullYear(), unionId: '', unitId: '', clubId: '', activityName: '', isAudit: 2, pageNumber: 1, pageSize: 10, searchName: 'u.username', searchKeyword: '', pageOrderName: '', pageOrderBy: 'desc'},
tableData: [], tableLoading: false, refreshing: false, finished: false, listError: false,
listSerial: 0, listBusy: false, detailSerial: 0,
unions: [{text: '全部工会', value: ''}], units: [{text: '全部单位', value: ''}], organizationsLoading: false, unitsLoading: false, unitsSerial: 0,
clubs: [{text: '全部协会', value: ''}], clubsLoading: false,
moreForm: {}, choiceKey: '', choiceQuery: '',
auditOptions: [{text: '未审核', value: 2}, {text: '已审核', value: 1}, {text: '全部', value: 0}],
filterOpen: {year: false, organization: false, isAudit: false}, filterClosing: false,
popups: {filter: false, detail: false, sign: false, confirm: false, preview: false, more: false, choice: false},
detail: {}, detailId: '', detailLoading: false, auditMode: false,
formData: {id: '', auditOpinion: '', auditSign: ''}, formLoading: false, reviewFlag: true,
reviewerName: '', reviewDate: '', previewUrl: '', previewIsImage: false
}
},
computed: {
organizationKey() { return this.config.stage === 'clubChairman' ? 'clubId' : this.config.canChooseUnion ? 'unionId' : 'unitId' },
organizationOptions() { return this.config.stage === 'clubChairman' ? this.clubs : this.config.canChooseUnion ? this.unions : this.units },
organizationLoading() { return this.config.stage === 'clubChairman' ? this.clubsLoading : this.config.canChooseUnion ? this.organizationsLoading : this.unitsLoading },
/** 查询类型直接切换草稿字段,输入提示同步变化;确定筛选前不触发列表请求。 */
moreSearchPlaceholder() { return this.moreForm.searchName === 'u.loginname' ? '请输入工号' : '请输入姓名' },
searchSummary() {
const parts = []
if (this.pageForm.activityName) parts.push(this.pageForm.activityName)
if (this.config.stage !== 'clubChairman' && this.pageForm.clubId) parts.push(this.optionText('clubId', this.pageForm.clubId))
if (this.pageForm.searchKeyword) parts.push(this.pageForm.searchKeyword)
if (this.config.canChooseUnion && this.pageForm.unitId) parts.push(this.optionText('unitId', this.pageForm.unitId))
if (this.pageForm.pageOrderName) parts.push(this.optionText('pageOrderName', this.pageForm.pageOrderName))
return parts.join(' · ') || '按活动名称、姓名、工号筛选'
},
choiceOptions() {
return {
unitId: this.units,
clubId: this.clubs,
searchName: [{text: '姓名', value: 'u.username'}, {text: '工号', value: 'u.loginname'}],
pageOrderName: [{text: '默认排序', value: ''}, {text: '所属工会', value: 'unionname'}, {text: '活动主体类型', value: 'activity_type'}, {text: '经费类型', value: 'absName'}, {text: '申请时间', value: 'reimbursement_apply_time'}, {text: '申请状态', value: 'reimbursement_state_id'}],
pageOrderBy: [{text: '升序', value: 'asc'}, {text: '降序', value: 'desc'}]
}
},
choiceTitle() { return {clubId: '所属协会', unitId: '所属单位', searchName: '查询类型', pageOrderName: '排序字段', pageOrderBy: '排序方式'}[this.choiceKey] || '请选择' },
filteredChoices() {
return (this.choiceOptions[this.choiceKey] || []).filter((item) => item.text.includes(this.choiceQuery.trim()))
},
yearOptions() {
return [{text: '全部年份', value: ''}].concat(Array.from({length: 11}, (_, index) => {
const year = new Date().getFullYear() - index
return {text: year + '年', value: year}
}))
}
},
methods: {
money(value) {
return value == null || !Number.isFinite(Number(value)) ? '—' : Number(value).toLocaleString('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2})
},
shortDate(value) {
return value ? String(value).slice(5, 10) : '—'
},
setRefreshing(value) { this.$set(this, 'refreshing', value) },
setTableLoading(value) { this.$set(this, 'tableLoading', value) },
setListError(value) { this.$set(this, 'listError', value) },
setIdea(value) { this.$set(this.formData, 'auditOpinion', value) },
/** 筛选切换或提交后,从第一页替换数据;旧请求晚到时不能混入当前筛选结果。 */
doSearch() {
this.listSerial++
this.listBusy = false
this.$set(this.pageForm, 'pageNumber', 1)
this.tableData = []
this.finished = false
this.listError = false
this.pageData()
},
/** 列表返回 data.list 和 totalCount;失败保留页码供重试,成功后才推进下一页。 */
pageData() {
if (this.listBusy || this.finished) return
const serial = this.listSerial
this.listBusy = true
this.tableLoading = true
this.listError = false
$.post(this.baseUrl + '/pageData', Object.assign({}, this.pageForm))
.then((res) => {
if (serial !== this.listSerial || this._isDestroyed) return
if (res.code !== 0 || !res.data) {
this.listError = true
this.$toast(res.msg || '列表加载失败')
return
}
const list = res.data.list || []
this.tableData = this.tableData.concat(list)
this.finished = !list.length || this.tableData.length >= Number(res.data.totalCount)
this.$set(this.pageForm, 'pageNumber', this.pageForm.pageNumber + 1)
})
.fail(() => {
if (serial === this.listSerial && !this._isDestroyed) this.listError = true
})
.always(() => {
if (serial !== this.listSerial || this._isDestroyed) return
this.listBusy = false
this.tableLoading = false
this.refreshing = false
})
},
/** 返回 data 数组的组织接口沿用 PC;组织数据范围由服务端控制。 */
loadOrganizations() {
if (this.config.showClub) this.loadClubs()
if (!this.config.showUnit) return
if (this.config.canChooseUnion) {
this.organizationsLoading = true
$.post('/platform/vi/common/unions', {isPermission: false})
.then((res) => {
if (this._isDestroyed) return
if (res.code === 0) this.unions = [{text: '全部工会', value: ''}].concat((res.data || []).map((item) => ({text: item.unionname, value: item.id})))
else this.$toast(res.msg || '工会加载失败')
})
.fail(() => { if (!this._isDestroyed) this.$toast('工会加载失败,请重试') })
.always(() => { this.organizationsLoading = false })
}
this.loadUnits()
},
/** 协会选项返回 stid/name,与 PC getClubsByRole 的筛选值一致。 */
loadClubs() {
if (this.clubsLoading) return
this.clubsLoading = true
$.post('/platform/vi/common/getClubsByRole', {})
.then((res) => {
if (this._isDestroyed) return
if (res.code === 0) this.clubs = [{text: '全部协会', value: ''}].concat((res.data || []).map((item) => ({text: item.name, value: item.stid})))
else this.$toast(res.msg || '协会加载失败')
})
.fail(() => { if (!this._isDestroyed) this.$toast('协会加载失败,请重试') })
.always(() => { this.clubsLoading = false })
},
/** 更换工会清空单位;序号避免旧响应覆盖新选项。无单位筛选的协会页不请求该接口。 */
loadUnits() {
if (!this.config.showUnit) return
const serial = ++this.unitsSerial
this.unitsLoading = true
return $.post('/platform/vi/common/units', {unionId: this.config.canChooseUnion ? this.pageForm.unionId : this.config.currentUnionId})
.then((res) => {
if (serial !== this.unitsSerial || this._isDestroyed) return
if (res.code === 0) this.units = [{text: '全部单位', value: ''}].concat((res.data || []).map((item) => ({text: item.name, value: item.id})))
else this.$toast(res.msg || '单位加载失败')
})
.fail(() => { if (serial === this.unitsSerial && !this._isDestroyed) this.$toast('单位加载失败,请重试') })
.always(() => { if (serial === this.unitsSerial) this.unitsLoading = false })
},
openMore() {
this.moreForm = Object.assign({}, this.pageForm)
this.loadUnits()
if (this.config.showClub && this.clubs.length === 1) this.loadClubs()
this.$options.popupSession.open('more')
},
setMoreField(key, value) { this.$set(this.moreForm, key, value) },
setChoiceQuery(value) { this.$set(this, 'choiceQuery', value) },
/** 选择项弹框压在更多筛选上层,返回时只退出当前选择,不丢弃其他筛选值。 */
openChoice(key) {
this.choiceKey = key
this.choiceQuery = ''
this.$options.popupSession.open('choice')
},
selectChoice(value) {
this.$set(this.moreForm, this.choiceKey, value)
this.$options.popupSession.close()
},
optionText(key, value) {
const option = (this.choiceOptions[key] || []).find((item) => item.value === value)
return option ? option.text : '请选择'
},
resetMore() {
this.moreForm = Object.assign({}, this.moreForm, {unitId: this.config.canChooseUnion ? '' : this.pageForm.unitId, searchName: 'u.username', searchKeyword: '', activityName: '', clubId: this.config.stage === 'clubChairman' ? this.pageForm.clubId : '', pageOrderName: '', pageOrderBy: 'desc'})
},
applyMore() {
const values = this.moreForm
this.$options.popupSession.close().then(() => {
if (this._isDestroyed) return
const keys = ['searchName', 'searchKeyword', 'activityName', 'pageOrderName', 'pageOrderBy']
if (this.config.showClub && this.config.stage !== 'clubChairman') keys.push('clubId')
if (this.config.canChooseUnion) keys.push('unitId')
keys.forEach((key) => this.$set(this.pageForm, key, values[key]))
this.doSearch()
})
},
/** key 为年度/组织/状态字段,open 为菜单展开状态;三个菜单切换时共用一层历史。 */
setFilterOpen(key, open) {
this.$set(this.filterOpen, key, open)
if (open && key === 'organization') {
if (this.organizationOptions.length === 1 && !this.organizationLoading) this.loadOrganizations()
}
// Vant 切换菜单会同时触发旧菜单 close 和新菜单 open,等本轮更新结束后统一判断。
this.$nextTick(() => {
if (this._isDestroyed || this.filterClosing) return
if (Object.values(this.filterOpen).some(Boolean)) {
if (!this.popups.filter) this.$options.popupSession.open('filter')
} else if (this.popups.filter) {
this.filterClosing = true
this.$options.popupSession.close().then(() => { this.filterClosing = false })
}
})
},
/** key 为筛选字段,value 为对应选项值;仅值改变时刷新,菜单收起事件负责回退历史。 */
selectFilter(key, value) {
if (this.pageForm[key] === value) return
this.$set(this.pageForm, key, value)
if (key === 'unionId') {
this.$set(this.pageForm, 'unitId', '')
this.units = [{text: '全部单位', value: ''}]
this.loadUnits()
}
this.doSearch()
},
/** 每次打开申请清空上次的意见和签字,防止在不同申请之间串用审核数据。 */
openDetail(id, audit) {
if (this.formLoading) return
this.detailId = id
this.detail = {}
this.auditMode = audit
this.formData = {id, auditOpinion: '', auditSign: ''}
this.reviewDate = moment().format('YYYY-MM-DD')
this.$options.popupSession.open('detail')
this.loadDetail(id)
},
loadDetail(id) {
const serial = ++this.detailSerial
this.detailLoading = true
$.post(this.baseUrl + '/findOne', {id})
.then((res) => {
if (serial !== this.detailSerial || !this.popups.detail || this._isDestroyed) return
if (res.code === 0 && res.data) {
this.detail = res.data
if (!this.detail.canReview) this.auditMode = false
} else {
this.$toast(res.msg || '详情加载失败')
}
})
.fail(() => {
if (serial === this.detailSerial && !this._isDestroyed) this.$toast('详情加载失败,请重试')
})
.always(() => {
if (serial === this.detailSerial && !this._isDestroyed) this.detailLoading = false
})
},
startAudit() {
this.auditMode = true
this.$nextTick(() => {
const body = this.$el.querySelector('.reimbursement-review-detail-body')
if (body) body.scrollTop = body.scrollHeight
})
},
openSignature() {
if (!this.formLoading) this.$options.popupSession.open('sign')
},
/** signature 是 mobile-sign 回传的本次签字 Base64,随审核请求一起保存。 */
setSignature(signature) {
this.$set(this.formData, 'auditSign', signature)
},
confirmReview(flag) {
if (this.formLoading || !this.detail.canReview) return
if (!this.formData.auditOpinion.trim() || this.formData.auditOpinion.trim().length > 500 || !this.formData.auditSign) {
this.$toast('请填写1至500字审核意见并签字')
return
}
this.reviewFlag = flag
this.$options.popupSession.open('confirm')
},
/** Vant 用 done 结束按钮 loading;显隐仍由历史栈驱动,避免组件自行关闭后留下历史层。 */
beforeReviewClose(action, done) {
if (this.formLoading) {
done(false)
return
}
if (action === 'confirm') {
this.submitReview().always(() => done(false))
} else {
this.$options.popupSession.close().then(() => done(false))
}
},
/** id 为报销主键,audit 为意见/签字/通过标识 JSON;返回 code=0 成功,身份与时间由服务端确定。 */
submitReview() {
if (this.formLoading) return
this.formLoading = true
const audit = {auditOpinion: this.formData.auditOpinion.trim(), auditSign: this.formData.auditSign, auditPass: this.reviewFlag}
return $.post(this.baseUrl + '/doAuditing', {id: this.formData.id, audit: JSON.stringify(audit)})
.then((res) => {
if (this._isDestroyed) return
if (res.code === 0) {
this.$toast.success('审核成功')
this.$options.popupSession.clear().then(() => {
if (this._isDestroyed) return
this.formData = {id: '', auditOpinion: '', auditSign: ''}
this.doSearch()
})
} else {
this.$toast(res.msg || '审核失败')
// 关闭确认层后重读状态;若已被 PC 处理则切换为只读,保留未提交的意见便于核对。
if (this.popups.confirm) this.$options.popupSession.close().then(() => this.loadDetail(this.detailId))
}
})
.fail(() => this.$toast('提交失败,请检查网络后重试'))
.always(() => { this.formLoading = false })
},
/** 图片使用文件流,文档沿用项目预览接口;预览自身同样占一层历史。 */
previewFile(file) {
if (file.imageUrl) {
this.previewIsImage = true
this.previewUrl = file.imageUrl
this.$options.popupSession.open('preview')
return
}
if (!file.id) {
this.$toast('附件信息不完整')
return
}
this.previewIsImage = /\.(png|jpe?g|gif|webp|bmp)$/i.test(file.filename || file.filepath || '')
const streamUrl = '/file_server/fileStreamPreview?id=' + encodeURIComponent(file.id)
this.previewUrl = this.previewIsImage
? streamUrl
: '/assets/platform/plugins/pdfJs/web/viewer.html?file=' + encodeURIComponent(streamUrl)
this.$options.popupSession.open('preview')
},
closePopup() {
if (this.formLoading) {
this.$toast('正在提交,请稍候')
return
}
this.$options.popupSession.close()
},
/** 有弹框时仅关闭顶层;列表返回沿用活动申报页的 PJAX 首页入口,避免浏览器历史指向其他页面。 */
back() {
if (Object.values(this.popups).some(Boolean)) {
this.closePopup()
return
}
this.$options.popupSession.clear().then(() => {
this.$destroy()
pjaxReplace('/mobile/index')
})
}
},
created() {
// 仅注册状态同步回调;popstate 由公共管理器统一监听。
this.$options.popupSession = window.mobilePopupHistory.register('reimbursementReview-' + this.config.stage, (layers) => {
Object.keys(this.popups).forEach((name) => this.$set(this.popups, name, layers.includes(name)))
// 系统返回或页面返回先收起下拉菜单;由公共历史栈驱动,页面不另绑 popstate。
if (!layers.includes('filter')) {
Object.keys(this.filterOpen).forEach((key) => {
this.$set(this.filterOpen, key, false)
const dropdown = this.$refs[key + 'Filter']
if (dropdown) dropdown.toggle(false)
})
}
})
const user = JSON.parse(window.sessionStorage.getItem('user') || '{}')
this.reviewerName = user.username || ''
this.loadOrganizations()
this.doSearch()
const query = new URLSearchParams(window.location.search)
const id = query.get('id')
if (id) this.openDetail(id, query.get('mode') === 'audit')
// PJAX 不会自动销毁旧 Vue 实例,在替换容器前注销页面回调。
$(document).on('pjax:beforeReplace.reimbursementReview', () => this.$destroy())
},
beforeDestroy() {
this.listSerial++
this.detailSerial++
this.unitsSerial++
const session = this.$options.popupSession
if (Object.values(this.popups).some(Boolean)) {
session.clear().then(() => session.dispose())
} else {
// PJAX 替换后可能立即创建新页面;空栈必须同步注销,不能等 Promise 回调再释放注册。
session.dispose()
}
$(document).off('pjax:beforeReplace.reimbursementReview')
}
})
@@ -0,0 +1,18 @@
<!--#
layout("/mobile/platform.html"){
#-->
<!--#include('/mobile/jf/reimbursement/review/index.html'){}#-->
<script>
// 本页固定审核节点;服务端按相同节点校验权限、归属及报销状态。
const reimbursementReviewConfig = {
stage: 'schoolChairman', title: '校工会主席审核(报销)', state: 1040,
confirmMessage: '通过后将完成本次活动报销流程。', showClub: true, showUnit: true,
canChooseUnion: ${@shiro.hasRole('sysadmin')||@shiro.hasRole('A06')||@shiro.hasRole('H03')},
currentUnionId: '${@io.v.nutz.base.utils.Vi.getUnionId()}'
}
<!--#include('/mobile/jf/reimbursement/review/info.js'){}#-->
<!--#include('/mobile/jf/reimbursement/review/page.js'){}#-->
</script>
<!--#
}
#-->
@@ -0,0 +1,18 @@
<!--#
layout("/mobile/platform.html"){
#-->
<!--#include('/mobile/jf/reimbursement/review/index.html'){}#-->
<script>
// 本页固定审核节点;服务端按相同节点校验权限、归属及报销状态。
const reimbursementReviewConfig = {
stage: 'schoolDirector', title: '校工会负责人审核(报销)', state: 1020,
confirmMessage: '通过后将转交校工会主席审核。', showClub: true, showUnit: true,
canChooseUnion: ${@shiro.hasRole('sysadmin')||@shiro.hasRole('A06')||@shiro.hasRole('H03')},
currentUnionId: '${@io.v.nutz.base.utils.Vi.getUnionId()}'
}
<!--#include('/mobile/jf/reimbursement/review/info.js'){}#-->
<!--#include('/mobile/jf/reimbursement/review/page.js'){}#-->
</script>
<!--#
}
#-->
@@ -0,0 +1,18 @@
<!--#
layout("/mobile/platform.html"){
#-->
<!--#include('/mobile/jf/reimbursement/review/index.html'){}#-->
<script>
// 本页固定审核节点;服务端按相同节点校验权限、归属及报销状态。
const reimbursementReviewConfig = {
stage: 'unionChairman', title: '基层工会主席审核(报销)', state: 1010,
confirmMessage: '通过后将转交校工会负责人审核。', showClub: false, showUnit: true,
canChooseUnion: ${@shiro.hasRole('sysadmin')||@shiro.hasRole('A06')},
currentUnionId: '${@io.v.nutz.base.utils.Vi.getUnionId()}'
}
<!--#include('/mobile/jf/reimbursement/review/info.js'){}#-->
<!--#include('/mobile/jf/reimbursement/review/page.js'){}#-->
</script>
<!--#
}
#-->
@@ -25,6 +25,8 @@
<script src="${base!}/assets/platform/plugins/jquery/jquery-1.11.1.min.js"></script>
<script src="${base!}/assets/platform/plugins/pjax/jquery.pjax.js"></script>
<!-- PJAX 会晚于片段内脚本加载外链脚本,弹框历史管理必须随公共布局提前加载。 -->
<script src="${base!}/assets/mobile/js/popupHistory.js"></script>
<script src="${base!}/assets/platform/plugins/jSignature/jSignature.min.js"></script>
<script src="${base!}/assets/platform/plugins/jSignature/flashcanvas.js"></script>
<script src="${base!}/assets/platform/js/pinyin.js"></script>
@@ -79,7 +81,9 @@
}
})()
function pjaxReplace(url){
window.pjaxReplace = (url) => {
// 普通表单的导航也先消费公共签字层,避免携带未回退的弹框历史进入下一页面。
if (window.mobilePopupHistory.closeTop()) return
$.pjax({
url: url,
container: "#container",
@@ -1,53 +1,49 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<title>签字</title>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover" />
<link rel="stylesheet" href="${base!}/assets/platform/plugins/vant/vant.css">
<script src="${base!}/assets/platform/plugins/vue/vue.js"></script>
<script src="${base!}/assets/platform/plugins/vant/vant.js"></script>
<script src="${base!}/assets/platform/plugins/vue-loader/httpVueLoader.js"></script>
<script src="${base!}/assets/platform/plugins/smooth-signature/index.umd.min.js"></script>
<script src="${base!}/assets/platform/plugins/jquery/jquery-1.11.1.min.js"></script>
<script src="${base!}/assets/mobile/js/popupHistory.js"></script>
<style>body { margin: 0; background: #f7f8fa; } [v-cloak] { display: none; }</style>
</head>
<body>
<div id="app">
<signature @save="save"></signature>
<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"
:disabled="subDis || formLoading" @input="save" @open="openSignature" @close="closeSignature"></mobile-sign>
</div>
</body>
<script>
function getQueryVariable(variable) {
var query = window.location.search.substring(1);
var vars = query.split("&");
for (var i = 0; i < vars.length; i++) {
var pair = vars[i].split("=");
if (pair[0] == variable) {
return pair[1];
}
}
return '';
}
new Vue({
el: "#app",
components: {
signature: httpVueLoader("/components/sign/index.vue?v=" + new Date().getTime())
'mobile-sign': httpVueLoader("/components/sign/mobileSign.vue?v=" + new Date().getTime())
},
data() {
return {
prefix: '',
id: '',
isNull: function (val) {
if (val === null || val === undefined || val === '') {
return true
}
return false
},
}
return {prefix: '', id: '', sign: '', subDis: false, formLoading: false, showSignaturePanel: false}
},
methods: {
openSignature() {
if (!this.subDis && !this.formLoading) this.$options.popupSession.open('sign')
},
closeSignature() {
if (!this.formLoading) this.$options.popupSession.close()
},
/**
* signature 为公共签字板回传的 PNG Base64;prefix/id 来自二维码,关联 PC 端轮询。
* 保留 is_value_base64=false 的文件保存方式;接口返回 {code, msg},code=0 表示成功。
*/
save(signature) {
if (this.formLoading || this.subDis) return
this.sign = signature
this.formLoading = true
$.post("/signature/doSub", {
prefix: this.prefix,
id: this.id,
@@ -56,44 +52,31 @@
})
.then((res) => {
if (res.code === 0) {
alert("保存成功")
this.showSignaturePanel = false
this.$toast.success("保存成功")
this.$options.popupSession.clear()
} else {
alert("保存失败")
this.$toast.fail(res.msg || "保存失败,请重试")
}
})
},
base64ToFile(base64Data, filename) {
// 将base64的数据部分提取出来
const parts = base64Data.split(";base64,")
const contentType = parts[0].split(":")[1]
const raw = window.atob(parts[1])
const rawLength = raw.length
const uInt8Array = new Uint8Array(rawLength)
for (let i = 0; i < rawLength; ++i) {
uInt8Array[i] = raw.charCodeAt(i)
}
// 使用Blob对象创建File对象
const blob = new Blob([uInt8Array], { type: contentType })
blob.lastModifiedDate = new Date()
blob.name = filename
return new File([blob], filename, { type: contentType })
.fail(() => this.$toast.fail("保存失败,请重试"))
.always(() => { this.formLoading = false })
}
},
created: function () {
if (this.isNull(getQueryVariable("id")) || this.isNull(getQueryVariable("prefix"))) {
this.$notify({
message: '缺少参数!',
type: 'danger'
});
this.subDis = true
}
this.id = getQueryVariable("id")
this.prefix = getQueryVariable("prefix")
created() {
// 扫码入口也是独立页面,使用公共历史栈统一处理返回、取消及保存成功后的清理。
this.$options.popupSession = window.mobilePopupHistory.register('qrSignature', (layers) => {
this.showSignaturePanel = layers.includes('sign')
})
const query = new URLSearchParams(window.location.search)
this.id = query.get('id') || ''
this.prefix = query.get('prefix') || ''
this.subDis = !this.id || !this.prefix
},
beforeDestroy() {
const session = this.$options.popupSession
session.clear().then(() => session.dispose())
}
})
</script>
</body>
</html>