Author SHA1 Message Date
c-zhouhf1 19d6cebeb0 对接学校OA待办 2026-09-16 13:51:50 +08:00
c-zhouhf1 72b2413c00 Merge remote-tracking branch 'origin/fix_漏洞修复' 2026-09-14 19:33:57 +08:00
c-chenxs1 da13b19b57 Merge branch 'feature_积分商城_对账' into release_20260914 2026-09-14 08:45:27 +08:00
c-chengc1 c3ea84bfad fix:20260904代码扫描漏洞问题修复 2026-09-11 16:28:57 +08:00
c-chengc1 61b8819057 fix:20260904代码扫描漏洞问题修复 2026-09-11 14:04:30 +08:00
c-chengc1 01921f7fcf fix:20260904代码扫描漏洞问题修复 2026-09-09 10:55:31 +08:00
c-chengc1 465a3257b5 fix:20260904代码扫描漏洞问题修复 2026-09-09 10:25:53 +08:00
c-chengc1 45410cb0a3 Merge remote-tracking branch 'origin/feature_工会报销UI优化' into feature_工会报销UI优化 2026-09-04 16:16:09 +08:00
c-hongqw1 6ef45e5403 fix:1、提案查看的流程节点,增加排序
2、场地预约关闭按钮无法关闭的bug修复
2026-09-04 16:15:55 +08:00
c-hongqw1 4235deede7 地大bug整改 2026-09-04 16:07:12 +08:00
c-chengc1 43ae24dceb Merge remote-tracking branch 'origin/feature_程诚_0831问题修复' into release_20260831 2026-09-02 16:27:12 +08:00
c-chengc1 cccad1a852 fix:职代会pc端修复投票纪录无法正常显示工会、代表团 2026-09-02 16:26:38 +08:00
c-chengc1 0b3fef4d71 Merge remote-tracking branch 'origin/feature_工会报销UI优化' into release_20260831 2026-09-02 11:43:11 +08:00
c-hongqw1 0b44f36c83 手机端社团、工会报销bug整改 2026-09-02 10:39:56 +08:00
c-chengc1 2de6820c1a Merge remote-tracking branch 'origin/feature_周何峰' into release_20260831 2026-09-02 09:20:52 +08:00
c-zhouhf1 478b70c2d1 整改微信小程序登录
整改测试提案UI
整改测试作品征集UI
2026-09-01 18:00:36 +08:00
c-chengc1 d305317458 Merge branch 'feature_程诚_0831问题修复' into release_20260831 2026-09-01 16:00:47 +08:00
c-chengc1 982065c424 feat:职工福利PC端系统默认选择改为复选框组件;职代会PC端投票统计结果显示不正确问题修复;体育活动h5分工会报名权限提示;品牌活动h5联系人字段超长样式适配修复; 2026-09-01 16:00:11 +08:00
c-chengc1 a92f75730d feat:职工福利ui优化,我的福利支持修改福利 2026-09-01 15:04:19 +08:00
c-zhouhf1 8a0714a8e3 Merge branch 'release_20260831' into feature_周何峰 2026-08-31 15:20:19 +08:00
c-zhouhf1 bf40155fb9 品牌活动报名统计增加校工会活动管理员看全部活动
不是超级管理员加不了角色
2026-08-31 15:18:29 +08:00
126 changed files with 4105 additions and 679 deletions
+33
View File
@@ -133,6 +133,38 @@
</dependencies> </dependencies>
<dependencyManagement> <dependencyManagement>
<dependencies> <dependencies>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.1</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>javax.websocket</groupId>
<artifactId>javax.websocket-api</artifactId>
<version>1.1</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-websocket</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-el</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.nutz</groupId> <groupId>org.nutz</groupId>
<artifactId>nutzboot-parent</artifactId> <artifactId>nutzboot-parent</artifactId>
@@ -151,6 +183,7 @@
<easypoi.version>4.5.0</easypoi.version> <easypoi.version>4.5.0</easypoi.version>
<jsoup.veision>1.15.3</jsoup.veision> <jsoup.veision>1.15.3</jsoup.veision>
<mysql-connector-java.version>8.0.28</mysql-connector-java.version> <mysql-connector-java.version>8.0.28</mysql-connector-java.version>
<tomcat.version>9.0.121</tomcat.version>
<sa-token.version>1.25.0</sa-token.version> <sa-token.version>1.25.0</sa-token.version>
<easy-captcha.version>1.6.2</easy-captcha.version> <easy-captcha.version>1.6.2</easy-captcha.version>
<emoji-java.veision>4.0.0</emoji-java.veision> <emoji-java.veision>4.0.0</emoji-java.veision>
+50 -1
View File
@@ -7,6 +7,7 @@
<properties> <properties>
<nutzboot.version>2.6.0-SNAPSHOT</nutzboot.version> <nutzboot.version>2.6.0-SNAPSHOT</nutzboot.version>
<nutz.version>1.r.70-SNAPSHOT</nutz.version> <nutz.version>1.r.70-SNAPSHOT</nutz.version>
<tomcat.version>9.0.121</tomcat.version>
<mysql-connector-java.version>8.0.28</mysql-connector-java.version> <mysql-connector-java.version>8.0.28</mysql-connector-java.version>
<jaxb-api.version>2.3.1</jaxb-api.version> <jaxb-api.version>2.3.1</jaxb-api.version>
<slf4j.version>2.0.8</slf4j.version> <slf4j.version>2.0.8</slf4j.version>
@@ -144,8 +145,22 @@
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.nutz</groupId> <groupId>org.nutz</groupId>
<artifactId>nutzboot-starter-jetty</artifactId> <artifactId>nutzboot-starter-tomcat</artifactId>
<version>${nutzboot.version}</version> <version>${nutzboot.version}</version>
<exclusions>
<exclusion>
<groupId>org.gandon.tomcat</groupId>
<artifactId>juli-to-slf4j</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.nutz</groupId>
<artifactId>nutz-plugins-websocket</artifactId>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.nutz</groupId> <groupId>org.nutz</groupId>
@@ -424,6 +439,40 @@
</dependencies> </dependencies>
<dependencyManagement> <dependencyManagement>
<dependencies> <dependencies>
<!-- Servlet API 由 Tomcat 提供,防止旧 API 混入可执行 JAR。 -->
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.1</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>javax.websocket</groupId>
<artifactId>javax.websocket-api</artifactId>
<version>1.1</version>
<scope>provided</scope>
</dependency>
<!-- 显式覆盖导入的 NutzBoot BOM,避免回退到其默认 Tomcat 8.5。 -->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-websocket</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-el</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.nutz</groupId> <groupId>org.nutz</groupId>
<artifactId>nutzboot-parent</artifactId> <artifactId>nutzboot-parent</artifactId>
@@ -11,6 +11,7 @@ import com.budwk.app.web.commons.auth.satoken.SaTokenDaoRedisImpl;
import com.budwk.app.web.commons.auth.satoken.StpInterfaceImpl; import com.budwk.app.web.commons.auth.satoken.StpInterfaceImpl;
import com.budwk.app.web.commons.base.Globals; import com.budwk.app.web.commons.base.Globals;
import com.budwk.app.web.commons.ext.pubsub.WebPubSub; import com.budwk.app.web.commons.ext.pubsub.WebPubSub;
import com.budwk.app.web.commons.ext.handler.WkTomcatConfiguration;
import com.budwk.app.zhgh.dayofficework.message.service.GlobalMessageSendService; import com.budwk.app.zhgh.dayofficework.message.service.GlobalMessageSendService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.beetl.core.GroupTemplate; import org.beetl.core.GroupTemplate;
@@ -65,6 +66,7 @@ public class MainLauncher {
public static void main(String[] args) throws Exception { public static void main(String[] args) throws Exception {
NbApp nb = new NbApp().setArgs(args).setPrintProcDoc(true); NbApp nb = new NbApp().setArgs(args).setPrintProcDoc(true);
nb.getAppContext().setMainPackage("com.budwk"); nb.getAppContext().setMainPackage("com.budwk");
nb.addListener(new WkTomcatConfiguration());
nb.run(); nb.run();
} }
@@ -0,0 +1,20 @@
package com.budwk.app.base.portal;
import com.budwk.app.base.portal.model.PortalTodoResponse;
/** 成功发送记录的持久化服务,不负责再次请求门户。 */
public interface PortalTodoLogService {
/**
* 保存一次已确认成功的发送快照;使用当前业务事务,外层回滚时记录也可能回滚。
* @param requestId 本次调用的32位关联编号,对应日志主键
* @param requestUrl 实际发送接口地址,不传认证头
* @param requestBody 实际发送的完整JSON字符串
* @param responseBody 门户原始响应正文
* @param response 解析后的成功结果,含HTTP状态、业务码、提示、data及traceId
* @param requestTime 请求开始的毫秒时间戳
* @param elapsedMillis 接口耗时,不含数据库保存时间
* @return 无返回值;保存异常由调用端单独处理,不改变门户调用结果
*/
void recordSuccess(String requestId, String requestUrl, String requestBody, String responseBody,
PortalTodoResponse response, long requestTime, long elapsedMillis);
}
@@ -0,0 +1,26 @@
package com.budwk.app.base.portal;
import com.budwk.app.base.portal.model.PortalTodoRequest;
import com.budwk.app.base.portal.model.PortalTodoSendRequest;
import com.budwk.app.base.portal.model.PortalTodoResponse;
/** 信息门户待办接口,可在业务 service 中注入后独立调用。 */
public interface PortalTodoService {
/**
* @param request 业务名称、唯一 ID、单据编码、接收人登录账号及标题、正文、完整 PC 链接
* @return 调用结果;success 表示门户成功,skipped 表示开关拦截,msg/traceId 用于排查
*/
PortalTodoResponse sendTodo(PortalTodoSendRequest request);
/**
* @param request 与发送时完全一致的业务名称、唯一 ID、单据编码和接收人账号
* @return 调用结果;审核完成使用此方法,门户保留已办记录
*/
PortalTodoResponse doneTodo(PortalTodoRequest request);
/**
* @param request 与发送时完全一致的业务名称、唯一 ID、单据编码和接收人账号
* @return 调用结果;撤销或删除业务时使用,失败不会抛出网络异常阻断业务
*/
PortalTodoResponse removeTodo(PortalTodoRequest request);
}
@@ -0,0 +1,58 @@
# 门户待办调用
在业务 service 中注入 `PortalTodoService`。三个方法均返回 `PortalTodoResponse`,不会因门户 HTTP 失败主动抛出异常。
```java
@Inject
private PortalTodoService portalTodoService;
// 同一待办发送、完成、删除时保留下面四项定位数据。
PortalTodoSendRequest request = new PortalTodoSendRequest();
request.setEntityName("职工福利审核");
request.setEntityId(taskId);
request.setEntityNumber(businessNumber);
request.setTargets(List.of(receiverLoginName));
request.setSubject("职工福利审核待办");
request.setContent("您有一条职工福利申请需要审核。");
request.setLink(auditPageUrl);
request.setMobileLink(h5AuditPageUrl);
request.setCreator(senderLoginName);
request.setCreatorName(senderName);
PortalTodoResponse result = portalTodoService.sendTodo(request);
```
审核完成时调用 `portalTodoService.doneTodo(request)`;撤销或删除时调用 `portalTodoService.removeTodo(request)`。这两个操作也可单独构造 `PortalTodoRequest`,只设置四项定位数据,不需要标题和内容。
- `success=true`:门户接口返回成功,实际展示仍以门户为准。
- `skipped=true`:开发模式或开关关闭,没有发送请求。
- 两者均为 `false`:校验、网络或门户业务失败,使用 `msg``code``traceId` 排查。
- `data` 按门户原值返回,不当作 `notifyId` 使用。
## 启用配置
1. 部署配置设置 `portal.todo.base-url``portal.todo.username``portal.todo.password`。密码按原始字符填写,不编码、不转义增删。
2. 按当前项目约定,`Globals.sso` 必须为 `true`(来自 `cas.enable`)。
3. 在现有系统参数管理中配置 `AppPortalTodo=true` 并刷新系统配置;未配置默认关闭,与 `AppSms` 无关。
4. 设置正确的项目 `AppDomain`,供 wf 构建审核链接。
wf 会自动同步通用任务变化,业务代码无需再次发送同一 wf 待办。HTTP 超时不自动重试;本地事务无法撤销已成功的外部调用,需要根据日志人工补偿。
PC链接使用任务的 `formKey`,手机链接使用 `h5FormKey`,补齐 `AppDomain` 并携带编码后的 `taskId``bizId``taskKey`。未配置手机表单时记录提示并回退到 `/platform/h5` 手机首页。独立调用的 `mobileLink` 应传完整HTTP(S)地址,未传也回退到项目手机首页。修改只影响新发送的待办,不更新门户已有链接。
## 发送记录与控制台排查
成功的 `send` 请求写入 `portal_todo_log`,每次请求一条,保存实际发送的完整JSON、原始响应正文、业务标识、标题正文、发送人、接收人账号数组、链接、请求时间、耗时及返回状态。失败、跳过、已办和删除不写成功发送记录。
控制台搜索 `——————————————学校OA待办发送`,按“调用编号”关联“请求开始”和“请求结束”。结束日志包含成功/失败/跳过、HTTP状态、业务码、提示、traceId、耗时和响应正文;无响应的网络异常仍输出结束记录。日志不打印Basic凭证或认证头。
成功发送但保存记录失败时另输出“发送成功但记录入库失败”,门户返回结果保持成功,不自动重发。保存沿用业务事务,外层回滚时记录也可能回滚;控制台“已写入当前事务”不表示外层事务已提交。
表结构通过现有实体扫描机制管理:`MainLauncher.init_sys` 仅在DEBUG日志级别时自动建表。生产部署需确认该表已创建,本功能不生成或执行额外SQL,不提供后台查询页面。
## 验证说明
`PortalTodoServiceTest` 使用本地模拟 HTTP 服务;`FlowPortalTodoServiceTest` 使用内存 Dao 替身。两者不连接真实门户或业务数据库。
串并行会签、转办、撤回和流程删除仍需在测试部署中走完整业务流程验收。提案附议等脱离标准任务状态的业务特例未单独适配。
现有 `activate` 方法只恢复 `INTERRUPT` 状态任务,并不恢复 `PENDING`;本次保持该业务规则,仅为实际恢复的任务重发待办。
@@ -0,0 +1,56 @@
package com.budwk.app.base.portal.impl;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.budwk.app.base.portal.PortalTodoLogService;
import com.budwk.app.base.portal.model.PortalTodoLog;
import com.budwk.app.base.portal.model.PortalTodoResponse;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
/** 记录实际请求报文的业务快照,不读取可变化的人员或流程数据。 */
@IocBean
public class PortalTodoLogServiceImpl implements PortalTodoLogService {
@Inject
private Dao dao;
@Override
@Aop(TransAop.READ_COMMITTED)
public void recordSuccess(String requestId, String requestUrl, String requestBody, String responseBody,
PortalTodoResponse response, long requestTime, long elapsedMillis) {
// 防止跳过或HTTP失败被误记为成功发送;多接收人作为一次请求保留原数组。
if (response == null || !response.isSuccess() || response.isSkipped()
|| response.getHttpStatus() == null || response.getHttpStatus() < 200
|| response.getHttpStatus() >= 300) return;
JSONObject payload = JSONUtil.parseObj(requestBody);
PortalTodoLog record = new PortalTodoLog();
record.setId(requestId);
record.setAppName(payload.getStr("appName"));
record.setModuleName(payload.getStr("moduleName"));
record.setEntityName(payload.getStr("entityName"));
record.setEntityId(payload.getStr("entityId"));
record.setEntityNumber(payload.getStr("entityNumber"));
record.setSubject(payload.getStr("subject"));
record.setContent(payload.getStr("content"));
record.setCreator(payload.getStr("creator"));
record.setCreatorName(payload.getStr("creatorName"));
record.setTargets(JSONUtil.toJsonStr(payload.getJSONArray("targets")));
record.setLink(payload.getStr("link"));
record.setMobileLink(payload.getStr("mobileLink"));
record.setRequestUrl(requestUrl);
record.setRequestBody(requestBody);
record.setResponseBody(responseBody);
record.setRequestTime(requestTime);
record.setElapsedMillis(elapsedMillis);
record.setHttpStatus(response.getHttpStatus());
record.setSuccess(response.isSuccess());
record.setCode(response.getCode());
record.setMsg(response.getMsg());
record.setData(response.getData());
record.setTraceId(response.getTraceId());
dao.insert(record);
}
}
@@ -0,0 +1,193 @@
package com.budwk.app.base.portal.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.budwk.app.base.portal.PortalTodoService;
import com.budwk.app.base.portal.PortalTodoLogService;
import com.budwk.app.base.portal.model.*;
import com.budwk.app.web.commons.base.Globals;
import lombok.extern.slf4j.Slf4j;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.impl.PropertiesProxy;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/** Basic 认证的门户 REST 客户端,所有操作共用环境拦截及结果处理。 */
@IocBean
@Slf4j
public class PortalTodoServiceImpl implements PortalTodoService {
private static final String LOG_PREFIX = "——————————————学校OA待办发送";
@Inject
private PropertiesProxy conf;
@Inject
private PortalTodoLogService portalTodoLogService;
@Override
@Aop(TransAop.READ_COMMITTED)
public PortalTodoResponse sendTodo(PortalTodoSendRequest request) {
return post("send", request);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public PortalTodoResponse doneTodo(PortalTodoRequest request) {
return post("done", request);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public PortalTodoResponse removeTodo(PortalTodoRequest request) {
return post("removeTodo", request);
}
/** 先拦截环境与开关,再校验参数并请求;禁止自动重试不确定是否已送达的发送。 */
private PortalTodoResponse post(String operation, PortalTodoRequest request) {
PortalTodoResponse result = new PortalTodoResponse();
String requestId = UUID.randomUUID().toString().replace("-", "");
long requestTime = System.currentTimeMillis();
long startedNanos = System.nanoTime();
String requestUrl = null;
String requestBody = null;
String responseBody = null;
String exceptionType = null;
HttpURLConnection connection = null;
try {
// 环境与功能开关均开启才调用门户,拦截早于参数及认证配置校验。
if (!Globals.sso || !Globals.MyConfig.getBoolean("AppPortalTodo", false)) {
result.setSkipped(true);
result.setCode("SKIPPED");
result.setMsg(!Globals.sso ? "开发模式不调用门户待办" : "门户待办开关未开启");
return result;
}
JSONObject payload = payload(operation, request);
requestBody = payload.toString();
String baseUrl = required(conf.get("portal.todo.base-url"), "门户地址未配置");
String username = required(conf.get("portal.todo.username"), "门户认证用户名未配置");
String password = required(conf.get("portal.todo.password"), "门户认证密码未配置");
requestUrl = StrUtil.removeSuffix(baseUrl, "/")
+ "/openapi/sys-notifybus/sysNotifyComponent/" + operation;
// 打印实际发送的同一份JSON,不输出Basic用户名、密码或Authorization头。
log.info("{}【请求开始】调用编号={},操作={},地址={},发送内容={}",
LOG_PREFIX, requestId, operation, requestUrl, requestBody);
connection = (HttpURLConnection) URI.create(requestUrl).toURL().openConnection();
connection.setInstanceFollowRedirects(false);
connection.setRequestMethod("POST");
connection.setDoOutput(true);
connection.setUseCaches(false);
connection.setConnectTimeout(conf.getInt("portal.todo.connect-timeout", 5000));
connection.setReadTimeout(conf.getInt("portal.todo.read-timeout", 10000));
connection.setRequestProperty("Content-Type", "application/json;charset=UTF-8");
connection.setRequestProperty("Authorization", "Basic " + Base64.getEncoder()
.encodeToString((username + ":" + password).getBytes(StandardCharsets.UTF_8)));
try (OutputStream out = connection.getOutputStream()) {
out.write(requestBody.getBytes(StandardCharsets.UTF_8));
}
int status = connection.getResponseCode();
result.setHttpStatus(status);
try (InputStream in = status >= 400 ? connection.getErrorStream() : connection.getInputStream()) {
// 先保留原始响应,非JSON、解析失败等情况也能在控制台排查。
responseBody = in == null ? "" : new String(in.readAllBytes(), StandardCharsets.UTF_8);
JSONObject json = JSONUtil.parseObj(responseBody);
result.setCode(json.getStr("code"));
result.setMsg(json.getStr("msg"));
result.setData(json.getStr("data"));
result.setTraceId(json.getStr("traceId"));
result.setSuccess(status >= 200 && status < 300 && Boolean.TRUE.equals(json.getBool("success")));
}
} catch (Exception e) {
result.setSuccess(false);
result.setCode("CLIENT_ERROR");
exceptionType = e.getClass().getSimpleName();
// 异常仅记录类型,不打印可能携带认证信息的异常原文。
result.setMsg(e instanceof IllegalArgumentException ? "门户参数、配置或响应格式无效" : "门户请求失败或超时");
} finally {
long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos);
// 将多行响应编码为单行JSON字符串,确保每条日志均可用统一前缀定位。
String outcome = result.isSkipped() ? "已跳过" : result.isSuccess() ? "成功" : "失败";
String format = "{}【请求结束-{}】调用编号={},操作={},业务ID={}HTTP状态={},耗时={}mscode={}msg={}traceId={},异常类型={},返回内容={}";
Object[] values = {LOG_PREFIX, outcome, requestId, operation,
request == null ? null : request.getEntityId(), result.getHttpStatus(), elapsedMillis,
result.getCode(), JSONUtil.toJsonStr(result.getMsg()), result.getTraceId(), exceptionType,
JSONUtil.toJsonStr(responseBody)};
if (result.isSuccess() || result.isSkipped()) log.info(format, values);
else log.error(format, values);
if (connection != null) connection.disconnect();
// 仅send成功落库。日志保存异常不能改写门户成功结果,也不能触发再次发送。
if ("send".equals(operation) && result.isSuccess() && !result.isSkipped()) {
try {
portalTodoLogService.recordSuccess(requestId, requestUrl, requestBody, responseBody,
result, requestTime, elapsedMillis);
log.info("{}【记录已写入当前事务】调用编号={},traceId={}", LOG_PREFIX, requestId, result.getTraceId());
} catch (Exception e) {
log.error("{}【发送成功但记录入库失败】调用编号={},业务ID={}traceId={},异常类型={}",
LOG_PREFIX, requestId, request.getEntityId(), result.getTraceId(), e.getClass().getSimpleName());
}
}
}
return result;
}
/** 只发送接口使用的字段;删除和已办不得混入标题、正文等发送参数。 */
private JSONObject payload(String operation, PortalTodoRequest request) {
if (request == null) throw new IllegalArgumentException("请求不能为空");
if (request.getTargets() == null || request.getTargets().isEmpty()
|| request.getTargets().stream().anyMatch(StrUtil::isBlank)) {
throw new IllegalArgumentException("接收人账号不能为空");
}
JSONObject json = new JSONObject();
json.set("appName", "智慧工会平台");
json.set("moduleName", "智慧工会平台");
json.set("entityName", required(request.getEntityName(), "业务名称不能为空"));
json.set("entityId", required(request.getEntityId(), "业务ID不能为空"));
json.set("entityNumber", required(request.getEntityNumber(), "单据编码不能为空"));
json.set("targets", request.getTargets().stream().distinct().toList());
json.set("notifyType", "todo");
json.set("orgProperty", "fdLoginName");
json.set("todoType", 1);
json.set("async", false);
if ("send".equals(operation)) {
PortalTodoSendRequest send = (PortalTodoSendRequest) request;
json.set("subject", required(send.getSubject(), "标题不能为空"));
json.set("content", required(send.getContent(), "正文不能为空"));
String link = required(send.getLink(), "PC链接不能为空");
URI uri = URI.create(link);
if (!("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))
|| uri.getHost() == null) throw new IllegalArgumentException("PC链接必须为完整HTTP地址");
json.set("link", link);
// 独立调用可传完整H5地址;未提供时使用手机首页,避免门户打开空白页。
String mobileLink = send.getMobileLink();
if (StrUtil.isBlank(mobileLink)) {
mobileLink = URI.create(StrUtil.appendIfMissing(Globals.AppDomain, "/"))
.resolve("/platform/h5").toString();
}
URI mobileUri = URI.create(mobileLink);
if (!("http".equalsIgnoreCase(mobileUri.getScheme()) || "https".equalsIgnoreCase(mobileUri.getScheme()))
|| mobileUri.getHost() == null) throw new IllegalArgumentException("手机链接必须为完整HTTP地址");
json.set("mobileLink", mobileLink);
if (StrUtil.isNotBlank(send.getCreator())) json.set("creator", send.getCreator());
if (StrUtil.isNotBlank(send.getCreatorName())) json.set("creatorName", send.getCreatorName());
json.set("queryType", 1);
json.set("todoLevel", 3);
}
return json;
}
private String required(String value, String message) {
if (StrUtil.isBlank(value)) throw new IllegalArgumentException(message);
return value;
}
}
@@ -0,0 +1,99 @@
package com.budwk.app.base.portal.model;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.*;
/** 每次发送成功的请求保存一条快照,多个接收人仍归属于同一次请求。 */
@Data
@EqualsAndHashCode(callSuper = true)
@Table("portal_todo_log")
@TableMeta("{'mysql-charset':'utf8mb4'}")
@Comment("学校OA待办成功发送记录")
public class PortalTodoLog extends BaseModel {
@Name
@ColDefine(type = ColType.VARCHAR, width = 32)
@Comment("请求关联编号,与控制台调用编号一致")
private String id;
@Column
@Comment("来源系统")
private String appName;
@Column
@Comment("来源模块")
private String moduleName;
@Column
@Comment("来源业务名称")
private String entityName;
@Column
@Comment("业务记录或wf任务ID")
private String entityId;
@Column
@Comment("业务单据编码")
private String entityNumber;
@Column
@ColDefine(type = ColType.TEXT)
@Comment("待办标题")
private String subject;
@Column
@ColDefine(customType = "MEDIUMTEXT")
@Comment("待办正文")
private String content;
@Column
@Comment("发送人登录账号")
private String creator;
@Column
@Comment("发送人姓名")
private String creatorName;
@Column
@ColDefine(customType = "MEDIUMTEXT")
@Comment("接收人登录账号JSON数组,保留工号前导零")
private String targets;
@Column
@ColDefine(type = ColType.TEXT)
@Comment("PC跳转地址")
private String link;
@Column
@ColDefine(type = ColType.TEXT)
@Comment("移动端跳转地址")
private String mobileLink;
@Column
@ColDefine(type = ColType.TEXT)
@Comment("发送接口地址,不含认证信息")
private String requestUrl;
@Column
@ColDefine(customType = "MEDIUMTEXT")
@Comment("实际发送的完整JSON报文")
private String requestBody;
@Column
@ColDefine(customType = "MEDIUMTEXT")
@Comment("门户完整响应正文")
private String responseBody;
@Column
@Comment("请求开始时间,毫秒时间戳")
private Long requestTime;
@Column
@Comment("接口调用耗时,毫秒,不含日志入库")
private Long elapsedMillis;
@Column
@Comment("HTTP响应状态码")
private Integer httpStatus;
@Column
@Comment("门户是否返回成功,不代表接收人已查看")
private Boolean success;
@Column
@Comment("门户业务状态码")
private String code;
@Column
@ColDefine(type = ColType.TEXT)
@Comment("门户返回提示")
private String msg;
@Column
@ColDefine(customType = "MEDIUMTEXT")
@Comment("门户原样返回的数据,不推断为notifyId")
private String data;
@Column
@Comment("门户链路追踪编号")
private String traceId;
}
@@ -0,0 +1,17 @@
package com.budwk.app.base.portal.model;
import lombok.Data;
import java.util.List;
/** 门户待办定位参数;发送、已办和删除时必须使用同一组业务标识及人员账号。 */
@Data
public class PortalTodoRequest {
/** 来源业务名称,同一待办生命周期内不得更改。 */
private String entityName;
/** 业务记录或任务的唯一 ID,不能仅使用可重复的节点名称。 */
private String entityId;
/** 所属业务单据编码。 */
private String entityNumber;
/** 门户登录账号数组,保留工号前导零,不传本系统人员 ID。 */
private List<String> targets;
}
@@ -0,0 +1,18 @@
package com.budwk.app.base.portal.model;
import lombok.Data;
/** 门户调用结果;跳过不表示发送成功,网络异常时 httpStatus 为空。 */
@Data
public class PortalTodoResponse {
private boolean success;
private boolean skipped;
private Integer httpStatus;
/** 门户业务错误码,或本地 SKIPPED / CLIENT_ERROR。 */
private String code;
private String msg;
/** 门户原样返回的数据,不推断为 notifyId。 */
private String data;
/** 提供给门户管理员排查的链路标识。 */
private String traceId;
}
@@ -0,0 +1,22 @@
package com.budwk.app.base.portal.model;
import lombok.Data;
import lombok.EqualsAndHashCode;
/** 发送待办参数;继承的定位参数需由业务方保留,供后续已办、删除使用。 */
@Data
@EqualsAndHashCode(callSuper = true)
public class PortalTodoSendRequest extends PortalTodoRequest {
/** 待办标题。 */
private String subject;
/** 待办正文。 */
private String content;
/** 发送人门户登录账号,可不填。 */
private String creator;
/** 发送人姓名,可不填。 */
private String creatorName;
/** 可访问的 PC 业务页面完整地址,必填。 */
private String link;
/** 可访问的H5业务页面完整HTTP(S)地址;未传时回退到项目手机首页。 */
private String mobileLink;
}
@@ -93,6 +93,15 @@ public class FlowTodoCenterController {
return Result.success(pagination); return Result.success(pagination);
} }
/**
* 分页查询当前用户的已办任务,仅返回列表展示及详情跳转所需的信息。
*
* @param pageNumber 查询页码
* @param pageSize 每页记录数
* @param searchKeyword 流程名称查询关键字
* @param category 流程分类
* @return 已办任务分页结果,不包含完整流程变量和任务表单数据
*/
@At @At
@SaCheckLogin @SaCheckLogin
public Result done(Integer pageNumber, Integer pageSize, String searchKeyword, String category) { public Result done(Integer pageNumber, Integer pageSize, String searchKeyword, String category) {
@@ -106,8 +115,13 @@ public class FlowTodoCenterController {
t.h5FormKey, t.h5FormKey,
t.createdAt, t.createdAt,
t.finishTime, t.finishTime,
t.variable AS taskVariable, -- 保留任务变量字段类型,但已办列表不返回其中的业务表单数据。
ins.variable, '{}' AS taskVariable,
-- 列表仅需流程名称和申请人,避免完整变量中的身份证等信息泄露。
JSON_OBJECT(
'instanceName', ins.variable -> '$.instanceName',
'initiatorName', ins.variable -> '$.initiatorName'
) AS variable,
ins.state AS instanceState, ins.state AS instanceState,
ins.id AS instanceId, ins.id AS instanceId,
ins.businessNo, ins.businessNo,
@@ -0,0 +1,66 @@
package com.budwk.app.flow.listenter;
import com.budwk.app.flow.engine.event.ProcessEvent;
import com.budwk.app.flow.engine.event.ProcessEventListener;
import com.budwk.app.flow.entity.ProcessTask;
import com.budwk.app.flow.enums.ProcessEventTypeEnum;
import com.budwk.app.flow.enums.ProcessTaskStateEnum;
import com.budwk.app.flow.service.FlowPortalTodoService;
import com.budwk.app.web.commons.base.Globals;
import lombok.extern.slf4j.Slf4j;
import org.nutz.dao.Dao;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
/** 门户专用监听器;发送从实际参与人新增入口触发,不重复消费任务开始事件。 */
@IocBean
@Slf4j
public class ProcessPortalTodoEventListener implements ProcessEventListener {
private static final String LOG_PREFIX = "——————————————学校OA待办发送";
@Inject
private Dao dao;
@Inject
private FlowPortalTodoService flowPortalTodoService;
/** @param event 任务结束/撤销或实例结束事件;按真实状态区分已办和移除,异常不阻断流程。 */
@Override
public void onEvent(ProcessEvent event) {
log.info("{}【WF事件入口】事件={},来源ID={}", LOG_PREFIX, event.getEventType(), event.getSourceId());
// 未启用门户同步时停止消费事件,保留日志便于定位跳过原因。
if (!Globals.sso || !Globals.MyConfig.getBoolean("AppPortalTodo", false)) {
log.info("{}【WF事件跳过】事件={},来源ID={},原因={}sso={}AppPortalTodo={}",
LOG_PREFIX, event.getEventType(), event.getSourceId(),
!Globals.sso ? "开发模式" : "门户待办开关未开启", Globals.sso,
Globals.MyConfig.getBoolean("AppPortalTodo", false));
return;
}
try {
if (event.getEventType() == ProcessEventTypeEnum.PROCESS_TASK_END) {
ProcessTask task = dao.fetch(ProcessTask.class, event.getSourceId());
if (task == null) {
log.warn("{}【WF事件跳过】来源ID={},原因=任务不存在", LOG_PREFIX, event.getSourceId());
return;
}
if (ProcessTaskStateEnum.FINISHED.getCode().equals(task.getTaskState())) {
flowPortalTodoService.doneTask(task.getId());
} else if (ProcessTaskStateEnum.ABANDON.getCode().equals(task.getTaskState())) {
flowPortalTodoService.removeTask(task.getId(), null);
} else {
log.info("{}【WF事件跳过】任务ID={},原因=结束事件的任务状态无需同步,任务状态={}",
LOG_PREFIX, task.getId(), task.getTaskState());
}
} else if (event.getEventType() == ProcessEventTypeEnum.PROCESS_TASK_REVOKE) {
flowPortalTodoService.removeTask(event.getSourceId(), null);
} else if (event.getEventType() == ProcessEventTypeEnum.PROCESS_INSTANCE_END) {
flowPortalTodoService.removeInstance(event.getSourceId());
} else {
// 开始事件不再次发送,发送已由新增参与人入口负责。
log.info("{}【WF事件跳过】事件={},来源ID={},原因=该事件无需本监听器同步,发送由新增参与人入口负责",
LOG_PREFIX, event.getEventType(), event.getSourceId());
}
} catch (Exception e) {
log.warn("{}【WF事件同步失败】来源ID={},异常类型={}", LOG_PREFIX, event.getSourceId(), e.getClass().getSimpleName());
}
}
}
@@ -35,6 +35,9 @@ public class FlowCommonService {
@Inject @Inject
private Dao dao; private Dao dao;
@Inject
private FlowPortalTodoService flowPortalTodoService;
/** /**
* 执行任务 * 执行任务
* @param args * @param args
@@ -111,9 +114,12 @@ public class FlowCommonService {
// 撤销任务 // 撤销任务
List<ProcessTask> taskList = dao.query(ProcessTask.class, Cnd.where(ProcessTask::getTaskParentId, "=", taskId)); List<ProcessTask> taskList = dao.query(ProcessTask.class, Cnd.where(ProcessTask::getTaskParentId, "=", taskId));
// 收集全部被撤回的任务,包含并行会签的非直接子任务,避免门户残留待办。
java.util.Set<Long> revokedTaskIds = new java.util.LinkedHashSet<>();
for (ProcessTask task : taskList) { for (ProcessTask task : taskList) {
task.setTaskState(ProcessTaskStateEnum.WITHDRAW.getCode()); task.setTaskState(ProcessTaskStateEnum.WITHDRAW.getCode());
dao.update(task); dao.update(task);
revokedTaskIds.add(task.getId());
} }
// 会签并行任务 撤销后续任务 // 会签并行任务 撤销后续任务
if (selfTask.getPerformType().equals(ProcessTaskPerformTypeEnum.COUNTERSIGN.getCode())) { if (selfTask.getPerformType().equals(ProcessTaskPerformTypeEnum.COUNTERSIGN.getCode())) {
@@ -122,6 +128,7 @@ public class FlowCommonService {
for (ProcessTask doingTask : doingTasks) { for (ProcessTask doingTask : doingTasks) {
doingTask.setTaskState(ProcessTaskStateEnum.WITHDRAW.getCode()); doingTask.setTaskState(ProcessTaskStateEnum.WITHDRAW.getCode());
dao.update(doingTask); dao.update(doingTask);
revokedTaskIds.add(doingTask.getId());
} }
} }
@@ -136,6 +143,12 @@ public class FlowCommonService {
ProcessPublisher.notify(ProcessEvent.builder().eventType(ProcessEventTypeEnum.PROCESS_TASK_REVOKE).sourceId(task.getId()).build()); ProcessPublisher.notify(ProcessEvent.builder().eventType(ProcessEventTypeEnum.PROCESS_TASK_REVOKE).sourceId(task.getId()).build());
} }
// 直接子任务由撤回事件同步;额外并行任务在此补齐,不重复删除同一任务。
taskList.forEach(task -> revokedTaskIds.remove(task.getId()));
revokedTaskIds.forEach(id -> flowPortalTodoService.removeTask(id, null));
// 原任务重新进入办理状态,需要恢复它在门户中的待办。
flowPortalTodoService.sendTask(selfTask.getId(), null);
return Result.success(); return Result.success();
} }
@@ -0,0 +1,213 @@
package com.budwk.app.flow.service;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.budwk.app.base.portal.PortalTodoService;
import com.budwk.app.base.portal.model.PortalTodoSendRequest;
import com.budwk.app.flow.constant.FlowConst;
import com.budwk.app.flow.entity.ProcessInstance;
import com.budwk.app.flow.entity.ProcessTask;
import com.budwk.app.flow.entity.ProcessTaskActor;
import com.budwk.app.flow.enums.ProcessTaskStateEnum;
import com.budwk.app.sys.views.View_user;
import com.budwk.app.web.commons.base.Globals;
import lombok.extern.slf4j.Slf4j;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* wf 门户适配,所有标识由任务持久化数据重建,不依赖当前登录请求。
*/
@IocBean
@Slf4j
public class FlowPortalTodoService {
private static final String LOG_PREFIX = "——————————————学校OA待办发送";
@Inject
private Dao dao;
@Inject
private PortalTodoService portalTodoService;
/**
* @param taskId wf 任务 ID
* @param actors 本次新增的参与人,null 表示当前任务全部参与人;空列表不发送
* @return 无返回值;门户异常只记录,不中断工作流
*/
@Aop(TransAop.READ_COMMITTED)
public void sendTask(Long taskId, List<ProcessTaskActor> actors) {
sync(taskId, actors, "send");
}
/**
* @param taskId 已完成的 wf 任务 ID;参与人账号沿用原任务,异常只记录。
*/
@Aop(TransAop.READ_COMMITTED)
public void doneTask(Long taskId) {
sync(taskId, null, "done");
}
/**
* @param taskId 原 wf 任务 ID,必须在删除任务记录之前调用
* @param actors 被移除的参与人快照;null 表示原任务全部参与人
* @return 无返回值;不改变 wf 数据,门户失败仅记录
*/
@Aop(TransAop.READ_COMMITTED)
public void removeTask(Long taskId, List<ProcessTaskActor> actors) {
sync(taskId, actors, "removeTodo");
}
/**
* @param instanceId wf 实例 ID;实例结束或删除前清理非已完成任务的门户待办。
*/
@Aop(TransAop.READ_COMMITTED)
public void removeInstance(Long instanceId) {
log.info("{}【WF实例清理入口】实例ID={}", LOG_PREFIX, instanceId);
if (!enabled()) {
log.info("{}【WF跳过】实例ID={},原因={}sso={}AppPortalTodo={}", LOG_PREFIX, instanceId,
!Globals.sso ? "开发模式" : "门户待办开关未开启", Globals.sso,
Globals.MyConfig.getBoolean("AppPortalTodo", false));
return;
}
try {
List<ProcessTask> tasks = dao.query(ProcessTask.class,
Cnd.where(ProcessTask::getProcessInstanceId, "=", instanceId)
.and(ProcessTask::getTaskState, "!=", ProcessTaskStateEnum.FINISHED.getCode()));
if (tasks.isEmpty()) {
log.info("{}【WF跳过】实例ID={},原因=没有需要清理的任务", LOG_PREFIX, instanceId);
}
for (ProcessTask task : tasks) sync(task.getId(), null, "removeTodo");
} catch (Exception e) {
log.warn("{}【WF实例清理失败】实例ID={},异常类型={}", LOG_PREFIX, instanceId, e.getClass().getSimpleName());
}
}
private boolean enabled() {
// WF同步与独立门户调用使用相同的环境及功能开关。
return Globals.sso && Globals.MyConfig.getBoolean("AppPortalTodo", false);
}
/**
* 包括数据读取和参数组装在内的异常均隔离,避免影响已授权的 wf 业务操作。
*/
private void sync(Long taskId, List<ProcessTaskActor> actors, String operation) {
// 在开关判断之前记录入口,使未进入HTTP客户端的调用也能明确定位跳过原因。
log.info("{}【WF同步入口】任务ID={},操作={}", LOG_PREFIX, taskId, operation);
if (!enabled()) {
log.info("{}【WF跳过】任务ID={},操作={},原因={}sso={}AppPortalTodo={}",
LOG_PREFIX, taskId, operation, !Globals.sso ? "开发模式" : "门户待办开关未开启",
Globals.sso, Globals.MyConfig.getBoolean("AppPortalTodo", false));
return;
}
try {
ProcessTask task = dao.fetch(ProcessTask.class, taskId);
if (task == null) {
log.warn("{}【WF跳过】任务ID={},操作={},原因=任务不存在", LOG_PREFIX, taskId, operation);
return;
}
// 首次申请节点由发起人直接提交,无需发送瞬时待办;退回申请节点仍需发送。
if (Long.valueOf(0).equals(task.getTaskParentId()) && "startTask".equals(task.getTaskName())) {
log.info("{}【WF跳过】任务ID={},操作={},原因=首次申请节点无需发送待办", LOG_PREFIX, taskId, operation);
return;
}
if ("send".equals(operation) && !ProcessTaskStateEnum.DOING.getCode().equals(task.getTaskState())) {
log.info("{}【WF跳过】任务ID={},操作={},原因=任务非办理状态,任务状态={}",
LOG_PREFIX, taskId, operation, task.getTaskState());
return;
}
ProcessInstance instance = dao.fetch(ProcessInstance.class, task.getProcessInstanceId());
if (instance == null) {
log.warn("{}【WF跳过】任务ID={},操作={},实例ID={},原因=流程实例不存在",
LOG_PREFIX, taskId, operation, task.getProcessInstanceId());
return;
}
List<ProcessTaskActor> recipients = actors == null ? dao.query(ProcessTaskActor.class,
Cnd.where(ProcessTaskActor::getProcessTaskId, "=", taskId)) : actors;
if (recipients.isEmpty()) {
log.info("{}【WF跳过】任务ID={},操作={},原因=接收人列表为空", LOG_PREFIX, taskId, operation);
return;
}
if (recipients.stream().anyMatch(actor -> StrUtil.isBlank(actor.getActorAccount()))) {
log.warn("{}【WF账号缺失】任务ID={},操作={},部分参与人没有登录账号,将过滤这些人员",
LOG_PREFIX, taskId, operation);
}
List<String> accounts = recipients.stream().map(ProcessTaskActor::getActorAccount)
.filter(StrUtil::isNotBlank).distinct().toList();
if (accounts.isEmpty()) {
log.info("{}【WF跳过】任务ID={},操作={},原因=接收人均缺少有效登录账号", LOG_PREFIX, taskId, operation);
return;
}
PortalTodoSendRequest request = new PortalTodoSendRequest();
request.setEntityName("wf");
request.setEntityId(String.valueOf(taskId));
request.setEntityNumber(StrUtil.blankToDefault(instance.getBusinessNo(), String.valueOf(instance.getId())));
request.setTargets(accounts);
if ("send".equals(operation)) {
String name = StrUtil.isBlank(instance.getVariable()) ? null : JSONUtil.parseObj(instance.getVariable())
.getStr(FlowConst.PROCESS_INSTANCE_NAME);
name = StrUtil.blankToDefault(name, request.getEntityNumber());
String node = StrUtil.blankToDefault(task.getDisplayName(), task.getTaskName());
request.setSubject(name + "-" + node);
request.setContent("您有一条待办任务,实例:" + name + ",处理环节:" + node);
request.setLink(buildLink(task, instance));
// H5使用任务自身的手机表单,不使用PC表单替代;缺失配置时明确记录回退原因。
if (StrUtil.isBlank(task.getH5FormKey())) {
log.warn("{}【H5入口回退】任务ID={},原因=未配置h5FormKey,将跳转手机首页", LOG_PREFIX, taskId);
}
request.setMobileLink(buildFormLink(task, instance, task.getH5FormKey(), "/platform/h5"));
if (StrUtil.isNotBlank(instance.getOperator())) {
View_user creator = dao.fetch(View_user.class, Cnd.where("id", "=", instance.getOperator()));
if (creator != null) {
request.setCreator(creator.getLoginname());
request.setCreatorName(creator.getUsername());
}
}
portalTodoService.sendTodo(request);
} else if ("done".equals(operation)) {
portalTodoService.doneTodo(request);
} else {
portalTodoService.removeTodo(request);
}
} catch (Exception e) {
log.warn("{}【WF同步失败】任务ID={},操作={},异常类型={}", LOG_PREFIX, taskId, operation, e.getClass().getSimpleName());
}
}
/**
* 复用 wf 表单入口参数,保留原链接查询和片段,避免工号或业务编号中的特殊字符破坏地址。
*/
private String buildLink(ProcessTask task, ProcessInstance instance) {
return buildFormLink(task, instance, task.getFormKey(), "/flow/todoCenter");
}
/**
* @param task 提供taskId、taskKey的WF任务
* @param instance 提供bizIdbusinessNo)的流程实例
* @param formKey 对应端的业务表单地址,支持相对路径及完整HTTP(S)地址
* @param fallback 表单未配置时的入口路径;回退入口不附加业务参数
* @return 补齐项目域名并编码任务参数的地址,保留已有查询参数和锚点
*/
private String buildFormLink(ProcessTask task, ProcessInstance instance, String formKey, String fallback) {
String form = StrUtil.blankToDefault(formKey, fallback);
String link = URI.create(StrUtil.appendIfMissing(Globals.AppDomain, "/")).resolve(form).toString();
if (StrUtil.isBlank(formKey)) return link;
int fragmentAt = link.indexOf('#');
String fragment = fragmentAt < 0 ? "" : link.substring(fragmentAt);
if (fragmentAt >= 0) link = link.substring(0, fragmentAt);
return link + (link.contains("?") ? "&" : "?") + "taskId=" + task.getId()
+ "&bizId=" + encode(StrUtil.blankToDefault(instance.getBusinessNo(), ""))
+ "&taskKey=" + encode(task.getTaskName()) + fragment;
}
private String encode(String value) {
return URLEncoder.encode(StrUtil.nullToEmpty(value), StandardCharsets.UTF_8);
}
}
@@ -23,10 +23,12 @@ import com.budwk.app.flow.entity.ProcessCcInstance;
import com.budwk.app.flow.entity.ProcessDefine; import com.budwk.app.flow.entity.ProcessDefine;
import com.budwk.app.flow.entity.ProcessInstance; import com.budwk.app.flow.entity.ProcessInstance;
import com.budwk.app.flow.entity.ProcessTask; import com.budwk.app.flow.entity.ProcessTask;
import com.budwk.app.flow.entity.ProcessTaskActor;
import com.budwk.app.flow.enums.ProcessInstanceStateEnum; import com.budwk.app.flow.enums.ProcessInstanceStateEnum;
import com.budwk.app.flow.enums.ProcessSubmitTypeEnum; import com.budwk.app.flow.enums.ProcessSubmitTypeEnum;
import com.budwk.app.flow.enums.ProcessTaskStateEnum; import com.budwk.app.flow.enums.ProcessTaskStateEnum;
import com.budwk.app.flow.service.ProcessDefineService; import com.budwk.app.flow.service.ProcessDefineService;
import com.budwk.app.flow.service.FlowPortalTodoService;
import com.budwk.app.flow.service.ProcessInstanceService; import com.budwk.app.flow.service.ProcessInstanceService;
import com.budwk.app.flow.service.ProcessTaskService; import com.budwk.app.flow.service.ProcessTaskService;
import com.budwk.app.flow.vo.HighLightVO; import com.budwk.app.flow.vo.HighLightVO;
@@ -56,6 +58,9 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
@Inject @Inject
private ProcessDefineService processDefineService; private ProcessDefineService processDefineService;
@Inject
private FlowPortalTodoService flowPortalTodoService;
@Override @Override
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
public boolean save(ProcessInstance param) { public boolean save(ProcessInstance param) {
@@ -197,10 +202,16 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
processInstance.setState(ProcessInstanceStateEnum.INTERRUPT.getCode()); processInstance.setState(ProcessInstanceStateEnum.INTERRUPT.getCode());
dao().update(ProcessInstance.class, Chain.from(processInstance), Cnd.where(ProcessInstance::getId, "=", processInstanceId) dao().update(ProcessInstance.class, Chain.from(processInstance), Cnd.where(ProcessInstance::getId, "=", processInstanceId)
.and(ProcessInstance::getState, "=", ProcessInstanceStateEnum.DOING.getCode())); .and(ProcessInstance::getState, "=", ProcessInstanceStateEnum.DOING.getCode()));
// 终止后任务不可办理,同步清理门户待办。
flowPortalTodoService.removeInstance(processInstanceId);
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void resume(Long processInstanceId, String operator) { public void resume(Long processInstanceId, String operator) {
List<ProcessTask> restoredTasks = dao().query(ProcessTask.class,
Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId)
.and(ProcessTask::getTaskState, "=", ProcessTaskStateEnum.INTERRUPT.getCode()));
Date now = new Date(); Date now = new Date();
ProcessInstance processInstance = fetch(processInstanceId); ProcessInstance processInstance = fetch(processInstanceId);
// 1. 更新流程实例状态为进行中 // 1. 更新流程实例状态为进行中
@@ -211,9 +222,12 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
processTask.setTaskState(ProcessTaskStateEnum.DOING.getCode()); processTask.setTaskState(ProcessTaskStateEnum.DOING.getCode());
dao().update(ProcessTask.class, Chain.from(processTask), Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId) dao().update(ProcessTask.class, Chain.from(processTask), Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId)
.and(ProcessTask::getTaskState, "=", ProcessTaskStateEnum.INTERRUPT.getCode())); .and(ProcessTask::getTaskState, "=", ProcessTaskStateEnum.INTERRUPT.getCode()));
// 仅恢复本次由终止状态重新进入办理的任务,避免重复发送已有待办。
restoredTasks.forEach(task -> flowPortalTodoService.sendTask(task.getId(), null));
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void pending(Long processInstanceId, String operator) { public void pending(Long processInstanceId, String operator) {
Date now = new Date(); Date now = new Date();
// 1. 将该流程实例产生的任务状态修改为挂起 // 1. 将该流程实例产生的任务状态修改为挂起
@@ -226,10 +240,17 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
processInstance.setState(ProcessInstanceStateEnum.PENDING.getCode()); processInstance.setState(ProcessInstanceStateEnum.PENDING.getCode());
dao().update(ProcessInstance.class, Chain.from(processInstance), Cnd.where(ProcessInstance::getId, "=", processInstanceId) dao().update(ProcessInstance.class, Chain.from(processInstance), Cnd.where(ProcessInstance::getId, "=", processInstanceId)
.and(ProcessInstance::getState, "=", ProcessInstanceStateEnum.DOING.getCode())); .and(ProcessInstance::getState, "=", ProcessInstanceStateEnum.DOING.getCode()));
// 挂起期间不应继续展示可办理的门户待办。
flowPortalTodoService.removeInstance(processInstanceId);
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void activate(Long processInstanceId, String operator) { public void activate(Long processInstanceId, String operator) {
// 沿用当前 activate 的状态选择规则,只同步实际被恢复的任务。
List<ProcessTask> restoredTasks = dao().query(ProcessTask.class,
Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId)
.and(ProcessTask::getTaskState, "=", ProcessTaskStateEnum.INTERRUPT.getCode()));
ProcessInstance processInstance = fetch(processInstanceId); ProcessInstance processInstance = fetch(processInstanceId);
// 1. 更新流程实例状态为进行中 // 1. 更新流程实例状态为进行中
processInstance.setState(ProcessInstanceStateEnum.DOING.getCode()); processInstance.setState(ProcessInstanceStateEnum.DOING.getCode());
@@ -239,6 +260,7 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
processTask.setTaskState(ProcessTaskStateEnum.DOING.getCode()); processTask.setTaskState(ProcessTaskStateEnum.DOING.getCode());
dao().update(ProcessTask.class, Chain.from(processTask), Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId) dao().update(ProcessTask.class, Chain.from(processTask), Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId)
.and(ProcessTask::getTaskState, "=", ProcessTaskStateEnum.INTERRUPT.getCode())); .and(ProcessTask::getTaskState, "=", ProcessTaskStateEnum.INTERRUPT.getCode()));
restoredTasks.forEach(task -> flowPortalTodoService.sendTask(task.getId(), null));
} }
@Override @Override
@@ -322,6 +344,7 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void withdraw(Long processInstanceId, String operator) { public void withdraw(Long processInstanceId, String operator) {
// Date now = new Date(); // Date now = new Date();
// 1. 将该流程实例状态修改为撤回 // 1. 将该流程实例状态修改为撤回
@@ -341,6 +364,8 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
processTask.setUpdatedBy(operator); processTask.setUpdatedBy(operator);
} }
dao().update(taskList, "taskState|updateAt|updateBy"); dao().update(taskList, "taskState|updateAt|updateBy");
// 实例撤回没有任务撤回事件,在此同步删除已不可办理的门户待办。
flowPortalTodoService.removeInstance(processInstanceId);
} }
// int update = dao().update(ProcessInstance.class, Chain.from(processInstance), Cnd.where(ProcessInstance::getId, "=", processInstanceId) // int update = dao().update(ProcessInstance.class, Chain.from(processInstance), Cnd.where(ProcessInstance::getId, "=", processInstanceId)
@@ -554,13 +579,22 @@ public class ProcessInstanceServiceImpl extends BaseServiceImpl<ProcessInstance>
@Override @Override
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
public void deleteProcessInstanceById(Long processInstanceId) { public void deleteProcessInstanceById(Long processInstanceId) {
// 在实例、任务和参与人删除前清理门户,否则无法重建原待办定位参数。
flowPortalTodoService.removeInstance(processInstanceId);
List<ProcessTask> taskList = dao().query(ProcessTask.class, Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId)); List<ProcessTask> taskList = dao().query(ProcessTask.class, Cnd.where(ProcessTask::getProcessInstanceId, "=", processInstanceId));
List<Long> taskIds = taskList.stream().map(ProcessTask::getId).toList(); List<Long> taskIds = taskList.stream().map(ProcessTask::getId).toList();
// 任务参与人依赖流程任务,删除流程时先清理参与人,避免遗留无法回查所属流程的孤立记录。
if (CollectionUtil.isNotEmpty(taskIds)) {
dao().clear(ProcessTaskActor.class, Cnd.where(ProcessTaskActor::getProcessTaskId, "in", taskIds));
dao().clear(ProcessTask.class, Cnd.where(ProcessTask::getId, "in", taskIds)); dao().clear(ProcessTask.class, Cnd.where(ProcessTask::getId, "in", taskIds));
}
// 抄送记录直接依赖流程实例,需要与实例一并清理。
dao().clear(ProcessCcInstance.class, Cnd.where(ProcessCcInstance::getProcessInstanceId, "=", processInstanceId));
dao().clear(ProcessInstance.class, Cnd.where(ProcessInstance::getId, "=", processInstanceId)); dao().clear(ProcessInstance.class, Cnd.where(ProcessInstance::getId, "=", processInstanceId));
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void deleteProcessInstanceByBusinessKey(String businessKey) { public void deleteProcessInstanceByBusinessKey(String businessKey) {
ProcessInstance processInstance = fetch(Cnd.where(ProcessInstance::getBusinessNo, "=", businessKey)); ProcessInstance processInstance = fetch(Cnd.where(ProcessInstance::getBusinessNo, "=", businessKey));
if (processInstance != null) { if (processInstance != null) {
@@ -27,6 +27,7 @@ import com.budwk.app.flow.entity.ProcessTask;
import com.budwk.app.flow.entity.ProcessTaskActor; import com.budwk.app.flow.entity.ProcessTaskActor;
import com.budwk.app.flow.enums.*; import com.budwk.app.flow.enums.*;
import com.budwk.app.flow.service.ProcessDefineService; import com.budwk.app.flow.service.ProcessDefineService;
import com.budwk.app.flow.service.FlowPortalTodoService;
import com.budwk.app.flow.service.ProcessInstanceService; import com.budwk.app.flow.service.ProcessInstanceService;
import com.budwk.app.flow.service.ProcessTaskService; import com.budwk.app.flow.service.ProcessTaskService;
import com.budwk.app.flow.vo.ProcessTaskVO; import com.budwk.app.flow.vo.ProcessTaskVO;
@@ -56,6 +57,9 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
@Inject @Inject
private ProcessDefineService processDefineService; private ProcessDefineService processDefineService;
@Inject
private FlowPortalTodoService flowPortalTodoService;
@Override @Override
public boolean save(ProcessTask param) { public boolean save(ProcessTask param) {
param.setId(null); param.setId(null);
@@ -125,6 +129,7 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void finishProcessTask(Long processTaskId, String operator, Dict args) { public void finishProcessTask(Long processTaskId, String operator, Dict args) {
ProcessTask his = fetch(processTaskId); ProcessTask his = fetch(processTaskId);
ProcessTask processTask = new ProcessTask(); ProcessTask processTask = new ProcessTask();
@@ -149,6 +154,7 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void abandonProcessTask(Long processTaskId, String operator, Dict args) { public void abandonProcessTask(Long processTaskId, String operator, Dict args) {
ProcessTask his = fetch(processTaskId); ProcessTask his = fetch(processTaskId);
ProcessTask processTask = new ProcessTask(); ProcessTask processTask = new ProcessTask();
@@ -228,10 +234,13 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
if (CollectionUtil.isEmpty(actors)) return; if (CollectionUtil.isEmpty(actors)) return;
List<String> dbActors = getTaskActors(processTaskId); List<String> dbActors = getTaskActors(processTaskId);
List<String> newActors = actors.stream().filter(actor -> !dbActors.contains(actor)).toList(); List<String> newActors = actors.stream().filter(actor -> !dbActors.contains(actor)).toList();
if (newActors.isEmpty()) return;
Sql sql = Sqls.create("select id,username,loginname,unitName,unitId from vw_user where id in (@ids)"); Sql sql = Sqls.create("select id,username,loginname,unitName,unitId from vw_user where id in (@ids)");
sql.setParam("ids", newActors); sql.setParam("ids", newActors);
List<NutMap> list = listMap(sql); List<NutMap> list = listMap(sql);
List<ProcessTaskActor> addedActors = new ArrayList<>();
for (NutMap actor : list) { for (NutMap actor : list) {
ProcessTaskActor processTaskActor = new ProcessTaskActor(); ProcessTaskActor processTaskActor = new ProcessTaskActor();
processTaskActor.setProcessTaskId(processTaskId); processTaskActor.setProcessTaskId(processTaskId);
@@ -243,7 +252,10 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
processTaskActor.setCreatedAt(System.currentTimeMillis()); processTaskActor.setCreatedAt(System.currentTimeMillis());
System.out.println(StrUtil.format("给任务:{},添加参与者:{}", processTaskId, actor.toString())); System.out.println(StrUtil.format("给任务:{},添加参与者:{}", processTaskId, actor.toString()));
insert(processTaskActor); insert(processTaskActor);
addedActors.add(processTaskActor);
} }
// 只为实际新增的账号发送,覆盖普通任务、串并行会签、转办和加签,避免事件重复发送。
flowPortalTodoService.sendTask(processTaskId, addedActors);
} }
@Override @Override
@@ -290,7 +302,14 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void removeTaskActor(Long processTaskId, List<String> actors) { public void removeTaskActor(Long processTaskId, List<String> actors) {
if (CollectionUtil.isEmpty(actors)) return;
// 先保留被移除人员的门户账号,参与人记录清理后无法再据此定位待办。
List<ProcessTaskActor> removedActors = dao().query(ProcessTaskActor.class,
Cnd.where(ProcessTaskActor::getProcessTaskId, "=", processTaskId)
.and(ProcessTaskActor::getActorId, "in", actors));
flowPortalTodoService.removeTask(processTaskId, removedActors);
dao().clear(ProcessTaskActor.class, Cnd.where(ProcessTaskActor::getProcessTaskId, "=", processTaskId).and(ProcessTaskActor::getActorId, "in", actors)); dao().clear(ProcessTaskActor.class, Cnd.where(ProcessTaskActor::getProcessTaskId, "=", processTaskId).and(ProcessTaskActor::getActorId, "in", actors));
} }
@@ -481,6 +500,9 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
task.setVariable(JSONUtil.toJsonStr(variable)); task.setVariable(JSONUtil.toJsonStr(variable));
updateIgnoreNull(task); updateIgnoreNull(task);
// 转办后原参与人不再办理,先清理原任务门户待办;新任务在添加参与人时发送。
flowPortalTodoService.removeTask(taskId, null);
// 创建转办任务 // 创建转办任务
ProcessTask newTask = BeanUtil.copyProperties(task, ProcessTask.class, "id"); ProcessTask newTask = BeanUtil.copyProperties(task, ProcessTask.class, "id");
newTask.setTaskParentId(taskId); newTask.setTaskParentId(taskId);
@@ -33,6 +33,10 @@ import org.nutz.mvc.upload.TempFile;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
@@ -43,6 +47,9 @@ import java.nio.file.StandardOpenOption;
import java.util.Base64; import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Iterator;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@@ -54,6 +61,9 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
static String IMG_BASE64_PATTERN = "<img\\s+[^>]*src\\s*=\\s*['\"](data:image/[^'\"]+;base64,[^'\"]+)['\"][^>]*>"; static String IMG_BASE64_PATTERN = "<img\\s+[^>]*src\\s*=\\s*['\"](data:image/[^'\"]+;base64,[^'\"]+)['\"][^>]*>";
private static final int DOWNLOAD_FILE_CACHE_LIMIT = 2000; private static final int DOWNLOAD_FILE_CACHE_LIMIT = 2000;
private final Map<String, Sys_file> downloadFileCache = new ConcurrentHashMap<>(); private final Map<String, Sys_file> downloadFileCache = new ConcurrentHashMap<>();
private static final Set<String> UPLOAD_SUFFIXES = Set.of("gif", "jpg", "jpeg", "png", "doc", "docx",
"xls", "xlsx", "rar", "zip", "7z", "txt", "pdf", "pptx", "ppt", "mp4");
private static final Set<String> ACTIVE_SUFFIXES = Set.of("svg", "svgz", "html", "htm", "xhtml", "xml", "js", "mjs", "swf");
public SysFileServiceImpl(Dao dao) { public SysFileServiceImpl(Dao dao) {
super(dao); super(dao);
@@ -86,11 +96,13 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
@Override @Override
public void download(String id, HttpServletRequest request, HttpServletResponse response) throws IOException { public void download(String id, HttpServletRequest request, HttpServletResponse response) throws IOException {
if (handleCachedFileRequest(id, request, response)) { response.setHeader("X-Content-Type-Options", "nosniff");
return; response.setHeader("Cache-Control", "private, no-store");
}
Sys_file sys_file = fetchDownloadFile(id); Sys_file sys_file = fetchDownloadFile(id);
if (!ObjectUtil.isEmpty(sys_file) && handleCachedImageRequest(sys_file, request, response)) { // 历史主动内容也必须在缓存协商前拦截,避免旧 SVG 继续在业务域下执行。
if (sys_file != null && (ACTIVE_SUFFIXES.contains(StrUtil.blankToDefault(sys_file.getSuffix(), "").toLowerCase(Locale.ROOT))
|| ACTIVE_SUFFIXES.contains(FileUtil.extName(sys_file.getName()).toLowerCase(Locale.ROOT)))) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "不允许访问此类型的附件");
return; return;
} }
if (ObjectUtil.isEmpty(sys_file)) { if (ObjectUtil.isEmpty(sys_file)) {
@@ -117,16 +129,6 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
} }
} }
private boolean handleCachedFileRequest(String id, HttpServletRequest request, HttpServletResponse response) {
String etag = buildFileEtag(id);
if (etag.equals(request.getHeader("If-None-Match"))) {
response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
setImageCacheHeaders(response, etag);
return true;
}
return false;
}
private Sys_file fetchDownloadFile(String id) { private Sys_file fetchDownloadFile(String id) {
if (StrUtil.isBlank(id)) { if (StrUtil.isBlank(id)) {
return null; return null;
@@ -145,23 +147,15 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
return sysFile; return sysFile;
} }
private boolean handleCachedImageRequest(Sys_file sysFile, HttpServletRequest request, HttpServletResponse response) {
if (!isImage(sysFile)) {
return false;
}
String etag = buildFileEtag(sysFile);
if (etag.equals(request.getHeader("If-None-Match"))) {
response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
setImageCacheHeaders(response, etag);
return true;
}
return false;
}
private boolean writeImageResponse(Sys_file sysFile, byte[] bytes, HttpServletResponse response) throws IOException { private boolean writeImageResponse(Sys_file sysFile, byte[] bytes, HttpServletResponse response) throws IOException {
if (!isImage(sysFile)) { if (!isImage(sysFile)) {
return false; return false;
} }
String suffix = StrUtil.blankToDefault(sysFile.getSuffix(), FileUtil.extName(sysFile.getName())).toLowerCase(Locale.ROOT);
if (!isValidImage(bytes, suffix)) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "图片内容与文件类型不符");
return true;
}
setImageCacheHeaders(response, buildFileEtag(sysFile)); setImageCacheHeaders(response, buildFileEtag(sysFile));
response.setHeader("Content-Disposition", "inline;filename=" + java.net.URLEncoder.encode(sysFile.getName(), java.nio.charset.StandardCharsets.UTF_8)); response.setHeader("Content-Disposition", "inline;filename=" + java.net.URLEncoder.encode(sysFile.getName(), java.nio.charset.StandardCharsets.UTF_8));
response.setHeader("Content-Length", String.valueOf(bytes.length)); response.setHeader("Content-Length", String.valueOf(bytes.length));
@@ -171,9 +165,9 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
} }
private void setImageCacheHeaders(HttpServletResponse response, String etag) { private void setImageCacheHeaders(HttpServletResponse response, String etag) {
response.setHeader("Cache-Control", "public, max-age=604800, immutable"); response.setHeader("Cache-Control", "private, no-cache");
response.setHeader("ETag", etag); response.setHeader("ETag", etag);
response.setDateHeader("Expires", System.currentTimeMillis() + 604800000L); response.setDateHeader("Expires", 0);
} }
private String buildFileEtag(Sys_file sysFile) { private String buildFileEtag(Sys_file sysFile) {
@@ -181,12 +175,12 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
} }
private String buildFileEtag(String id) { private String buildFileEtag(String id) {
return "\"" + id + "\""; return "\"safe-" + id + "\"";
} }
private boolean isImage(Sys_file sysFile) { private boolean isImage(Sys_file sysFile) {
String suffix = StrUtil.blankToDefault(sysFile.getSuffix(), FileUtil.extName(sysFile.getName())).toLowerCase(); String suffix = StrUtil.blankToDefault(sysFile.getSuffix(), FileUtil.extName(sysFile.getName())).toLowerCase();
return "jpg".equals(suffix) || "jpeg".equals(suffix) || "png".equals(suffix) || "gif".equals(suffix) || "webp".equals(suffix) || "bmp".equals(suffix) || "svg".equals(suffix); return "jpg".equals(suffix) || "jpeg".equals(suffix) || "png".equals(suffix) || "gif".equals(suffix) || "webp".equals(suffix) || "bmp".equals(suffix);
} }
private String getImageContentType(Sys_file sysFile) { private String getImageContentType(Sys_file sysFile) {
@@ -197,7 +191,6 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
case "gif" -> "image/gif"; case "gif" -> "image/gif";
case "webp" -> "image/webp"; case "webp" -> "image/webp";
case "bmp" -> "image/bmp"; case "bmp" -> "image/bmp";
case "svg" -> "image/svg+xml";
default -> "application/octet-stream"; default -> "application/octet-stream";
}; };
} }
@@ -310,6 +303,7 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
* 存储文件 * 存储文件
**/ **/
private String storageFile(String engine, TempFile file, boolean returnFileId) { private String storageFile(String engine, TempFile file, boolean returnFileId) {
validateUpload(file);
// 如果引擎为空,默认使用本地 // 如果引擎为空,默认使用本地
if (ObjectUtil.isEmpty(engine)) { if (ObjectUtil.isEmpty(engine)) {
engine = SysFileEngineTypeEnum.LOCAL.getValue(); engine = SysFileEngineTypeEnum.LOCAL.getValue();
@@ -383,6 +377,53 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
} }
} }
/** 所有业务上传共用校验,客户端声明的 MIME 类型不能作为图片真实性依据。 */
private void validateUpload(TempFile file) {
if (file == null || StrUtil.isBlank(file.getSubmittedFileName())) {
throw new BaseException("请选择要上传的文件");
}
String suffix = FileUtil.extName(file.getSubmittedFileName()).toLowerCase(Locale.ROOT);
if (!UPLOAD_SUFFIXES.contains(suffix)) {
throw new BaseException("不允许上传此类型的文件");
}
if (Set.of("gif", "jpg", "jpeg", "png").contains(suffix)) {
try {
if (!isValidImage(Files.readAllBytes(file.getFile().toPath()), suffix)) {
throw new BaseException("图片内容与文件类型不符");
}
} catch (IOException e) {
throw new BaseException("图片读取失败");
}
}
}
/** 解码真实图片并限制像素数量,阻止伪装为图片的 SVG 和异常大尺寸图片。 */
private boolean isValidImage(byte[] bytes, String suffix) {
try (ImageInputStream input = ImageIO.createImageInputStream(new ByteArrayInputStream(bytes))) {
if (input == null) {
return false;
}
Iterator<ImageReader> readers = ImageIO.getImageReaders(input);
if (!readers.hasNext()) {
return false;
}
ImageReader reader = readers.next();
try {
reader.setInput(input, true, true);
String expected = "jpg".equals(suffix) ? "jpeg" : suffix;
if (!expected.equalsIgnoreCase(reader.getFormatName())) {
return false;
}
long pixels = (long) reader.getWidth(0) * reader.getHeight(0);
return pixels > 0 && pixels <= 40000000 && reader.read(0) != null;
} finally {
reader.dispose();
}
} catch (IOException | RuntimeException e) {
return false;
}
}
/** /**
* 存储文件 * 存储文件
*/ */
@@ -10,6 +10,9 @@ import cn.dev33.satoken.servlet.model.SaStorageForServlet;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.Mvcs; import org.nutz.mvc.Mvcs;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponseWrapper;
/** /**
* @author wizzer@qq.com * @author wizzer@qq.com
*/ */
@@ -22,7 +25,20 @@ public class SaTokenContextImpl implements SaTokenContext {
@Override @Override
public SaResponse getResponse() { public SaResponse getResponse() {
return new SaResponseForServlet(Mvcs.getResp()); // 当前 Sa-Token 版本没有 Cookie 安全属性配置,在写入登录 Cookie 时统一补齐。
return new SaResponseForServlet(new HttpServletResponseWrapper(Mvcs.getResp()) {
/**
* 限制登录 Cookie 的脚本访问,并在 HTTPS 请求中仅允许安全传输。
*
* @param cookie Sa-Token 写入或清除的 Cookie
*/
@Override
public void addCookie(Cookie cookie) {
cookie.setHttpOnly(true);
cookie.setSecure(cookie.getSecure() || Mvcs.getReq().isSecure());
super.addCookie(cookie);
}
});
} }
@Override @Override
@@ -15,6 +15,10 @@ import java.lang.reflect.Method;
public class SaCheckLoginInterceptor implements MethodInterceptor { public class SaCheckLoginInterceptor implements MethodInterceptor {
@Override @Override
public void filter(InterceptorChain chain) throws Throwable { public void filter(InterceptorChain chain) throws Throwable {
if (SaTokenAuthIgnoreUtil.isIgnore()) {
chain.doChain();
return;
}
Method method = chain.getCallingMethod(); Method method = chain.getCallingMethod();
SaCheckLogin at = method.getAnnotation(SaCheckLogin.class); SaCheckLogin at = method.getAnnotation(SaCheckLogin.class);
SaManager.getStpLogic(at.type()).checkByAnnotation(at); SaManager.getStpLogic(at.type()).checkByAnnotation(at);
@@ -15,6 +15,10 @@ import java.lang.reflect.Method;
public class SaCheckPermissionInterceptor implements MethodInterceptor { public class SaCheckPermissionInterceptor implements MethodInterceptor {
@Override @Override
public void filter(InterceptorChain chain) throws Throwable { public void filter(InterceptorChain chain) throws Throwable {
if (SaTokenAuthIgnoreUtil.isIgnore()) {
chain.doChain();
return;
}
Method method = chain.getCallingMethod(); Method method = chain.getCallingMethod();
SaCheckPermission at = method.getAnnotation(SaCheckPermission.class); SaCheckPermission at = method.getAnnotation(SaCheckPermission.class);
SaManager.getStpLogic(at.type()).checkByAnnotation(at); SaManager.getStpLogic(at.type()).checkByAnnotation(at);
@@ -15,6 +15,10 @@ import java.lang.reflect.Method;
public class SaCheckRoleInterceptor implements MethodInterceptor { public class SaCheckRoleInterceptor implements MethodInterceptor {
@Override @Override
public void filter(InterceptorChain chain) throws Throwable { public void filter(InterceptorChain chain) throws Throwable {
if (SaTokenAuthIgnoreUtil.isIgnore()) {
chain.doChain();
return;
}
Method method = chain.getCallingMethod(); Method method = chain.getCallingMethod();
SaCheckRole at = method.getAnnotation(SaCheckRole.class); SaCheckRole at = method.getAnnotation(SaCheckRole.class);
SaManager.getStpLogic(at.type()).checkByAnnotation(at); SaManager.getStpLogic(at.type()).checkByAnnotation(at);
@@ -0,0 +1,60 @@
package com.budwk.app.web.commons.auth.satoken.aop;
import com.budwk.app.web.commons.base.Globals;
import org.nutz.lang.Lang;
import org.nutz.lang.Strings;
import org.nutz.mvc.Mvcs;
import javax.servlet.http.HttpServletRequest;
import java.util.Arrays;
import java.util.regex.Pattern;
/**
* Sa-Token 注解认证白名单工具。
* <p>
* 用于集中维护不需要登录、权限、角色校验即可访问的 URL,匹配规则与旧 Shiro 体系中的
* authIgnoreUrlArr 保持一致:数组中的每一项都按正则表达式与当前请求 URI 完整匹配。
*/
public class SaTokenAuthIgnoreUtil {
/**
* 不需要认证授权的 URL。
* <p>
* 参数说明:数组中的字符串为请求 URI 正则表达式,匹配的是去除项目 contextPath 后的路径。
* 返回值说明:通过 {@link #isIgnore()} 返回 booleantrue 表示当前请求跳过 Sa-Token 注解校验。
*/
private static final String[] AUTH_IGNORE_URL_ARR = Lang.array("/",
"/platform/activity/basic/scope/getScopeUser",
"/platform/fitnessWalk/stepManage/getActivityDateStep",
"/platform/fitnessWalk/stepManage/winningRecord",
"/platform/fitnessWalk/stepManage/updateStepMonth",
"/platform/fitnessWalk/stepRanking/getUserStepRanking",
"/platform/fitnessWalk/stepManage/getActivityQualifyProgressBar",
"/platform/fitnessWalk/stepWining/getUserStepWining",
"/platform/fitnessWalk/common/getIssueById",
"/platform/fitnessWalk/common/saveAnswer",
"/platform/fitnessWalk/stepWining/prizeOption",
"/platform/fitnessWalk/stepWining/prizeUsers",
"/platform/fitnessWalk/punchLottery/judgeWinningToPunch",
"/platform/fitnessWalk/punchLottery/getLotteryRecord",
"/platform/fitnessWalk/punchLottery/doExchange",
"/platform/fitnessWalk/punchLottery/getPunchWining",
"/platform/fitnessWalk/punchLottery/doReadLottery",
"/platform/fitnessWalk/activityRelation/isCurrentUserRelationActivityQualified",
"/platform/fitnessWalk/welfare/addWelfareList",
"/platform/fitnessWalk/weapp/.*");
/**
* 判断当前请求是否命中匿名访问白名单。
*
* @return boolean true 表示当前 URL 不需要执行登录、权限、角色注解校验;false 表示继续走原有 Sa-Token 校验逻辑
*/
public static boolean isIgnore() {
HttpServletRequest request = Mvcs.getReq();
if (request == null) {
return false;
}
String requestURI = Strings.sNull(request.getRequestURI()).replaceFirst("^" + Pattern.quote(Strings.sNull(Globals.AppBase)), "");
return Arrays.stream(AUTH_IGNORE_URL_ARR).anyMatch(v -> Pattern.compile(v).matcher(requestURI).matches());
}
}
@@ -1,7 +1,6 @@
package com.budwk.app.web.commons.ext.handler; package com.budwk.app.web.commons.ext.handler;
import org.eclipse.jetty.server.Request; import org.nutz.boot.starter.WebServletFace;
import org.eclipse.jetty.servlet.ErrorPageErrorHandler;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json; import org.nutz.json.Json;
import org.nutz.lang.util.NutMap; import org.nutz.lang.util.NutMap;
@@ -9,28 +8,63 @@ import org.nutz.log.Log;
import org.nutz.log.Logs; import org.nutz.log.Logs;
import javax.servlet.RequestDispatcher; import javax.servlet.RequestDispatcher;
import javax.servlet.DispatcherType;
import javax.servlet.Servlet;
import javax.servlet.ServletException; import javax.servlet.ServletException;
import javax.servlet.ServletRequest; import javax.servlet.ServletRequest;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpServlet;
import java.io.IOException; import java.io.IOException;
/** /**
* 错误页拦截器,登陆后台显示友好提示 * 错误页拦截器,登陆后台显示友好提示
*/ */
@IocBean @IocBean
public class WkErrorPageHandler extends ErrorPageErrorHandler { public class WkErrorPageHandler extends HttpServlet implements WebServletFace {
private static final Log log = Logs.get(); private static final Log log = Logs.get();
/**
* 返回容器错误处理 Servlet 的注册名称。
* @return 独立于业务路由的 Servlet 名称
*/
@Override @Override
public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { public String getName() {
return "wkErrorPage";
}
/**
* 返回容器错误页的内部派发地址。
* @return 错误页映射路径
*/
@Override
public String getPathSpec() {
return "/__container_error";
}
/**
* 将当前错误处理实例交给 NutzBoot 注册。
* @return 当前 Servlet 实例
*/
@Override
public Servlet getServlet() {
return this;
}
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
if (request.getDispatcherType() != DispatcherType.ERROR) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
if (response.getStatus() == 403 || response.getStatus() == 404 || response.getStatus() == 500) { if (response.getStatus() == 403 || response.getStatus() == 404 || response.getStatus() == 500) {
try { try {
if (isAjax(request)) { if (isAjax(request)) {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(Json.toJson(new NutMap("code", "-1").setv("msg", response.getStatus() + " error"))); response.getWriter().write(Json.toJson(new NutMap("code", "-1").setv("msg", response.getStatus() + " error")));
return; return;
} else { } else {
request.setAttribute("original_request_uri", request.getRequestURI()); request.setAttribute("original_request_uri", request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI));
RequestDispatcher rd = request.getRequestDispatcher("/platform/home/" + response.getStatus()); RequestDispatcher rd = request.getRequestDispatcher("/platform/home/" + response.getStatus());
rd.forward(request, response); rd.forward(request, response);
return; return;
@@ -39,7 +73,7 @@ public class WkErrorPageHandler extends ErrorPageErrorHandler {
log.error(e); log.error(e);
} }
} }
super.handle(target, baseRequest, request, response); // 不回落到容器默认错误页,避免泄露异常堆栈和容器信息。
} }
private boolean isAjax(ServletRequest req) { private boolean isAjax(ServletRequest req) {
@@ -0,0 +1,88 @@
package com.budwk.app.web.commons.ext.handler;
import com.budwk.app.web.commons.ext.websocket.WkWebSocket;
import org.apache.catalina.core.StandardContext;
import org.apache.catalina.Executor;
import org.apache.catalina.core.StandardThreadExecutor;
import org.apache.catalina.startup.Tomcat;
import org.apache.catalina.valves.ErrorReportValve;
import org.apache.tomcat.websocket.server.WsSci;
import org.apache.tomcat.util.descriptor.web.FilterMap;
import org.nutz.boot.NbApp;
import org.nutz.boot.starter.tomcat.TomcatStarter;
import org.nutz.boot.tools.NbAppEventListener;
import org.nutz.ioc.impl.PropertiesProxy;
import java.util.Set;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
/**
* 在容器启动前补齐 NutzBoot Tomcat 的 WebSocket 注册和线程池绑定。
*/
public class WkTomcatConfiguration implements NbAppEventListener {
/**
* 配置官方嵌入式 Tomcat,保留项目的监听地址与消息推送能力。
*
* @param app 当前 NutzBoot 应用
* @param eventType 只在服务器启动前配置,避免重复注册端点
*/
@Override
public void whenStartServers(NbApp app, EventType eventType) {
if (eventType != EventType.before) {
return;
}
TomcatStarter starter = app.getAppContext().getIoc().get(TomcatStarter.class);
Tomcat tomcat = starter.getServer();
PropertiesProxy conf = app.getAppContext().getConf();
tomcat.getConnector().setProperty("address", starter.getHost());
// Starter 创建了共享线程池,但尚未把它交给 Connector 使用。
for (Executor executor : tomcat.getService().findExecutors()) {
if (executor instanceof StandardThreadExecutor) {
StandardThreadExecutor threads = (StandardThreadExecutor) executor;
threads.setMinSpareThreads(conf.getInt("tomcat.executor.minSpareThreads", 10));
threads.setMaxIdleTime(conf.getInt("tomcat.executor.maxIdleTime", 60000));
tomcat.getConnector().getProtocolHandler().setExecutor(threads);
break;
}
}
// NutzBoot 使用编程式 Context,必须显式运行官方 WebSocket 初始化器。
StandardContext context = (StandardContext) tomcat.getHost().findChild(starter.getContextPath());
context.setUseHttpOnly(conf.getBoolean("tomcat.session.cookie.httponly", true));
context.addServletContainerInitializer(new WsSci(), Set.of(WkWebSocket.class));
// Tomcat 的错误页再次 forward 时仍保持 ERROR 派发;让 MVC 渲染原业务错误页。
// Nutz 的过滤器在前一个监听器中注册,须等其完成后才能添加映射。
context.addApplicationLifecycleListener(new ServletContextListener() {
/**
* 在 Nutz 注册完成后补充业务错误页的 ERROR 派发映射。
* @param event 当前 Servlet 上下文初始化事件
*/
@Override
public void contextInitialized(ServletContextEvent event) {
FilterMap errorPages = new FilterMap();
errorPages.setFilterName("nutz");
errorPages.setDispatcher("ERROR");
// ERROR 再转发时 URL 匹配路径不变,按目标 Servlet 匹配才能进入 MVC。
errorPages.addServletName("default");
context.addFilterMap(errorPages);
}
});
// Host 的兜底错误页也不能重新暴露此前已关闭的版本和堆栈。
ErrorReportValve errorReport = null;
for (org.apache.catalina.Valve valve : tomcat.getHost().getPipeline().getValves()) {
if (valve instanceof ErrorReportValve) {
errorReport = (ErrorReportValve) valve;
break;
}
}
if (errorReport == null) {
errorReport = new ErrorReportValve();
tomcat.getHost().getPipeline().addValve(errorReport);
}
errorReport.setShowReport(false);
errorReport.setShowServerInfo(false);
}
}
@@ -162,27 +162,10 @@ public class ActivityWorksCollectionManageController {
@SaCheckPermission(value = {"activity.workscollection.manage", "activity.workscollection.new"}, mode = SaMode.OR) @SaCheckPermission(value = {"activity.workscollection.manage", "activity.workscollection.new"}, mode = SaMode.OR)
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
public Result update(@Param("data") @Valid Activity_works_collection worksCollection) { public Result update(@Param("data") @Valid Activity_works_collection worksCollection) {
String validateMessage = activityWorksCollectionService.validateReferencedTypesBeforeUpdate(worksCollection); String validateMessage = activityWorksCollectionService.updatePreservingTypeIds(worksCollection);
if (StrUtil.isNotBlank(validateMessage)) { if (StrUtil.isNotBlank(validateMessage)) {
return Result.error(validateMessage); return Result.error(validateMessage);
} }
dao.update(worksCollection);
dao.updateLinks(worksCollection, "subjectTypes");
dao.insertLinks(worksCollection, "subjectTypes");
//清除页面已经删除的
dao.clear(Activity_works_subjectType.class,
Cnd.where(Activity_works_subjectType::getActivityId, "=", worksCollection.getId())
.andEX("id", "not in", worksCollection.getSubjectTypes().stream().map(Activity_works_subjectType::getId).toList()));
worksCollection.getSubjectTypes().forEach(item -> {
dao.updateLinks(item, "worksTypes");
dao.insertLinks(item, "worksTypes");
//清除页面已经删除的
List<String> workIds = item.getWorksTypes().stream().map(Activity_works_worksType::getId).toList();
dao.clear(Activity_works_worksType.class,
Cnd.where(Activity_works_worksType::getId, "not in ", workIds)
.and(Activity_works_worksType::getSubjectId,"=",item.getId()));
});
syncHomeActivity(worksCollection); syncHomeActivity(worksCollection);
return Result.success(); return Result.success();
} }
@@ -35,6 +35,14 @@ public interface ActivityWorksCollectionService extends BaseService<Activity_wor
*/ */
void cancelTemplate(String id); void cancelTemplate(String id);
/**
* 更新作品征集活动及其主题、作品类型,并保留所有原有类型ID。
*
* @param worksCollection 前端提交的活动及其主题、作品类型配置
* @return 更新成功返回 {@code null};数据层级异常或删除了投稿引用类型时返回提示信息
*/
String updatePreservingTypeIds(Activity_works_collection worksCollection);
/** /**
* 校验编辑活动时删除的主题类型、作品类型是否已被投稿引用。 * 校验编辑活动时删除的主题类型、作品类型是否已被投稿引用。
* *
@@ -21,7 +21,9 @@ import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -116,6 +118,136 @@ public class ActivityWorksCollectionServiceImpl extends BaseServiceImpl<Activity
Cnd.where("id", "=", id)); Cnd.where("id", "=", id));
} }
/**
* 活动编辑采用显式新增、更新、删除,避免通用关联保存重新生成已有类型ID。
* 原有主题和作品类型必须携带数据库ID;只有页面新增的类型允许生成新ID。
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public String updatePreservingTypeIds(Activity_works_collection worksCollection) {
if (worksCollection == null || StrUtil.isBlank(worksCollection.getId())) {
return "活动数据不存在,请刷新页面后重试";
}
Activity_works_collection persistedActivity = dao.fetch(Activity_works_collection.class, worksCollection.getId());
if (persistedActivity == null) {
return "活动不存在或已被删除";
}
List<Activity_works_subjectType> submittedSubjectTypes = CollUtil.defaultIfEmpty(
worksCollection.getSubjectTypes(), Collections.emptyList());
List<Activity_works_subjectType> persistedSubjectTypes = dao.query(Activity_works_subjectType.class,
Cnd.where(Activity_works_subjectType::getActivityId, "=", worksCollection.getId()));
Map<String, Activity_works_subjectType> persistedSubjectMap = persistedSubjectTypes.stream()
.collect(Collectors.toMap(Activity_works_subjectType::getId, item -> item));
Set<String> submittedSubjectIds = new HashSet<>();
for (Activity_works_subjectType subjectType : submittedSubjectTypes) {
if (subjectType == null) {
return "主题类型数据不能为空";
}
if (StrUtil.isNotBlank(subjectType.getId())) {
if (!persistedSubjectMap.containsKey(subjectType.getId())) {
return "主题类型数据已发生变化,请刷新页面后重新编辑";
}
if (!submittedSubjectIds.add(subjectType.getId())) {
return "主题类型数据重复,请刷新页面后重新编辑";
}
}
List<Activity_works_worksType> submittedWorksTypes = CollUtil.defaultIfEmpty(
subjectType.getWorksTypes(), Collections.emptyList());
if (StrUtil.isBlank(subjectType.getId())) {
boolean containsPersistedWorksType = submittedWorksTypes.stream()
.anyMatch(worksType -> worksType != null && StrUtil.isNotBlank(worksType.getId()));
if (containsPersistedWorksType) {
return "新增主题不能关联原有作品类型,请刷新页面后重新编辑";
}
continue;
}
List<Activity_works_worksType> persistedWorksTypes = dao.query(Activity_works_worksType.class,
Cnd.where(Activity_works_worksType::getSubjectId, "=", subjectType.getId()));
Map<String, Activity_works_worksType> persistedWorksTypeMap = persistedWorksTypes.stream()
.collect(Collectors.toMap(Activity_works_worksType::getId, item -> item));
Set<String> submittedWorksIds = new HashSet<>();
for (Activity_works_worksType worksType : submittedWorksTypes) {
if (worksType == null) {
return "作品类型数据不能为空";
}
if (StrUtil.isNotBlank(worksType.getId()) && !persistedWorksTypeMap.containsKey(worksType.getId())) {
return "作品类型数据已发生变化,请刷新页面后重新编辑";
}
if (StrUtil.isNotBlank(worksType.getId()) && !submittedWorksIds.add(worksType.getId())) {
return "作品类型数据重复,请刷新页面后重新编辑";
}
}
}
String validateMessage = validateReferencedTypesBeforeUpdate(worksCollection);
if (StrUtil.isNotBlank(validateMessage)) {
return validateMessage;
}
dao.update(worksCollection);
for (Activity_works_subjectType subjectType : submittedSubjectTypes) {
subjectType.setActivityId(worksCollection.getId());
if (StrUtil.isBlank(subjectType.getId())) {
dao.insert(subjectType);
} else {
dao.update(subjectType);
}
submittedSubjectIds.add(subjectType.getId());
List<Activity_works_worksType> submittedWorksTypes = CollUtil.defaultIfEmpty(
subjectType.getWorksTypes(), Collections.emptyList());
Set<String> retainedWorksIds = new HashSet<>();
for (Activity_works_worksType worksType : submittedWorksTypes) {
worksType.setSubjectId(subjectType.getId());
if (StrUtil.isBlank(worksType.getId())) {
dao.insert(worksType);
} else {
dao.update(worksType);
}
retainedWorksIds.add(worksType.getId());
}
clearRemovedWorksTypes(subjectType.getId(), retainedWorksIds);
}
clearRemovedSubjectTypes(worksCollection.getId(), submittedSubjectIds);
return null;
}
/**
* 删除页面已移除且未被投稿引用的作品类型;空集合表示删除当前主题下全部原有类型。
*/
private void clearRemovedWorksTypes(String subjectId, Set<String> retainedWorksIds) {
Cnd cnd = Cnd.where(Activity_works_worksType::getSubjectId, "=", subjectId);
if (CollUtil.isNotEmpty(retainedWorksIds)) {
cnd.and(Activity_works_worksType::getId, "not in", retainedWorksIds);
}
dao.clear(Activity_works_worksType.class, cnd);
}
/**
* 删除页面已移除且未被投稿引用的主题类型;空集合表示删除当前活动全部原有主题。
*/
private void clearRemovedSubjectTypes(String activityId, Set<String> retainedSubjectIds) {
Cnd cnd = Cnd.where(Activity_works_subjectType::getActivityId, "=", activityId);
if (CollUtil.isNotEmpty(retainedSubjectIds)) {
cnd.and(Activity_works_subjectType::getId, "not in", retainedSubjectIds);
}
List<String> removedSubjectIds = dao.query(Activity_works_subjectType.class, cnd).stream()
.map(Activity_works_subjectType::getId)
.toList();
if (CollUtil.isEmpty(removedSubjectIds)) {
return;
}
// 先删除未被投稿引用主题下的作品类型,避免产生作品类型孤儿数据。
dao.clear(Activity_works_worksType.class,
Cnd.where(Activity_works_worksType::getSubjectId, "in", removedSubjectIds));
dao.clear(Activity_works_subjectType.class,
Cnd.where(Activity_works_subjectType::getId, "in", removedSubjectIds));
}
@Override @Override
public String validateReferencedTypesBeforeUpdate(Activity_works_collection worksCollection) { public String validateReferencedTypesBeforeUpdate(Activity_works_collection worksCollection) {
if (worksCollection == null || StrUtil.isBlank(worksCollection.getId())) { if (worksCollection == null || StrUtil.isBlank(worksCollection.getId())) {
@@ -218,6 +218,10 @@ public class ClubUserJoinApplyController {
} }
ProcessInstance processInstance = dao.fetch(ProcessInstance.class, Cnd.where(ProcessInstance::getBusinessNo, "=", userApply.getId())); ProcessInstance processInstance = dao.fetch(ProcessInstance.class, Cnd.where(ProcessInstance::getBusinessNo, "=", userApply.getId()));
// 申请记录存在但没有流程实例时,说明用户仅保存了申请草稿,需提示其先到“我的申请”继续提交。
if (processInstance == null) {
return Result.error(99, "您有该社团的申请记录尚未提交,请到我的申请里查看!");
}
if (!List.of(ProcessInstanceStateEnum.REJECT.getCode(), ProcessInstanceStateEnum.FINISHED.getCode()).contains(processInstance.getState())) { if (!List.of(ProcessInstanceStateEnum.REJECT.getCode(), ProcessInstanceStateEnum.FINISHED.getCode()).contains(processInstance.getState())) {
return Result.error(99, "您有该社团的申请记录尚未完成,请到我的申请里查看!"); return Result.error(99, "您有该社团的申请记录尚未完成,请到我的申请里查看!");
} }
@@ -11,6 +11,7 @@ import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.PageUtil; import com.budwk.app.base.utils.PageUtil;
import com.budwk.app.flow.constant.FlowConst; import com.budwk.app.flow.constant.FlowConst;
import com.budwk.app.flow.engine.FlowEngine; import com.budwk.app.flow.engine.FlowEngine;
import com.budwk.app.flow.entity.ProcessDefine;
import com.budwk.app.flow.entity.ProcessInstance; import com.budwk.app.flow.entity.ProcessInstance;
import com.budwk.app.flow.entity.ProcessTask; import com.budwk.app.flow.entity.ProcessTask;
import com.budwk.app.flow.enums.ProcessInstanceStateEnum; import com.budwk.app.flow.enums.ProcessInstanceStateEnum;
@@ -95,6 +96,7 @@ public class ClubRefreshReportController {
sys_club_refresh info sys_club_refresh info
LEFT JOIN sys_club club ON club.id = info.clubId LEFT JOIN sys_club club ON club.id = info.clubId
LEFT JOIN `wf_process_instance` ins ON ins.businessNo = info.id LEFT JOIN `wf_process_instance` ins ON ins.businessNo = info.id
AND ins.processDefineId IN (SELECT id FROM wf_process_define WHERE name = 'XHHJBG')
LEFT JOIN wf_process_task t ON t.processInstanceId = ins.id AND t.taskState = 10 LEFT JOIN wf_process_task t ON t.processInstanceId = ins.id AND t.taskState = 10
LEFT JOIN wf_process_task_actor ta ON ta.processTaskId = t.id LEFT JOIN wf_process_task_actor ta ON ta.processTaskId = t.id
$condition $condition
@@ -178,8 +180,21 @@ public class ClubRefreshReportController {
@SaCheckPermission("club.infoManage.refreshReport") @SaCheckPermission("club.infoManage.refreshReport")
@SLog(tag = "社团管理系统-信息管理", msg = "删除换届报告") @SLog(tag = "社团管理系统-信息管理", msg = "删除换届报告")
public Result delete(@Param("id") String id) { public Result delete(@Param("id") String id) {
// 历史换届记录可能与社团注册共用业务编号,必须按流程定义精确定位换届流程,避免误删注册流程。
List<Long> refreshDefineIds = dao.query(ProcessDefine.class,
Cnd.where(ProcessDefine::getName, "=", "XHHJBG"))
.stream()
.map(ProcessDefine::getId)
.toList();
List<ProcessInstance> refreshInstances = refreshDefineIds.isEmpty()
? List.of()
: dao.query(ProcessInstance.class,
Cnd.where(ProcessInstance::getBusinessNo, "=", id)
.and(ProcessInstance::getProcessDefineId, "in", refreshDefineIds));
clubInfoManageService.dao().delete(SysClubRefresh.class, id); clubInfoManageService.dao().delete(SysClubRefresh.class, id);
flowEngine.processInstanceService().deleteProcessInstanceByBusinessKey(id); for (ProcessInstance refreshInstance : refreshInstances) {
flowEngine.processInstanceService().deleteProcessInstanceById(refreshInstance.getId());
}
return Result.success(); return Result.success();
} }
} }
@@ -17,6 +17,8 @@ import lombok.extern.slf4j.Slf4j;
import org.nutz.dao.Cnd; import org.nutz.dao.Cnd;
import org.nutz.dao.Dao; import org.nutz.dao.Dao;
import org.nutz.dao.util.cri.SqlExpressionGroup; import org.nutz.dao.util.cri.SqlExpressionGroup;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Lang; import org.nutz.lang.Lang;
@@ -161,6 +163,26 @@ public class SiteCugManageController {
return Result.success(savedInfo == null ? info : savedInfo); return Result.success(savedInfo == null ? info : savedInfo);
} }
/**
* 切换场地开启状态。
*
* @param id 场地ID,必须传入已存在的 site_cug_info 主键
* @param state 目标状态,true 表示开启,false 表示关闭
* @return Result 响应;code 为 0 表示状态更新成功,否则 msg 为参数错误信息
*/
@At
@ApiOperation("切换场地开启状态")
@Aop(TransAop.READ_COMMITTED)
@SaCheckPermission("siteCug.manage")
@SLog(tag = "场馆功能管理-场地管理", msg = "切换场地开启状态")
public Result switchState(String id, Boolean state) {
if (StrUtil.isBlank(id) || state == null) {
return Result.error("场地ID或开启状态不能为空");
}
infoService.updateState(id, state);
return Result.success();
}
@At @At
@ApiOperation("delete") @ApiOperation("delete")
@SaCheckPermission("siteCug.manage") @SaCheckPermission("siteCug.manage")
@@ -4,4 +4,12 @@ import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.dayofficework.siteCug.model.SiteCugInfo; import com.budwk.app.zhgh.dayofficework.siteCug.model.SiteCugInfo;
public interface SiteCugInfoService extends BaseService<SiteCugInfo> { public interface SiteCugInfoService extends BaseService<SiteCugInfo> {
/**
* 仅更新场地开启状态,不触发场地完整表单校验。
*
* @param id 场地ID
* @param state true 表示开启,false 表示关闭
*/
void updateState(String id, Boolean state);
} }
@@ -4,7 +4,11 @@ import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.zhgh.dayofficework.siteCug.model.SiteCugInfo; import com.budwk.app.zhgh.dayofficework.siteCug.model.SiteCugInfo;
import com.budwk.app.zhgh.dayofficework.siteCug.service.SiteCugInfoService; import com.budwk.app.zhgh.dayofficework.siteCug.service.SiteCugInfoService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao; import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
@Slf4j @Slf4j
@@ -14,4 +18,16 @@ public class SiteCugInfoServiceImpl extends BaseServiceImpl<SiteCugInfo> impleme
public SiteCugInfoServiceImpl(Dao dao) { public SiteCugInfoServiceImpl(Dao dao) {
super(dao); super(dao);
} }
/**
* 仅更新指定场地的开启状态,防止开关操作覆盖其他场地配置。
*
* @param id 场地ID
* @param state true 表示开启,false 表示关闭
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public void updateState(String id, Boolean state) {
update(Chain.make("state", state), Cnd.where("id", "=", id));
}
} }
@@ -54,7 +54,7 @@ import java.util.List;
@IocBean @IocBean
@At("/platform/unionReimburse/apply") @At("/platform/unionReimburse/apply")
@Api("工会报销申请") @Api("工会报销申请")
@Ok("json:full") @Ok("json:{locked:'condolenceIdCard|condolenceBirthday|condolenceMobile',ignoreNull:false}")
@Slf4j @Slf4j
public class UnionReimburseApplyController { public class UnionReimburseApplyController {
@@ -151,14 +151,11 @@ public class UnionReimburseApplyController {
username as userName, username as userName,
loginname as loginName, loginname as loginName,
sex, sex,
mobile,
technicalTitle, technicalTitle,
IFNULL(unitname, '暂无') as unitName, IFNULL(unitname, '暂无') as unitName,
unitid as unitId, unitid as unitId,
unionid as unionId, unionid as unionId,
unionname as unionName, unionname as unionName,
DATE(birthday) AS birthday,
idCard,
unionCode unionCode
from from
vw_user vw_user
@@ -173,6 +170,9 @@ public class UnionReimburseApplyController {
} }
cnd.and(View_user::getId, "!=", SecurityUtil.getUserId()); cnd.and(View_user::getId, "!=", SecurityUtil.getUserId());
if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) { if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) {
if (StrUtil.isBlank(SecurityUtil.getUnionId())) {
return Result.success(java.util.Collections.emptyList());
}
cnd.and(View_user::getUnionId, "=", SecurityUtil.getUnionId()); cnd.and(View_user::getUnionId, "=", SecurityUtil.getUnionId());
} }
sql.setCondition(cnd); sql.setCondition(cnd);
@@ -34,7 +34,7 @@ import java.util.List;
*/ */
@IocBean @IocBean
@At("/platform/unionReimburse/certifierUserSign") @At("/platform/unionReimburse/certifierUserSign")
@Ok("json:full") @Ok("json:{locked:'condolenceIdCard|condolenceBirthday|condolenceMobile',ignoreNull:false}")
@Api("证明人签字") @Api("证明人签字")
@Slf4j @Slf4j
public class UnionReimburseCertifierUserSignController { public class UnionReimburseCertifierUserSignController {
@@ -68,7 +68,7 @@ import java.util.HashMap;
@IocBean @IocBean
@At("/platform/unionReimburse/mine") @At("/platform/unionReimburse/mine")
@Api("工会报销我的") @Api("工会报销我的")
@Ok("json:full") @Ok("json:{locked:'condolenceIdCard|condolenceBirthday|condolenceMobile',ignoreNull:false}")
@Slf4j @Slf4j
public class UnionReimburseMineController { public class UnionReimburseMineController {
@@ -29,7 +29,7 @@ import java.util.List;
@IocBean @IocBean
@At("/platform/unionReimburse/review") @At("/platform/unionReimburse/review")
@Ok("json:full") @Ok("json:{locked:'condolenceIdCard|condolenceBirthday|condolenceMobile',ignoreNull:false}")
@Api("工会报销审核") @Api("工会报销审核")
public class UnionReimburseReviewController { public class UnionReimburseReviewController {
@@ -38,7 +38,7 @@ import java.util.List;
@IocBean @IocBean
@At("/platform/unionReimburse/statistics") @At("/platform/unionReimburse/statistics")
@Ok("json:full") @Ok("json:{locked:'condolenceIdCard|condolenceBirthday|condolenceMobile',ignoreNull:false}")
@Api("慰问统计") @Api("慰问统计")
@Slf4j @Slf4j
public class UnionReimburseStatisticsController { public class UnionReimburseStatisticsController {
@@ -4,10 +4,12 @@ import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.constant.RoleConstant; import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.exception.BaseException;
import com.budwk.app.base.page.Pagination; import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.result.Result; import com.budwk.app.base.result.Result;
import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.sys.models.Sys_file; import com.budwk.app.sys.models.Sys_file;
import com.budwk.app.sys.views.View_user;
import com.budwk.app.sys.services.SysFileService; import com.budwk.app.sys.services.SysFileService;
import com.budwk.app.zhgh.club.model.ClubUser; import com.budwk.app.zhgh.club.model.ClubUser;
import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.club.model.OutlayManageClub; import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.club.model.OutlayManageClub;
@@ -125,6 +127,37 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
if (oldRecord == null) { if (oldRecord == null) {
return Result.error("未找到对应的报销记录"); return Result.error("未找到对应的报销记录");
} }
if (!isAdmin() && !Objects.equals(oldRecord.getUserId(), SecurityUtil.getUserId())) {
return Result.error("无权修改此报销申请");
}
}
// 人员身份和敏感资料由服务端补齐,避免前端删减字段导致丢失或被篡改。
if (oldRecord == null) {
View_user handler = dao().fetch(View_user.class, Cnd.where("id", "=", SecurityUtil.getUserId()));
if (handler == null) {
return Result.error("经办人信息不存在");
}
unionReimburse.setUserId(handler.getId());
unionReimburse.setUserName(handler.getUsername());
unionReimburse.setLoginName(handler.getLoginname());
unionReimburse.setUnitId(handler.getUnitId());
unionReimburse.setUnitName(handler.getUnitName());
unionReimburse.setUnionId(handler.getUnionId());
unionReimburse.setUnionName(handler.getUnionName());
} else {
// 修改历史单据时保留原经办组织,避免人员调动改变经费归属。
unionReimburse.setUserId(oldRecord.getUserId());
unionReimburse.setUserName(oldRecord.getUserName());
unionReimburse.setLoginName(oldRecord.getLoginName());
unionReimburse.setUnitId(oldRecord.getUnitId());
unionReimburse.setUnitName(oldRecord.getUnitName());
unionReimburse.setUnionId(oldRecord.getUnionId());
unionReimburse.setUnionName(oldRecord.getUnionName());
}
Result recipientResult = fillCondolencePersonalData(unionReimburse, oldRecord);
if (recipientResult != null) {
return recipientResult;
} }
normalizeInvoiceDetails(unionReimburse); normalizeInvoiceDetails(unionReimburse);
@@ -179,14 +212,104 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
if (unionReimburse == null) { if (unionReimburse == null) {
return null; return null;
} }
if (!canReadApply(unionReimburse)) {
throw new BaseException("无权查看此报销申请");
}
List<UnionReimburseInvoiceDetail> invoiceDetails = this.dao().query( List<UnionReimburseInvoiceDetail> invoiceDetails = this.dao().query(
UnionReimburseInvoiceDetail.class, UnionReimburseInvoiceDetail.class,
Cnd.where("reimburseId", "=", id).asc("createdAt") Cnd.where("reimburseId", "=", id).asc("createdAt")
); );
unionReimburse.setInvoiceDetails(invoiceDetails == null ? new ArrayList<>() : invoiceDetails); unionReimburse.setInvoiceDetails(invoiceDetails == null ? new ArrayList<>() : invoiceDetails);
// 申请页和详情页共用此接口,原始身份资料仅保留在服务端业务记录中。
unionReimburse.setCondolenceIdCard(null);
unionReimburse.setCondolenceBirthday(null);
unionReimburse.setCondolenceMobile(null);
return unionReimburse; return unionReimburse;
} }
/** 保留历史单据的身份资料快照,新选人员须符合人员查询的数据范围。 */
private Result fillCondolencePersonalData(UnionReimburse form, UnionReimburse oldRecord) {
form.setCondolenceIdCard(null);
form.setCondolenceBirthday(null);
form.setCondolenceMobile(null);
if (!"UNION_REIMBURSE_PROJECT_1".equals(form.getReimburseProject())) {
return null;
}
if (StrUtil.isBlank(form.getCondolenceUserId())) {
return null;
}
if (oldRecord != null && Objects.equals(oldRecord.getCondolenceUserId(), form.getCondolenceUserId())) {
form.setCondolenceIdCard(oldRecord.getCondolenceIdCard());
form.setCondolenceBirthday(oldRecord.getCondolenceBirthday());
form.setCondolenceMobile(oldRecord.getCondolenceMobile());
form.setCondolenceUnionId(oldRecord.getCondolenceUnionId());
form.setCondolenceUnitId(oldRecord.getCondolenceUnitId());
form.setCondolenceUserName(oldRecord.getCondolenceUserName());
form.setCondolenceLoginName(oldRecord.getCondolenceLoginName());
form.setCondolenceUnitName(oldRecord.getCondolenceUnitName());
form.setCondolenceUnionName(oldRecord.getCondolenceUnionName());
form.setCondolenceUnionCode(oldRecord.getCondolenceUnionCode());
form.setCondolenceSex(oldRecord.getCondolenceSex());
return null;
}
View_user user = dao().fetch(View_user.class, Cnd.where("id", "=", form.getCondolenceUserId()));
if (user == null || Objects.equals(user.getId(), SecurityUtil.getUserId())
|| (!isAdmin() && (StrUtil.isBlank(SecurityUtil.getUnionId())
|| !Objects.equals(SecurityUtil.getUnionId(), user.getUnionId())))) {
return Result.error("无权选择此慰问对象");
}
form.setCondolenceUserName(user.getUsername());
form.setCondolenceLoginName(user.getLoginname());
form.setCondolenceUnitId(user.getUnitId());
form.setCondolenceUnitName(user.getUnitName());
form.setCondolenceUnionId(user.getUnionId());
form.setCondolenceUnionName(user.getUnionName());
form.setCondolenceUnionCode(user.getUnionCode());
form.setCondolenceSex(user.getSex());
form.setCondolenceIdCard(user.getIdCard());
form.setCondolenceBirthday(user.getBirthday() == null ? null : DateUtil.formatDate(user.getBirthday()));
form.setCondolenceMobile(user.getMobile());
return null;
}
/** 详情访问沿用经办、审核、统计和流程参与范围,不能仅凭单据 ID 查询。 */
private boolean canReadApply(UnionReimburse form) {
String userId = SecurityUtil.getUserId();
if (StrUtil.isBlank(userId)) {
return false;
}
if (isAdmin() || userId.equals(form.getUserId())) {
return true;
}
if (form.getStateId() != 1 && (AuthUtil.hasPermission("unionReimburse.review")
|| AuthUtil.hasPermission("h5.unionReimburse.review"))) {
return true;
}
if ((form.getStateId() == 2 || form.getStateId() == 3)
&& StrUtil.isNotBlank(SecurityUtil.getUnionId())
&& Objects.equals(SecurityUtil.getUnionId(), form.getCondolenceUnionId())
&& (AuthUtil.hasPermission("unionReimburse.statistics")
|| AuthUtil.hasPermission("h5.unionReimburse.statistics"))) {
return true;
}
// 旧流程单据允许任务参与人和抄送人回看,关系必须由数据库确认。
Sql sql = Sqls.create("""
SELECT COUNT(*) FROM wf_process_instance i
WHERE i.businessNo = @id AND (
EXISTS (SELECT 1 FROM wf_process_task t
INNER JOIN wf_process_task_actor a ON a.processTaskId = t.id
WHERE t.processInstanceId = i.id AND a.actorId = @userId)
OR EXISTS (SELECT 1 FROM wf_process_cc_instance c
WHERE c.processInstanceId = i.id AND c.actorId = @userId)
)
""");
sql.setParam("id", form.getId());
sql.setParam("userId", userId);
sql.setCallback(Sqls.callback.integer());
dao().execute(sql);
return sql.getInt() > 0;
}
@Override @Override
public void deleteApply(String id) { public void deleteApply(String id) {
if (StrUtil.isBlank(id)) { if (StrUtil.isBlank(id)) {
@@ -242,14 +365,11 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
u.username AS userName, u.username AS userName,
u.loginname AS loginName, u.loginname AS loginName,
u.sex, u.sex,
u.mobile,
u.technicalTitle, u.technicalTitle,
IFNULL(u.unitname, '暂无') AS unitName, IFNULL(u.unitname, '暂无') AS unitName,
u.unitid AS unitId, u.unitid AS unitId,
u.unionid AS unionId, u.unionid AS unionId,
u.unionname AS unionName, u.unionname AS unionName,
DATE(u.birthday) AS birthday,
u.idCard,
u.unionCode u.unionCode
FROM FROM
vw_user u vw_user u
@@ -91,6 +91,7 @@ public class CongressVotingStatisticalController {
@At("/records") @At("/records")
@SaCheckPermission("congress.vote.statistical") @SaCheckPermission("congress.vote.statistical")
public Result voteRecords(@Param("issueId") String issueId) { public Result voteRecords(@Param("issueId") String issueId) {
// H5 投票使用现有职代会代表表写入 rep_id,旧表关联仅作为历史数据兼容回退。
Sql sql = Sqls.create(""" Sql sql = Sqls.create("""
SELECT SELECT
r.id, r.id,
@@ -98,10 +99,17 @@ public class CongressVotingStatisticalController {
r.name, r.name,
r.vote_result AS voteResult, r.vote_result AS voteResult,
r.create_time AS createTime, r.create_time AS createTime,
COALESCE(rep.union_id, du.union_id) AS unionId, COALESCE(tc_delegate.unionId, rep.union_id, du.union_id) AS unionId,
du.union_name AS unionName, COALESCE(tc_delegate.unionName, du.union_name) AS unionName,
COALESCE(rep.delegation_name, d.name) AS delegationName COALESCE(delegate_delegation.name, rep.delegation_name, d.name) AS delegationName
FROM congress_voting_record r FROM congress_voting_record r
LEFT JOIN teacher_congress_delegate tc_delegate ON tc_delegate.id = r.rep_id
AND tc_delegate.sessionId = r.session_id
AND COALESCE(tc_delegate.delFlag, 0) = 0
LEFT JOIN teacher_congress_delegation delegate_delegation
ON delegate_delegation.id = COALESCE(r.delegation_id, tc_delegate.delegationId)
AND delegate_delegation.sessionId = r.session_id
AND COALESCE(delegate_delegation.delFlag, 0) = 0
LEFT JOIN congress_rep rep ON rep.id = r.rep_id LEFT JOIN congress_rep rep ON rep.id = r.rep_id
LEFT JOIN congress_delegation d ON d.id = r.delegation_id LEFT JOIN congress_delegation d ON d.id = r.delegation_id
LEFT JOIN ( LEFT JOIN (
@@ -173,10 +173,7 @@ public class ProposalSecondedController {
} }
ProposalSearchParam.buildSearch(cnd, pageForm); ProposalSearchParam.buildSearch(cnd, pageForm);
cnd.groupBy("t.id"); cnd.groupBy("t.id");
// 用户未指定有效排序字段时,继续沿用原有任务创建时间倒序。
if (!proposalCommonService.applySafePageOrder(cnd, pageForm, ORDER_COLUMNS)) {
cnd.desc("t.createdAt"); cnd.desc("t.createdAt");
}
sql.setCondition(cnd); sql.setCondition(cnd);
Pagination pagination = baseService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); Pagination pagination = baseService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql);
return Result.success(pagination); return Result.success(pagination);
@@ -244,7 +244,7 @@ public class ProposalUnderTakeReplyController {
@SaCheckPermission("proposal.unitReply") @SaCheckPermission("proposal.unitReply")
@ApiOperation("查询本单位可转办用户") @ApiOperation("查询本单位可转办用户")
public Result selectViceUser(@Param("keyword") @Valid String keyword) { public Result selectViceUser(@Param("keyword") @Valid String keyword) {
Sql sql = Sqls.create("select id,loginname,username from vw_user $condition"); Sql sql = Sqls.create("select id,loginname,username,unitName from vw_user $condition");
Cnd cnd = Cnd.NEW(); Cnd cnd = Cnd.NEW();
cnd.andEX("unitId", "=", SecurityUtil.getUnitId()); cnd.andEX("unitId", "=", SecurityUtil.getUnitId());
cnd.andEX("id", "!=", SecurityUtil.getUserId()); cnd.andEX("id", "!=", SecurityUtil.getUserId());
@@ -79,7 +79,7 @@ public class ProposalSearchParam extends PageForm {
if (StrUtil.isAllNotBlank(searchParam.getPageOrderName(), searchParam.getPageOrderBy())) { if (StrUtil.isAllNotBlank(searchParam.getPageOrderName(), searchParam.getPageOrderBy())) {
cnd.orderBy(searchParam.getPageOrderName(), PageUtil.getOrder(searchParam.getPageOrderBy())); cnd.orderBy(searchParam.getPageOrderName(), PageUtil.getOrder(searchParam.getPageOrderBy()));
} else { } else {
cnd.asc("info.code"); cnd.desc("info.code");
} }
} }
} }
@@ -11,7 +11,6 @@ import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import org.eclipse.jetty.util.StringUtil;
import org.nutz.dao.entity.annotation.*; import org.nutz.dao.entity.annotation.*;
import org.nutz.dao.interceptor.annotation.PrevInsert; import org.nutz.dao.interceptor.annotation.PrevInsert;
@@ -161,13 +160,13 @@ public class MallGoods extends BaseModel {
//循环sku,判定sku是否有效 //循环sku,判定sku是否有效
for (Map<String, Object> sku : goodsOperationDTO.getSkuList()) { for (Map<String, Object> sku : goodsOperationDTO.getSkuList()) {
//判定参数不能为空 //判定参数不能为空
if ((!sku.containsKey("price") || StringUtil.isEmpty(sku.get("price").toString()) || Convert.toDouble(sku.get("price")) <= 0)) { if ((!sku.containsKey("price") || CharSequenceUtil.isEmpty(sku.get("price").toString()) || Convert.toDouble(sku.get("price")) <= 0)) {
throw new BaseException("商品SKU价格不能小于等于0"); throw new BaseException("商品SKU价格不能小于等于0");
} }
// if ((!sku.containsKey("cost") || StringUtil.isEmpty(sku.get("cost").toString()) || Convert.toDouble(sku.get("cost")) <= 0)) { // if ((!sku.containsKey("cost") || StringUtil.isEmpty(sku.get("cost").toString()) || Convert.toDouble(sku.get("cost")) <= 0)) {
// throw new BaseException("商品SKU成本价不能小于等于0"); // throw new BaseException("商品SKU成本价不能小于等于0");
// } // }
if (!sku.containsKey("quantity") || StringUtil.isEmpty(sku.get("quantity").toString()) || Convert.toInt(sku.get("quantity").toString()) < 0) { if (!sku.containsKey("quantity") || CharSequenceUtil.isEmpty(sku.get("quantity").toString()) || Convert.toInt(sku.get("quantity").toString()) < 0) {
throw new BaseException("商品库存数量不能为负数"); throw new BaseException("商品库存数量不能为负数");
} }
sku.values().forEach(i -> { sku.values().forEach(i -> {
@@ -127,7 +127,6 @@ public class CondolenceApplyController {
username as userName, username as userName,
loginname as loginName, loginname as loginName,
sex, sex,
mobile,
technicalTitle, technicalTitle,
IFNULL(unitname, '暂无') as unitName, IFNULL(unitname, '暂无') as unitName,
unitid as unitId, unitid as unitId,
@@ -146,6 +145,9 @@ public class CondolenceApplyController {
} }
if(!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) { if(!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) {
if(AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_ADMIN.name(), RoleConstant.BRANCH_UNION_CHAIRMAN.name())) { if(AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_ADMIN.name(), RoleConstant.BRANCH_UNION_CHAIRMAN.name())) {
if (StrUtil.isBlank(SecurityUtil.getUnionId())) {
return Result.success(java.util.Collections.emptyList());
}
cnd.and(View_user::getUnionId, "=", SecurityUtil.getUnionId()); cnd.and(View_user::getUnionId, "=", SecurityUtil.getUnionId());
} else { } else {
cnd.and(View_user::getId, "=", SecurityUtil.getUserId()); cnd.and(View_user::getId, "=", SecurityUtil.getUserId());
+12 -8
View File
@@ -16,16 +16,20 @@ sm4cbc:
keyHex: 00112233445566778899aabbccddeeff keyHex: 00112233445566778899aabbccddeeff
ivHex: 0102030405060708090a0b0c0d0e0f10 ivHex: 0102030405060708090a0b0c0d0e0f10
jetty: tomcat:
contextPath: / contextPath: ""
threadpool: # 容器错误响应不披露版本;Session Cookie 保持 HttpOnly。
idleTimeout: 60000 session:
minThreads: 10 cookie:
httponly: true
executor:
maxIdleTime: 60000
minSpareThreads: 10
maxThreads: 500 maxThreads: 500
page: page:
403: /error/403.html 403: /__container_error
404: /error/404.html 404: /__container_error
500: /error/500.html 500: /__container_error
#结合ftp使用,或用nginx代理ftp路径 #结合ftp使用,或用nginx代理ftp路径
#staticPath: /Users/wizzer/temp/files #staticPath: /Users/wizzer/temp/files
#开发模式静态资源 #开发模式静态资源
+2 -2
View File
@@ -14,7 +14,7 @@ var ioc={
// 单个文件最大尺寸(大约的值,单位为字节,即 2097152 为 2M) // 单个文件最大尺寸(大约的值,单位为字节,即 2097152 为 2M)
maxFileSize : 2097152, maxFileSize : 2097152,
// 正则表达式匹配可以支持的文件名 // 正则表达式匹配可以支持的文件名
nameFilter : '^(.+[.])(gif|jpg|png|svg)$' nameFilter : '(?i)^(.+[.])(gif|jpg|jpeg|png)$'
} }
}, },
imageUpload : { imageUpload : {
@@ -32,7 +32,7 @@ var ioc={
// 单个文件最大尺寸(大约的值,单位为字节,即 209715200 为 200M) // 单个文件最大尺寸(大约的值,单位为字节,即 209715200 为 200M)
maxFileSize : 209715200, maxFileSize : 209715200,
// 正则表达式匹配可以支持的文件名 // 正则表达式匹配可以支持的文件名
nameFilter : '^(.+[.])(gif||svg|jpg|png|jpeg|doc|docx|xls|xlsx|rar|zip|7z|txt|pdf|pptx|ppt|mp4)$' nameFilter : '(?i)^(.+[.])(gif|jpg|png|jpeg|doc|docx|xls|xlsx|rar|zip|7z|txt|pdf|pptx|ppt|mp4)$'
} }
}, },
fileUpload : { fileUpload : {
@@ -36,7 +36,7 @@ layout("/layouts/platform.html"){
新增场地 新增场地
</el-button> </el-button>
</table-tool> </table-tool>
<el-table :data="tableData" @sort-change="pageOrder" :size="tableSize"> <el-table v-loading="tableLoading" :data="tableData" @sort-change="pageOrder" :size="tableSize">
<el-table-column label="序号" type="index" :index="indexMethod" width="60"></el-table-column> <el-table-column label="序号" type="index" :index="indexMethod" width="60"></el-table-column>
<el-table-column <el-table-column
v-for="column in tableColumns" v-for="column in tableColumns"
@@ -152,11 +152,23 @@ layout("/layouts/platform.html"){
.catch(() => {}) .catch(() => {})
}, },
switchChange(row) { switchChange(row) {
this.$axios.post("/platform/siteCug/manage/submit", row).then((res) => { const previousState = !row.state
this.tableLoading = true
this.$axios.post("/platform/siteCug/manage/switchState", {
id: row.id,
state: row.state
}).then((res) => {
if (res.code === 0) { if (res.code === 0) {
this.$message.success(res.msg) this.$message.success(res.msg)
this.pageData() } else {
// 状态更新失败时恢复开关,避免页面显示与数据库实际状态不一致。
this.$set(row, "state", previousState)
this.$message.warning(res.msg)
} }
}).catch(() => {
this.$set(row, "state", previousState)
}).finally(() => {
this.tableLoading = false
}) })
}, },
querySiteType() { querySiteType() {
@@ -187,31 +187,6 @@ layout("/layouts/platform.html"){
</el-form-item> </el-form-item>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="生日"
v-if="formData.reimburseProject === 'UNION_REIMBURSE_PROJECT_1'">
<el-form-item prop="condolenceBirthday" label="生日">
<el-input
:value="formData.condolenceBirthday && $moment(formData.condolenceBirthday).isValid() ?$moment(formData.condolenceBirthday).format('YYYY-MM-DD') : ''"
show-word-limit placeholder="请输入生日" disabled></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="身份证号"
v-if="formData.reimburseProject === 'UNION_REIMBURSE_PROJECT_1'">
<el-form-item prop="condolenceIdCard" label="身份证号">
<el-input v-model="formData.condolenceIdCard" show-word-limit
placeholder="请输入身份证号" disabled></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="联系方式"
v-if="formData.reimburseProject === 'UNION_REIMBURSE_PROJECT_1'">
<el-form-item prop="condolenceMobile" label="联系方式">
<el-input v-model="formData.condolenceMobile" show-word-limit
placeholder="请输入联系方式" disabled></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="慰问类型" <el-descriptions-item label="慰问类型"
v-if="formData.reimburseProject === 'UNION_REIMBURSE_PROJECT_1'"> v-if="formData.reimburseProject === 'UNION_REIMBURSE_PROJECT_1'">
<el-form-item label="慰问类型" prop="condolenceTypeId"> <el-form-item label="慰问类型" prop="condolenceTypeId">
@@ -816,7 +791,6 @@ layout("/layouts/platform.html"){
trigger: ["change", "blur"] trigger: ["change", "blur"]
}, {pattern: /^([1-9]{1})(\d{15}|\d{18})$/, message: "请输入正确的银行卡号", trigger: "blur"}], }, {pattern: /^([1-9]{1})(\d{15}|\d{18})$/, message: "请输入正确的银行卡号", trigger: "blur"}],
bankOfDeposit: [{required: true, message: "请填写开户行", trigger: ["change", "blur"]}], bankOfDeposit: [{required: true, message: "请填写开户行", trigger: ["change", "blur"]}],
condolenceMobile: [{pattern: /^1[3-9]\d{9}$/, message: "请输入正确的手机号码", trigger: "blur"}],
condolenceTime: [{required: true, message: "请选择慰问时间", trigger: ["change", "blur"]}], condolenceTime: [{required: true, message: "请选择慰问时间", trigger: ["change", "blur"]}],
// invoiceNumber: [{required: true, message: "请填写发票张数", trigger: ["change", "blur"]}], // invoiceNumber: [{required: true, message: "请填写发票张数", trigger: ["change", "blur"]}],
// invoice: [{required: true, message: "请填写发票号码", trigger: ["change", "blur"]}], // invoice: [{required: true, message: "请填写发票号码", trigger: ["change", "blur"]}],
@@ -1187,10 +1161,7 @@ layout("/layouts/platform.html"){
unionId, unionId,
unionName, unionName,
unionCode, unionCode,
sex, sex
birthday,
idCard,
mobile
} = user } = user
this.$set(this.formData, "condolenceUserName", userName) this.$set(this.formData, "condolenceUserName", userName)
this.$set(this.formData, "condolenceLoginName", loginName) this.$set(this.formData, "condolenceLoginName", loginName)
@@ -1200,9 +1171,6 @@ layout("/layouts/platform.html"){
this.$set(this.formData, "condolenceUnionName", unionName) this.$set(this.formData, "condolenceUnionName", unionName)
this.$set(this.formData, "condolenceUnionCode", unionCode) this.$set(this.formData, "condolenceUnionCode", unionCode)
this.$set(this.formData, "condolenceSex", sex) this.$set(this.formData, "condolenceSex", sex)
this.$set(this.formData, "condolenceBirthday", birthday)
this.$set(this.formData, "condolenceIdCard", idCard)
this.$set(this.formData, "condolenceMobile", mobile)
} }
}, },
typeChange(val) { typeChange(val) {
@@ -86,18 +86,6 @@ const unionReimburseInfo = {
<span>{{ viewData.condolenceSex }}</span> <span>{{ viewData.condolenceSex }}</span>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="生日" v-if="viewData.reimburseProject === 'UNION_REIMBURSE_PROJECT_1'">
<span>{{ viewData.condolenceBirthday && $moment(viewData.condolenceBirthday).isValid() ? $moment(viewData.condolenceBirthday).format('YYYY-MM-DD') : '' }}</span>
</el-descriptions-item>
<el-descriptions-item label="身份证号" v-if="viewData.reimburseProject === 'UNION_REIMBURSE_PROJECT_1'">
<span>{{ viewData.condolenceIdCard }}</span>
</el-descriptions-item>
<el-descriptions-item label="联系方式" v-if="viewData.reimburseProject === 'UNION_REIMBURSE_PROJECT_1'">
<span>{{ viewData.condolenceMobile }}</span>
</el-descriptions-item>
<el-descriptions-item label="慰问类型" v-if="viewData.reimburseProject === 'UNION_REIMBURSE_PROJECT_1'"> <el-descriptions-item label="慰问类型" v-if="viewData.reimburseProject === 'UNION_REIMBURSE_PROJECT_1'">
<span>{{ viewData.typeName }}</span> <span>{{ viewData.typeName }}</span>
</el-descriptions-item> </el-descriptions-item>
@@ -109,15 +109,27 @@ layout("/layouts/platform.html"){
if (!value) return [] if (!value) return []
try { try {
const data = typeof value === "string" ? JSON.parse(value) : value const data = typeof value === "string" ? JSON.parse(value) : value
return Array.isArray(data) ? data : [] if (Array.isArray(data)) return data
if (data && typeof data === "object") {
return Object.keys(data).map(item => ({item, result: data[item]}))
}
return []
} catch (e) { } catch (e) {
return [] return []
} }
}, },
voteOptionText(item) {
const value = item.result || item.vote || item.selected || item.option || item.value
if (Array.isArray(value)) return value.join("、")
if (value && typeof value === "object") {
return value.label || value.name || value.value || "-"
}
return value || "-"
},
voteResultText(value) { voteResultText(value) {
return this.parseVoteResult(value).map(item => { return this.parseVoteResult(value).map(item => {
return (item.item || item.itemName || "投票项") + "" + return (item.item || item.itemName || "投票项") + "" +
(item.selected || item.option || item.value || "-") this.voteOptionText(item)
}).join("") }).join("")
}, },
buildSummary(records) { buildSummary(records) {
@@ -125,7 +137,7 @@ layout("/layouts/platform.html"){
records.forEach(record => { records.forEach(record => {
this.parseVoteResult(record.voteResult).forEach(item => { this.parseVoteResult(record.voteResult).forEach(item => {
const itemName = item.item || item.itemName || "投票项" const itemName = item.item || item.itemName || "投票项"
const option = item.selected || item.option || item.value || "-" const option = this.voteOptionText(item)
if (!counter[itemName]) counter[itemName] = {} if (!counter[itemName]) counter[itemName] = {}
counter[itemName][option] = (counter[itemName][option] || 0) + 1 counter[itemName][option] = (counter[itemName][option] || 0) + 1
}) })
@@ -110,10 +110,10 @@ const PROPOSAL_INFO = {
</el-dialog> </el-dialog>
<!--审核信息支持按需同时展开多条记录,初次加载和切换提案时仍保持全部折叠。--> <!--审核信息支持按需同时展开多条记录,初次加载和切换提案时仍保持全部折叠。-->
<template v-if="doneTasks.length"> <template v-if="displayDoneTasks.length">
<div class="process-title">审核信息</div> <div class="process-title">审核信息</div>
<el-collapse v-model="activeTaskIds"> <el-collapse v-model="activeTaskIds">
<el-collapse-item v-for="task in doneTasks" :key="task.id" :name="task.id"> <el-collapse-item v-for="task in displayDoneTasks" :key="task.id" :name="task.id">
<template slot="title"> <template slot="title">
<!--折叠标题使用独立的单行布局,避免复用 process-title 后产生外边距和高度冲突。--> <!--折叠标题使用独立的单行布局,避免复用 process-title 后产生外边距和高度冲突。-->
<div style="display: flex;align-items: center;justify-content: space-between;width: 100%;min-width: 0;padding-left: 10px"> <div style="display: flex;align-items: center;justify-content: space-between;width: 100%;min-width: 0;padding-left: 10px">
@@ -125,8 +125,28 @@ const PROPOSAL_INFO = {
</span> </span>
</div> </div>
</template> </template>
<!--同一提案可能存在多个承办单位答复任务,查看时合并为一个流程节点,节点内保留每条答复。-->
<template v-if="task.replyTasks">
<el-descriptions v-for="replyTask in task.replyTasks" border class="flow-task-form"
:column="3" :key="replyTask.id">
<el-descriptions-item label="承办单位" :span="3">
{{replyTask.ext.underTakeName}}
</el-descriptions-item>
<el-descriptions-item label="办理用户">
{{replyTask.taskFormData.userName}}({{replyTask.taskFormData.loginName}})
</el-descriptions-item>
<el-descriptions-item label="办理时间">{{replyTask.finishTime}}</el-descriptions-item>
<el-descriptions-item label="办理结果">
<dict-tag :options="dict.type.PROCESS_TASK_SUBMIT_TYPE"
:value="replyTask.ext.submitType"></dict-tag>
</el-descriptions-item>
<el-descriptions-item label="办理意见" :span="3">
<div v-html="replyTask.taskFormData.tf_opinion"></div>
</el-descriptions-item>
</el-descriptions>
</template>
<el-descriptions border class="flow-task-form" :column="3" :key="task.id" <el-descriptions border class="flow-task-form" :column="3" :key="task.id"
v-if="task.ext.isFirstTaskNode"> v-else-if="task.ext.isFirstTaskNode">
<el-descriptions-item label="申请用户">{{ task.ext.initiatorName <el-descriptions-item label="申请用户">{{ task.ext.initiatorName
}}({{task.ext.initiatorAccount}}) }}({{task.ext.initiatorAccount}})
</el-descriptions-item> </el-descriptions-item>
@@ -233,6 +253,7 @@ const PROPOSAL_INFO = {
tableLoading: false, tableLoading: false,
viewData: {}, viewData: {},
doneTasks: [], doneTasks: [],
displayDoneTasks: [],
activeTaskIds: [], activeTaskIds: [],
row: null, row: null,
viewDialogVisible: false viewDialogVisible: false
@@ -258,6 +279,7 @@ const PROPOSAL_INFO = {
this.$set(this, "tableLoading", true) this.$set(this, "tableLoading", true)
// 切换提案时先清空上一条提案的审核记录和展开状态,避免异步加载期间展示旧数据。 // 切换提案时先清空上一条提案的审核记录和展开状态,避免异步加载期间展示旧数据。
this.doneTasks = [] this.doneTasks = []
this.displayDoneTasks = []
this.activeTaskIds = [] this.activeTaskIds = []
let pendingRequestCount = 2 let pendingRequestCount = 2
const handleRequestComplete = () => { const handleRequestComplete = () => {
@@ -293,15 +315,94 @@ const PROPOSAL_INFO = {
return this.$axios.post("/flow/common/doneTasks", {bizId: this.row.id}).then((res) => { return this.$axios.post("/flow/common/doneTasks", {bizId: this.row.id}).then((res) => {
if (res.code === 0) { if (res.code === 0) {
const tasks = res.data || [] const tasks = res.data || []
this.doneTasks = tasks // 提案历史按业务阶段展示,避免较晚完成的附议记录被排到团长审核之后。
const sortedTasks = this.sortDoneTasks(tasks)
this.$set(this, "doneTasks", sortedTasks)
// 参考历史系统的展示方式,将多个单位的答复收纳在一个流程节点内。
this.$set(this, "displayDoneTasks", this.groupReplyTasks(sortedTasks))
// 审核记录加载完成后默认全部折叠,由用户按需展开查看。 // 审核记录加载完成后默认全部折叠,由用户按需展开查看。
this.activeTaskIds = [] this.activeTaskIds = []
// 通知外层页面已办节点数据已加载完成,便于当前环节做表单回显。 // 通知外层页面已办节点数据已加载完成,便于当前环节做表单回显。
this.$emit("done-tasks", tasks) this.$emit("done-tasks", this.doneTasks)
} }
}) })
}, },
/**
* 按提案业务阶段对审核历史排序。
*
* @param tasks doneTasks 接口返回的已办任务数组
* @return 排序后的新数组;同一阶段按办理时间、任务 ID 保持稳定顺序
*/
sortDoneTasks(tasks) {
return (tasks || []).map((task, index) => ({task, index})).sort((left, right) => {
const stageDiff = this.getTaskStageOrder(left.task) - this.getTaskStageOrder(right.task)
if (stageDiff !== 0) {
return stageDiff
}
const leftTime = String(left.task.finishTime || "")
const rightTime = String(right.task.finishTime || "")
if (leftTime !== rightTime) {
if (!leftTime) return 1
if (!rightTime) return -1
return leftTime.localeCompare(rightTime)
}
const idDiff = Number(left.task.id || 0) - Number(right.task.id || 0)
return idDiff !== 0 ? idDiff : left.index - right.index
}).map((item) => item.task)
},
/**
* 将同一提案的承办单位答复任务合并成一个展示节点。
*
* @param tasks 已按业务阶段排好顺序的任务数组
* @return 仅用于查看页面的展示数组;原始任务及每条答复内容均保存在 replyTasks 中
*/
groupReplyTasks(tasks) {
const replyTaskNames = ["opinion_unit_reply", "unit_reply", "two_unit_reply", "master_reply", "slave_reply"]
const replyTasks = (tasks || []).filter((task) => replyTaskNames.includes(task.taskName))
if (!replyTasks.length) {
return tasks || []
}
const firstReplyTask = replyTasks[0]
const replyTaskIds = new Set(replyTasks.map((task) => task.id))
return (tasks || []).reduce((displayTasks, task) => {
if (!replyTaskIds.has(task.id)) {
displayTasks.push(task)
} else if (task.id === firstReplyTask.id) {
displayTasks.push({
id: "reply-group-" + firstReplyTask.id,
taskName: "replyGroup",
displayName: "承办单位答复",
replyTasks: replyTasks
})
}
return displayTasks
}, [])
},
/**
* 获取提案任务所属的展示阶段。
* 未单列的预审核、委员审议、确认承办单位和复核记录统一放在委员会立案阶段,
* 答复后的校领导审批通过 legacyStage 标识排在承办单位答复之后。
*
* @param task 单条已办任务
* @return 数字类型的阶段顺序
*/
getTaskStageOrder(task) {
const taskName = task.taskName
const legacyStage = task.ext ? task.ext.legacyStage : ""
if (taskName === "startTask") return 10
if (taskName === "invite") return 15
if (taskName === "second") return 20
if (taskName === "delegation") return 30
if (["opinion_unit_reply", "unit_reply", "two_unit_reply", "master_reply", "slave_reply"].includes(taskName)) return 50
if (taskName === "schoolLeader" && legacyStage === "replyLeaderAudit") return 55
if (taskName === "feedback") return 60
return 40
},
openChart(){ openChart(){
this.$refs.snakerChartRef.onOpenFull(this.row.instanceProcessDefineId, this.row.instanceId) this.$refs.snakerChartRef.onOpenFull(this.row.instanceProcessDefineId, this.row.instanceId)
} }
@@ -179,16 +179,17 @@ layout("/layouts/platform.html"){
</el-radio> </el-radio>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item prop="tf_attachment" label="附件"> <!-- 承办单位答复附件上传入口暂时停用,保留原配置便于后续恢复。 -->
<file-upload <!-- <el-form-item prop="tf_attachment" label="附件">-->
:value.sync="formData.tf_attachment" <!-- <file-upload-->
upload_mode="drag" <!-- :value.sync="formData.tf_attachment"-->
:upload_number="5" <!-- upload_mode="drag"-->
upload_result_category="array" <!-- :upload_number="5"-->
complete_result <!-- upload_result_category="array"-->
upload_mode="file" <!-- complete_result-->
></file-upload> <!-- upload_mode="file"-->
</el-form-item> <!-- ></file-upload>-->
<!-- </el-form-item>-->
<el-form-item label="答复内容" prop="tf_opinion" <el-form-item label="答复内容" prop="tf_opinion"
:rules="[{required:true,message:'必填',trigger:['change','blur']}]"> :rules="[{required:true,message:'必填',trigger:['change','blur']}]">
<text-editor v-model="formData.tf_opinion"></text-editor> <text-editor v-model="formData.tf_opinion"></text-editor>
@@ -14,12 +14,11 @@ const welfareOption = {
<el-table-column align="center" header-align="center" label="系统默认选择" width="140"> <el-table-column align="center" header-align="center" label="系统默认选择" width="140">
<template slot-scope="{row}"> <template slot-scope="{row}">
<el-radio <el-checkbox
v-model="systemDefaultOption" :value="!!row.isSystemDefault"
:label="row" @change="setSystemDefault(row, $event)">
@change="setSystemDefault(row)">
&nbsp; &nbsp;
</el-radio> </el-checkbox>
</template> </template>
</el-table-column> </el-table-column>
@@ -124,9 +123,10 @@ const welfareOption = {
}, },
// 每个福利项目只允许配置一个系统默认选项,保存时会随福利选项一并提交。 // 每个福利项目只允许配置一个系统默认选项,保存时会随福利选项一并提交。
setSystemDefault(defaultOption) { setSystemDefault(defaultOption, checked = true) {
this.systemDefaultOption = checked ? defaultOption : null;
this.welfareList.forEach((item) => { this.welfareList.forEach((item) => {
this.$set(item, 'isSystemDefault', item === defaultOption); this.$set(item, 'isSystemDefault', checked && item === defaultOption);
}); });
}, },
@@ -200,7 +200,7 @@ const welfareOption = {
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }
/deep/ .welfare-option .el-radio__label { /deep/ .welfare-option .el-checkbox__label {
padding-left: 0; padding-left: 0;
} }
@@ -397,6 +397,19 @@ layout("/layouts/platform_h5.html"){
justify-content: flex-end; justify-content: flex-end;
} }
.sports-event-union-action-hint {
display: inline-flex;
align-items: center;
color: #ed6a0c;
font-size: 12px;
line-height: 18px;
text-align: right;
}
.sports-event-union-action-hint.is-authorized {
color: #1989fa;
}
.sports-event-page .table-card .table-card-footer .van-cell__value { .sports-event-page .table-card .table-card-footer .van-cell__value {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -700,6 +713,12 @@ layout("/layouts/platform_h5.html"){
取消报名 取消报名
</van-button> </van-button>
</div> </div>
<span
v-if="row.applyWay.includes(2) && !row.applyWay.includes(1)"
class="sports-event-union-action-hint"
:class="{'is-authorized': canUnionApplyOnPc}">
{{ canUnionApplyOnPc ? '分工会报名请在PC端进行' : '该活动为分工会报名,暂无权限' }}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -731,6 +750,12 @@ layout("/layouts/platform_h5.html"){
<i class="fa fa-trash"></i>取消报名 <i class="fa fa-trash"></i>取消报名
</van-button> </van-button>
</div> </div>
<span
v-if="row.applyWay.includes(2) && !row.applyWay.includes(1)"
class="sports-event-union-action-hint"
:class="{'is-authorized': canUnionApplyOnPc}">
{{ canUnionApplyOnPc ? '分工会报名请在PC端进行' : '该活动为分工会报名,暂无权限' }}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -768,6 +793,16 @@ layout("/layouts/platform_h5.html"){
} }
}, },
components: {}, components: {},
computed: {
canUnionApplyOnPc() {
return this.$auth.hasRoleOr([
"SYSADMIN",
"SCHOOL_UNION_ADMIN",
"BRANCH_UNION_CHAIRMAN",
"BRANCH_UNION_WENTI_SPORTS"
]) && this.$auth.hasPermission("activity.apply")
}
},
methods: { methods: {
toggleViewMode() { toggleViewMode() {
this.viewMode = this.viewMode === "card" ? "list" : "card" this.viewMode = this.viewMode === "card" ? "list" : "card"
@@ -324,11 +324,37 @@ layout("/layouts/platform_h5.html"){
} }
.brand-course-card__detail:nth-child(-n + 2) .brand-course-card__detail-label, .brand-course-card__detail:nth-child(-n + 2) .brand-course-card__detail-label,
.brand-course-card__detail:nth-child(-n + 2) .brand-course-card__detail-value { .brand-course-card__detail:nth-child(2) .brand-course-card__detail-value {
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: normal; overflow-wrap: normal;
white-space: nowrap; white-space: nowrap;
} }
.brand-course-card__detail:first-child .brand-course-card__detail-value {
display: block;
max-width: 100%;
overflow: hidden;
overflow-wrap: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.brand-course-contact-popover {
display: block;
min-width: 0;
max-width: 100%;
}
.brand-course-contact-popover__content {
max-width: 240px;
padding: 8px 12px;
overflow-wrap: anywhere;
color: #33445a;
font-size: 12px;
line-height: 18px;
}
.brand-course-card__links { .brand-course-card__links {
display: flex; display: flex;
min-width: 0; min-width: 0;
@@ -913,7 +939,17 @@ layout("/layouts/platform_h5.html"){
<van-icon name="friends-o"></van-icon> <van-icon name="friends-o"></van-icon>
<div class="brand-course-card__detail-copy"> <div class="brand-course-card__detail-copy">
<div class="brand-course-card__detail-label">联系人</div> <div class="brand-course-card__detail-label">联系人</div>
<div class="brand-course-card__detail-value">{{row.courseInstructor}}</div> <van-popover
class="brand-course-contact-popover"
:value="contactPopoverIndex === index"
placement="top-start"
trigger="click"
@input="setContactPopover(index, $event)">
<div class="brand-course-contact-popover__content">{{row.courseInstructor || '暂无'}}</div>
<template #reference>
<div class="brand-course-card__detail-value">{{row.courseInstructor || '暂无'}}</div>
</template>
</van-popover>
</div> </div>
</div> </div>
<div class="brand-course-card__detail"> <div class="brand-course-card__detail">
@@ -1009,6 +1045,7 @@ layout("/layouts/platform_h5.html"){
activeAssort: null, activeAssort: null,
filterOpened: false, filterOpened: false,
viewMode: 'card', viewMode: 'card',
contactPopoverIndex: -1,
weekdayCNMap: {0: '周日', 1: '周一', 2: '周二', 3: '周三', 4: '周四', 5: '周五', 6: '周六'}, weekdayCNMap: {0: '周日', 1: '周一', 2: '周二', 3: '周三', 4: '周四', 5: '周五', 6: '周六'},
} }
@@ -1017,6 +1054,9 @@ layout("/layouts/platform_h5.html"){
toggleViewMode() { toggleViewMode() {
this.viewMode = this.viewMode === 'card' ? 'list' : 'card' this.viewMode = this.viewMode === 'card' ? 'list' : 'card'
}, },
setContactPopover(index, visible) {
this.contactPopoverIndex = visible ? index : -1
},
onAssortChange(name) { onAssortChange(name) {
this.pageForm.assortTypes = name ? JSON.stringify([name]) : [] this.pageForm.assortTypes = name ? JSON.stringify([name]) : []
this.doSearch() this.doSearch()
@@ -444,7 +444,7 @@ layout("/layouts/platform_h5.html"){
<button type="button" class="works-mine-card__action" @click="onView(row)"> <button type="button" class="works-mine-card__action" @click="onView(row)">
<van-icon name="eye-o"></van-icon>查看 <van-icon name="eye-o"></van-icon>查看
</button> </button>
<button type="button" class="works-mine-card__action is-primary" @click="onEdit(row)"> <button type="button" class="works-mine-card__action" @click="onEdit(row)">
<van-icon name="edit"></van-icon>编辑 <van-icon name="edit"></van-icon>编辑
</button> </button>
<button type="button" class="works-mine-card__action is-danger" @click="onDelete(row)"> <button type="button" class="works-mine-card__action is-danger" @click="onDelete(row)">
@@ -467,7 +467,7 @@ layout("/layouts/platform_h5.html"){
:close-on-click-overlay="false" show-cancel-button cancel-button-text="取消" confirm-button-text="确认删除" :close-on-click-overlay="false" show-cancel-button cancel-button-text="取消" confirm-button-text="确认删除"
@cancel="closeDeleteConfirm" @confirm="confirmDelete"></van-dialog> @cancel="closeDeleteConfirm" @confirm="confirmDelete"></van-dialog>
<apply-form ref="applyFormRef"></apply-form> <apply-form ref="applyFormRef" @submit-success="handleEditSubmitSuccess"></apply-form>
<info ref="infoRef"></info> <info ref="infoRef"></info>
</div> </div>
@@ -541,6 +541,10 @@ layout("/layouts/platform_h5.html"){
} }
this.$refs.applyFormRef.onOpenEdit(row) this.$refs.applyFormRef.onOpenEdit(row)
}, },
// 编辑提交成功后留在我的作品页面,仅刷新当前列表数据。
handleEditSubmitSuccess() {
this.loadWorks(true)
},
onDelete(row) { onDelete(row) {
this.$set(this, 'pendingDeleteRow', row) this.$set(this, 'pendingDeleteRow', row)
window.h5HistoryLayerManager.open('works-collection-mine-delete-confirm') window.h5HistoryLayerManager.open('works-collection-mine-delete-confirm')
@@ -8,7 +8,7 @@ const INFO = {
<van-sticky> <van-sticky>
<van-nav-bar title="作品详情" left-text="返回" left-arrow @click-left="closeInfo"></van-nav-bar> <van-nav-bar title="作品详情" left-text="返回" left-arrow @click-left="closeInfo"></van-nav-bar>
</van-sticky> </van-sticky>
<div class="works-detail-scroll"> <div ref="detailScrollRef" class="works-detail-scroll">
<section class="works-detail-card"> <section class="works-detail-card">
<h2 class="works-detail-card__title">基础信息</h2> <h2 class="works-detail-card__title">基础信息</h2>
<van-cell-group :border="false"> <van-cell-group :border="false">
@@ -154,6 +154,7 @@ const INFO = {
onOpen(row) { onOpen(row) {
this.$set(this, "viewData", Object.assign({}, row, {files: this.normalizeFiles(row.files)})) this.$set(this, "viewData", Object.assign({}, row, {files: this.normalizeFiles(row.files)}))
window.h5HistoryLayerManager.open("works-collection-mine-info") window.h5HistoryLayerManager.open("works-collection-mine-info")
this.resetScrollPosition()
this.$axios.post("/platform/activity/worksCollection/common/findOne", {id: row.id}).then((res) => { this.$axios.post("/platform/activity/worksCollection/common/findOne", {id: row.id}).then((res) => {
if (res.code === 0) { if (res.code === 0) {
const viewData = res.data || {} const viewData = res.data || {}
@@ -162,6 +163,12 @@ const INFO = {
} }
}) })
}, },
// 弹窗组件会被复用,每次打开后需清除上一次查看时保留的滚动位置。
resetScrollPosition() {
this.$nextTick(() => {
if (this.$refs.detailScrollRef) this.$refs.detailScrollRef.scrollTop = 0
})
},
normalizeFiles(files) { normalizeFiles(files) {
if (Array.isArray(files)) return files if (Array.isArray(files)) return files
if (!files) return [] if (!files) return []
@@ -222,24 +222,30 @@ layout("/layouts/platform_h5.html"){
white-space: nowrap; white-space: nowrap;
} }
.works-read-like-tag { .works-read-like-summary {
display: inline-flex; display: inline-flex;
min-height: 24px; min-height: 24px;
padding: 3px 9px;
align-items: center; align-items: center;
flex: none; flex: none;
border: 0; color: #7f8da1;
border-radius: 12px;
color: #397de0;
background-color: #edf5ff;
font-size: 12px; font-size: 12px;
line-height: 18px; line-height: 18px;
white-space: nowrap;
box-sizing: border-box; box-sizing: border-box;
} }
.works-read-like-tag .van-icon { /* 顶部仅展示累计点赞信息,人员图标与无底色文字用于区别底部可点击的点赞按钮。 */
margin-right: 4px; .works-read-like-summary .van-icon {
font-size: 14px; display: inline-flex;
width: 20px;
height: 20px;
margin-right: 5px;
align-items: center;
justify-content: center;
border-radius: 50%;
color: #ffffff;
background-color: #8795aa;
font-size: 12px;
} }
.works-read-files-row { .works-read-files-row {
@@ -441,8 +447,9 @@ layout("/layouts/platform_h5.html"){
<div class="works-read-card-title">{{row.name || '未命名作品'}}</div> <div class="works-read-card-title">{{row.name || '未命名作品'}}</div>
<div class="works-read-card-activity">{{row.activityName || '暂无活动信息'}}</div> <div class="works-read-card-activity">{{row.activityName || '暂无活动信息'}}</div>
</div> </div>
<div class="works-read-like-tag"> <div class="works-read-like-summary">
<van-icon name="good-job-o"></van-icon>{{row.num || 0}} <van-icon name="friends"></van-icon>
<span>已有 {{row.num || 0}} 人点赞</span>
</div> </div>
</div> </div>
</template> </template>
@@ -10,7 +10,7 @@ const applyForm = {
<van-nav-bar title="作品提交" left-text="返回" left-arrow @click-left="closeApply"></van-nav-bar> <van-nav-bar title="作品提交" left-text="返回" left-arrow @click-left="closeApply"></van-nav-bar>
</van-sticky> </van-sticky>
<van-form ref="formRef" class="works-apply-form form-container"> <van-form ref="formRef" class="works-apply-form form-container">
<div class="works-apply-scroll"> <div ref="applyScrollRef" class="works-apply-scroll">
<section class="works-apply-section"> <section class="works-apply-section">
<h2 class="works-apply-section__title">基础信息</h2> <h2 class="works-apply-section__title">基础信息</h2>
<van-cell-group :border="false"> <van-cell-group :border="false">
@@ -356,6 +356,7 @@ const applyForm = {
showOptionPicker: false, optionPopupType: "", optionPopupTitle: "", showOptionPicker: false, optionPopupType: "", optionPopupTitle: "",
optionPopupOptions: [], pendingOptionValue: "", optionPopupOptions: [], pendingOptionValue: "",
submitConfirmVisible: false, fileAccept: null, chooseWorksType: {}, submitConfirmVisible: false, fileAccept: null, chooseWorksType: {},
submitSuccessMode: "navigate",
historyLayerPageKey: "works-collection-upload-h5", historyLayerPageKey: "works-collection-upload-h5",
historyLayerUnsubscribe: null, ownsHistoryRegistration: false historyLayerUnsubscribe: null, ownsHistoryRegistration: false
} }
@@ -384,16 +385,19 @@ const applyForm = {
}, },
onOpen(row) { onOpen(row) {
this.resetFormData() this.resetFormData()
this.$set(this, "submitSuccessMode", "navigate")
this.$set(this, "row", row) this.$set(this, "row", row)
this.$set(this.formData, "activityId", row.id) this.$set(this.formData, "activityId", row.id)
this.activityChange(row.id).then(() => { this.activityChange(row.id).then(() => {
window.h5HistoryLayerManager.open("works-collection-apply") window.h5HistoryLayerManager.open("works-collection-apply")
this.resetScrollPosition()
}) })
}, },
onOpenEdit(row) { onOpenEdit(row) {
const formData = clone(row) const formData = clone(row)
formData.files = this.normalizeFiles(formData.files) formData.files = this.normalizeFiles(formData.files)
this.resetFormData() this.resetFormData()
this.$set(this, "submitSuccessMode", "close")
this.$set(this, "row", formData) this.$set(this, "row", formData)
this.$set(this, "formData", formData) this.$set(this, "formData", formData)
const user = this.$store.state.user || {} const user = this.$store.state.user || {}
@@ -408,6 +412,13 @@ const applyForm = {
}).then(() => { }).then(() => {
this.setSelectedWorksType(formData.worksId) this.setSelectedWorksType(formData.worksId)
window.h5HistoryLayerManager.open("works-collection-apply") window.h5HistoryLayerManager.open("works-collection-apply")
this.resetScrollPosition()
})
},
// 表单弹窗会被复用,每次打开后需清除上一次编辑或提交时保留的滚动位置。
resetScrollPosition() {
this.$nextTick(() => {
if (this.$refs.applyScrollRef) this.$refs.applyScrollRef.scrollTop = 0
}) })
}, },
// 附件可能由列表 SQL 返回 JSON 字符串,也可能已被序列化为数组;统一转换为上传组件需要的数组。 // 附件可能由列表 SQL 返回 JSON 字符串,也可能已被序列化为数组;统一转换为上传组件需要的数组。
@@ -505,6 +516,13 @@ const applyForm = {
}).then((res) => { }).then((res) => {
if (res.code === 0) { if (res.code === 0) {
this.$toast.success("提交成功") this.$toast.success("提交成功")
if (this.submitSuccessMode === "close") {
// 我的作品页使用弹窗编辑,成功后只关闭当前表单并通知父页面刷新列表。
window.h5HistoryLayerManager.close("works-collection-apply", () => {
this.$emit("submit-success")
})
return
}
window.h5HistoryLayerManager.closeAllAndNavigate("/platform/activity/worksCollection/mine/h5") window.h5HistoryLayerManager.closeAllAndNavigate("/platform/activity/worksCollection/mine/h5")
} }
}).finally(() => { this.$set(this, "formLoading", false) }) }).finally(() => { this.$set(this, "formLoading", false) })
@@ -128,7 +128,7 @@ layout("/layouts/platform_h5.html"){
<van-field v-model="formData.tf_opinion" class="club-audit-field" name="tf_opinion" type="textarea" rows="6" label="" placeholder="请输入审批意见" :rules="[{ required: true, message: '请填写审批意见' }]" maxlength="100" show-word-limit></van-field> <van-field v-model="formData.tf_opinion" class="club-audit-field" name="tf_opinion" type="textarea" rows="6" label="" placeholder="请输入审批意见" :rules="[{ required: true, message: '请填写审批意见' }]" maxlength="100" show-word-limit></van-field>
</van-form> </van-form>
</section> </section>
<div class="club-audit-actions"><van-button :loading="formLoading" type="danger" block @click="handleTaskAction(6)">退回</van-button><van-button :loading="formLoading" type="danger" block @click="handleTaskAction(2)">不同意</van-button><van-button :loading="formLoading" type="primary" block @click="handleTaskAction(1)">同意</van-button></div> <div class="club-audit-actions"><van-button :loading="formLoading" type="primary" block @click="handleTaskAction(1)">同意</van-button><van-button :loading="formLoading" type="danger" block @click="handleTaskAction(2)">拒绝</van-button><van-button :loading="formLoading" type="danger" block @click="handleTaskAction(6)">退回</van-button></div>
</template> </template>
</info> </info>
<van-dialog :value="confirmVisible" title="提示" :message="confirmMessage" show-cancel-button :confirm-button-loading="formLoading" @confirm="confirmPendingAction" @cancel="closeConfirm"></van-dialog> <van-dialog :value="confirmVisible" title="提示" :message="confirmMessage" show-cancel-button :confirm-button-loading="formLoading" @confirm="confirmPendingAction" @cancel="closeConfirm"></van-dialog>
@@ -62,22 +62,48 @@ layout("/layouts/platform_h5.html"){
</template> </template>
</table-list> </table-list>
</main> </main>
<info ref="infoRef" page-key="club-mineclub" :merge-apply-info="true"></info> <van-popup v-model="clubDetailVisible" position="right" :style="{ width: '100%', height: '100%' }" get-container="#app" class="club-user-join-popup" :close-on-click-overlay="false">
<div class="club-user-join-detail">
<van-nav-bar title="社团详情" left-text="返回" left-arrow fixed @click-left="closeClubDetail"></van-nav-bar>
<div class="club-user-join-detail__scroll">
<main class="club-user-join-detail__content">
<section class="club-user-join-card club-user-join-info-card">
<h3 class="club-user-join-card__title">社团基础信息</h3>
<van-cell-group :border="false">
<van-cell title="社团名称">{{ clubDetail.clubName || '--' }}</van-cell>
<van-cell title="成立时间">{{ clubDetail.foundTime || '--' }}</van-cell>
<van-cell title="社团编码">{{ clubDetail.clubCode || '--' }}</van-cell>
<van-cell title="社团类型"><dict-tag :options="dict.type.CLUB_REGISTER_TYPE" :value="clubDetail.clubType"></dict-tag></van-cell>
<van-cell title="会长">{{ clubDetail.clubLeader || '--' }}</van-cell>
<van-cell title="秘书长">{{ clubDetail.clubSecretary || '--' }}</van-cell>
<van-cell title="当前人数">{{ clubDetail.currentPeopleNum || 0 }} 人</van-cell>
<van-cell title="会费标准">{{ clubDetail.due || '--' }}</van-cell>
<van-cell title="社团介绍" class="club-user-join-info-long-cell"><div v-if="hasClubContent(clubDetail.introduce)" v-html="clubDetail.introduce"></div><span v-else>暂无</span></van-cell>
<van-cell title="社团宗旨" class="club-user-join-info-long-cell"><div v-if="hasClubContent(clubDetail.purpose)" v-html="clubDetail.purpose"></div><span v-else>暂无</span></van-cell>
<van-cell title="社团章程" class="club-user-join-info-long-cell"><file-preview v-if="clubDetail.rulesFile && clubDetail.rulesFile.length" :files="clubDetail.rulesFile" complete_result></file-preview><span v-else>暂无</span></van-cell>
<van-cell title="年度活动计划" class="club-user-join-info-long-cell"><file-preview v-if="clubDetail.yearPlanFile && clubDetail.yearPlanFile.length" :files="clubDetail.yearPlanFile" complete_result></file-preview><span v-else>暂无</span></van-cell>
</van-cell-group>
</section>
</main>
</div>
</div>
</van-popup>
<van-dialog :value="confirmVisible" title="提示" :message="confirmMessage" show-cancel-button :confirm-button-loading="formLoading" @confirm="confirmExit" @cancel="closeConfirm"></van-dialog> <van-dialog :value="confirmVisible" title="提示" :message="confirmMessage" show-cancel-button :confirm-button-loading="formLoading" @confirm="confirmExit" @cancel="closeConfirm"></van-dialog>
</div> </div>
<script nonce="${cspNonce!}"> <script nonce="${cspNonce!}">
<!--#include('../common/clubUserJoin.js'){}#-->
new Vue({ new Vue({
el: "#app", store, components: {info: clubUserJoin}, el: "#app", store, dicts: ['CLUB_REGISTER_TYPE'],
data() { return {pageForm: {pageNumber: 1, pageSize: 10, totalCount: 0, clubName: ''}, listLoading: true, formLoading: false, confirmVisible: false, currentRow: null, historyLayerUnsubscribe: null} }, data() { return {pageForm: {pageNumber: 1, pageSize: 10, totalCount: 0, clubName: ''}, listLoading: true, formLoading: false, clubDetailVisible: false, clubDetail: {}, confirmVisible: false, currentRow: null, historyLayerUnsubscribe: null} },
computed: { confirmMessage() { return this.currentRow ? '您确定要申请退出“' + this.currentRow.clubName + '”吗?' : '您确定要申请退出该社团吗?'; } }, computed: { confirmMessage() { return this.currentRow ? '您确定要申请退出“' + this.currentRow.clubName + '”吗?' : '您确定要申请退出该社团吗?'; } },
created() { const manager = window.h5HistoryLayerManager; if (manager) { manager.ensureRegistered('club-mineclub'); this.historyLayerUnsubscribe = manager.subscribe((layers) => { this.$set(this, 'confirmVisible', layers.includes('club-mineclub-confirm')); }); } }, created() { const manager = window.h5HistoryLayerManager; if (manager) { manager.ensureRegistered('club-mineclub'); this.historyLayerUnsubscribe = manager.subscribe((layers) => { this.$set(this, 'clubDetailVisible', layers.includes('club-mineclub-detail')); this.$set(this, 'confirmVisible', layers.includes('club-mineclub-confirm')); }); } },
beforeDestroy() { if (this.historyLayerUnsubscribe) { this.historyLayerUnsubscribe(); } const manager = window.h5HistoryLayerManager; if (manager) { manager.unregister('club-mineclub'); } }, beforeDestroy() { if (this.historyLayerUnsubscribe) { this.historyLayerUnsubscribe(); } const manager = window.h5HistoryLayerManager; if (manager) { manager.unregister('club-mineclub'); } },
methods: { methods: {
onNavBack() { const manager = window.h5HistoryLayerManager; if (manager && manager.stack.length) { manager.close(); return; } historyBack(); }, onNavBack() { const manager = window.h5HistoryLayerManager; if (manager && manager.stack.length) { manager.close(); return; } historyBack(); },
onLoadingChange(state) { this.$set(this, 'listLoading', state.loading); }, onLoadingChange(state) { this.$set(this, 'listLoading', state.loading); },
onView(row) { this.$refs.infoRef.onOpen(Object.assign({}, row, {id: row.applyId}), '社团申请详情'); }, onView(row) { this.$set(this, 'clubDetail', Object.assign({}, row || {})); const manager = window.h5HistoryLayerManager; if (manager) { manager.open('club-mineclub-detail'); return; } this.$set(this, 'clubDetailVisible', true); },
closeClubDetail(afterClose) { const manager = window.h5HistoryLayerManager; if (manager) { manager.close('club-mineclub-detail', afterClose); return; } this.$set(this, 'clubDetailVisible', false); if (afterClose) { afterClose(); } },
hasClubContent(value) { return value !== null && value !== undefined && String(value).replace(/<[^>]*>/g, '').replace(/&nbsp;/gi, '').trim().length > 0; },
onExit(row) { this.$set(this, 'currentRow', row); const manager = window.h5HistoryLayerManager; if (manager) { manager.open('club-mineclub-confirm'); return; } this.$set(this, 'confirmVisible', true); }, onExit(row) { this.$set(this, 'currentRow', row); const manager = window.h5HistoryLayerManager; if (manager) { manager.open('club-mineclub-confirm'); return; } this.$set(this, 'confirmVisible', true); },
closeConfirm(afterClose) { const manager = window.h5HistoryLayerManager; if (manager) { manager.close('club-mineclub-confirm', afterClose); return; } this.$set(this, 'confirmVisible', false); if (afterClose) { afterClose(); } }, closeConfirm(afterClose) { const manager = window.h5HistoryLayerManager; if (manager) { manager.close('club-mineclub-confirm', afterClose); return; } this.$set(this, 'confirmVisible', false); if (afterClose) { afterClose(); } },
confirmExit() { const row = this.currentRow; this.closeConfirm(() => { this.$set(this, 'formLoading', true); this.$axios.post('/platform/club/join/apply/submit', {data: JSON.stringify(row), mode: false}).then((res) => { if (res.code === 0) { this.$toast(res.msg); const manager = window.h5HistoryLayerManager; if (manager) { manager.closeAll(() => { this.$pjaxReplace('/platform/club/join/mine/h5'); }); } else { this.$pjaxReplace('/platform/club/join/mine/h5'); } } }).finally(() => { this.$set(this, 'formLoading', false); }); }); }, confirmExit() { const row = this.currentRow; this.closeConfirm(() => { this.$set(this, 'formLoading', true); this.$axios.post('/platform/club/join/apply/submit', {data: JSON.stringify(row), mode: false}).then((res) => { if (res.code === 0) { this.$toast(res.msg); const manager = window.h5HistoryLayerManager; if (manager) { manager.closeAll(() => { this.$pjaxReplace('/platform/club/join/mine/h5'); }); } else { this.$pjaxReplace('/platform/club/join/mine/h5'); } } }).finally(() => { this.$set(this, 'formLoading', false); }); }); },
@@ -579,9 +579,6 @@ layout("/layouts/platform_h5.html"){
</van-action-sheet> </van-action-sheet>
<van-field label="性别" v-model="formData.condolenceSex" readonly placeholder="自动带出"></van-field> <van-field label="性别" v-model="formData.condolenceSex" readonly placeholder="自动带出"></van-field>
<van-field label="生日" :value="formatDateText(formData.condolenceBirthday)" readonly placeholder="自动带出"></van-field>
<van-field label="身份证号" v-model="formData.condolenceIdCard" readonly placeholder="自动带出"></van-field>
<van-field label="联系方式" v-model="formData.condolenceMobile" readonly placeholder="自动带出"></van-field>
<van-field <van-field
v-model="formData.typeName" v-model="formData.typeName"
@@ -805,8 +802,13 @@ layout("/layouts/platform_h5.html"){
</van-popup> </van-popup>
</template> </template>
<van-field class="more-text" name="files" required </van-cell-group>
label="附件" :rules="[{ required: true, message: '请上传附件' }]"> </section>
<section class="form-section">
<h2 class="form-section__title">附件信息</h2>
<van-cell-group :border="false">
<van-field class="more-text reimburse-upload-field" name="files" required
label="" :rules="[{ required: true, message: '请上传附件' }]">
<template #input> <template #input>
<h5-file-upload <h5-file-upload
slot="input" slot="input"
@@ -1003,7 +1005,7 @@ layout("/layouts/platform_h5.html"){
</div> </div>
</van-form> </van-form>
</div> </div>
<van-popup v-model:show="invoiceDialogVisible" position="bottom" round :close-on-click-overlay="true" class="invoice-popup"> <van-popup v-model:show="invoiceDialogVisible" position="bottom" round :close-on-click-overlay="true" get-container="#app" class="invoice-popup">
<van-nav-bar :title="invoiceDialogMode === 'edit' ? '编辑发票明细' : '新增发票明细'" <van-nav-bar :title="invoiceDialogMode === 'edit' ? '编辑发票明细' : '新增发票明细'"
left-text="取消" left-text="取消"
left-arrow left-arrow
@@ -1013,7 +1015,7 @@ layout("/layouts/platform_h5.html"){
<van-loading type="spinner" color="#1677ff" size="24px">发票识别中...</van-loading> <van-loading type="spinner" color="#1677ff" size="24px">发票识别中...</van-loading>
</div> </div>
<van-cell-group class="invoice-popup-card"> <van-cell-group class="invoice-popup-card">
<van-field class="more-text" label="发票文件" required> <van-field class="more-text invoice-popup-file-field" label="发票文件" required>
<template #input> <template #input>
<h5-file-upload <h5-file-upload
slot="input" slot="input"
@@ -1407,7 +1409,7 @@ layout("/layouts/platform_h5.html"){
}, },
normalizeContactFields() { normalizeContactFields() {
// 提交前裁剪联系方式空格,避免系统手机号带空格时后端格式校验失败。 // 提交前裁剪联系方式空格,避免系统手机号带空格时后端格式校验失败。
const fields = ["mobile", "condolenceMobile"] const fields = ["mobile"]
fields.forEach((fieldName) => { fields.forEach((fieldName) => {
if (this.formData[fieldName]) { if (this.formData[fieldName]) {
this.$set(this.formData, fieldName, String(this.formData[fieldName]).trim()) this.$set(this.formData, fieldName, String(this.formData[fieldName]).trim())
@@ -2204,7 +2206,6 @@ layout("/layouts/platform_h5.html"){
const selectedUser = this.condolenceOptions.find(user => user.id === o.value) const selectedUser = this.condolenceOptions.find(user => user.id === o.value)
if (selectedUser) { if (selectedUser) {
this.fillCondolenceUser(selectedUser) this.fillCondolenceUser(selectedUser)
this.$set(this.formData, "condolenceMobile", selectedUser.mobile)
} }
}, },
onCondolenceTypeConfirm(o) { onCondolenceTypeConfirm(o) {
@@ -2336,9 +2337,6 @@ layout("/layouts/platform_h5.html"){
this.$set(this.formData, "condolenceUnionName", user.unionName) this.$set(this.formData, "condolenceUnionName", user.unionName)
this.$set(this.formData, "condolenceUnionCode", user.unionCode) this.$set(this.formData, "condolenceUnionCode", user.unionCode)
this.$set(this.formData, "condolenceSex", user.sex) this.$set(this.formData, "condolenceSex", user.sex)
this.$set(this.formData, "condolenceBirthday", user.birthday)
this.$set(this.formData, "condolenceIdCard", user.idCard)
this.$set(this.formData, "condolenceMobile", user.mobile)
}, },
// 查询慰问类型 // 查询慰问类型
queryCondolenceType() { queryCondolenceType() {
@@ -143,7 +143,7 @@ layout("/layouts/platform_h5.html"){
return isNaN(amount) ? value : amount.toFixed(2) + "元" return isNaN(amount) ? value : amount.toFixed(2) + "元"
}, },
onView(row) { onView(row) {
this.$refs.unionReimburseInfoRef.onOpen(row, "报销统计详情") this.$refs.unionReimburseInfoRef.onOpen(row, "报销详情")
}, },
onEdit(row) { onEdit(row) {
this.$pjaxReplace("/platform/unionReimburse/apply/h5?bizId=" + row.id + "&taskId=" + row.startTaskId) this.$pjaxReplace("/platform/unionReimburse/apply/h5?bizId=" + row.id + "&taskId=" + row.startTaskId)
@@ -258,6 +258,15 @@
background: #f7f9fd; background: #f7f9fd;
} }
/* 附件上传区域与困难帮扶申请页保持一致,避免上传控件被文本域样式包裹。 */
.union-reimburse-form .form-section .reimburse-upload-field .van-field__control {
min-height: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.union-reimburse-form .payment-section, .union-reimburse-form .payment-section,
.union-reimburse-form .invoice-section { padding: 0 0 14px; } .union-reimburse-form .invoice-section { padding: 0 0 14px; }
.union-reimburse-form .payment-toolbar, .union-reimburse-form .payment-toolbar,
@@ -388,6 +397,15 @@
border-radius: 10px; border-radius: 10px;
background: #f7f9fd; background: #f7f9fd;
} }
/* 发票文件只保留上传控件本身,移除报销文本域的边框、背景和内边距。 */
.union-reimburse-form .invoice-popup-card .more-text.invoice-popup-file-field .van-field__control {
min-height: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.union-reimburse-form .invoice-popup-tip { .union-reimburse-form .invoice-popup-tip {
margin: 10px 0 0; margin: 10px 0 0;
padding: 9px 11px; padding: 9px 11px;
@@ -15,7 +15,7 @@ const UNION_REIMBURSE_INFO = {
</div> </div>
<div class="union-info-row"> <div class="union-info-row">
<div class="union-info-label">经办人</div> <div class="union-info-label">经办人</div>
<div class="union-info-value union-info-link">{{ viewData.userName }}</div> <div class="union-info-value">{{ viewData.userName }}</div>
</div> </div>
<div class="union-info-row"> <div class="union-info-row">
<div class="union-info-label">联系方式</div> <div class="union-info-label">联系方式</div>
@@ -100,18 +100,6 @@ const UNION_REIMBURSE_INFO = {
<div class="union-info-label">性别</div> <div class="union-info-label">性别</div>
<div class="union-info-value">{{ viewData.condolenceSex }}</div> <div class="union-info-value">{{ viewData.condolenceSex }}</div>
</div> </div>
<div class="union-info-row">
<div class="union-info-label">生日</div>
<div class="union-info-value">{{ formatDateText(viewData.condolenceBirthday) }}</div>
</div>
<div class="union-info-row">
<div class="union-info-label">身份证号</div>
<div class="union-info-value">{{ viewData.condolenceIdCard }}</div>
</div>
<div class="union-info-row">
<div class="union-info-label">联系方式</div>
<div class="union-info-value">{{ viewData.condolenceMobile }}</div>
</div>
<div class="union-info-row"> <div class="union-info-row">
<div class="union-info-label">慰问类型</div> <div class="union-info-label">慰问类型</div>
<div class="union-info-value">{{ viewData.typeName || viewData.condolenceTypeName }}</div> <div class="union-info-value">{{ viewData.typeName || viewData.condolenceTypeName }}</div>
@@ -186,7 +174,7 @@ const UNION_REIMBURSE_INFO = {
<div class="union-info-section-title">活动信息</div> <div class="union-info-section-title">活动信息</div>
<div class="union-info-row"> <div class="union-info-row">
<div class="union-info-label">活动名称</div> <div class="union-info-label">活动名称</div>
<div class="union-info-value union-info-link">{{ viewData.activityName }}</div> <div class="union-info-value">{{ viewData.activityName }}</div>
</div> </div>
<div class="union-info-row"> <div class="union-info-row">
<div class="union-info-label">活动人数</div> <div class="union-info-label">活动人数</div>
@@ -346,17 +334,6 @@ const UNION_REIMBURSE_INFO = {
return this.viewData.paymentDetails || [] return this.viewData.paymentDetails || []
} }
}, },
watch: {
visible(value) {
const manager = window.h5HistoryLayerManager
if (!manager) return
if (value) {
manager.open(this.historyLayerName)
return
}
manager.close(this.historyLayerName)
}
},
mounted() { mounted() {
this.injectInfoStyle() this.injectInfoStyle()
const manager = window.h5HistoryLayerManager const manager = window.h5HistoryLayerManager
@@ -386,12 +363,19 @@ const UNION_REIMBURSE_INFO = {
onOpen(row, title) { onOpen(row, title) {
this.$set(this, "row", row) this.$set(this, "row", row)
this.$set(this, "detailTitle", title || "报销详情") this.$set(this, "detailTitle", title || "报销详情")
this.$set(this, "visible", true)
this.$set(this, "currentPaymentIndex", 0) this.$set(this, "currentPaymentIndex", 0)
// 详情弹层的显示状态由全局历史栈统一同步,确保系统返回键与页面返回按钮关闭同一层。
const manager = window.h5HistoryLayerManager
if (manager && manager.open(this.historyLayerName)) {
this.getInfo()
this.getDoneTasks()
return
}
this.$set(this, "visible", true)
this.getInfo() this.getInfo()
this.getDoneTasks() this.getDoneTasks()
}, },
// 由全局历史栈关闭详情,保证遮罩、关闭按钮和浏览器返回键遵循同一顺序 // 只关闭当前详情对应的历史层,避免误关闭审核确认弹框等上层业务弹框
onClose() { onClose() {
const manager = window.h5HistoryLayerManager const manager = window.h5HistoryLayerManager
if (manager && manager.close(this.historyLayerName)) return if (manager && manager.close(this.historyLayerName)) return
@@ -144,7 +144,7 @@ layout("/layouts/platform_h5.html"){
return value && this.$moment(value).isValid() ? this.$moment(value).format("YYYY-MM-DD HH:mm:ss") : "" return value && this.$moment(value).isValid() ? this.$moment(value).format("YYYY-MM-DD HH:mm:ss") : ""
}, },
onView(row) { onView(row) {
this.$refs.unionReimburseInfoRef.onOpen(row, "报销申请详情") this.$refs.unionReimburseInfoRef.onOpen(row, "报销详情")
}, },
onEdit(row) { onEdit(row) {
this.$pjaxReplace('/platform/unionReimburse/apply/h5?taskId=' + (row.startTaskId || '') + "&bizId=" + row.id) this.$pjaxReplace('/platform/unionReimburse/apply/h5?taskId=' + (row.startTaskId || '') + "&bizId=" + row.id)
@@ -166,7 +166,7 @@ layout("/layouts/platform_h5.html"){
submitTaskAction() { const submitType = this.pendingSubmitType; this.closeSubmitConfirm(() => { this.$set(this, "formLoading", true); this.$axios.post("/platform/unionReimburse/review/reviewTask", {data: JSON.stringify(Object.assign({}, this.formData)), submitType: submitType}).then((res) => { if (res.code === 0) { this.$toast.success("提交成功"); this.doSearch(); this.$refs.unionReimburseInfoRef.onClose() } else { this.$toast.fail(res.msg) } }).finally(() => { this.$set(this, "formLoading", false) }) }) }, submitTaskAction() { const submitType = this.pendingSubmitType; this.closeSubmitConfirm(() => { this.$set(this, "formLoading", true); this.$axios.post("/platform/unionReimburse/review/reviewTask", {data: JSON.stringify(Object.assign({}, this.formData)), submitType: submitType}).then((res) => { if (res.code === 0) { this.$toast.success("提交成功"); this.doSearch(); this.$refs.unionReimburseInfoRef.onClose() } else { this.$toast.fail(res.msg) } }).finally(() => { this.$set(this, "formLoading", false) }) }) },
onView(row) { onView(row) {
this.$set(this, "showApprovalForm", false) this.$set(this, "showApprovalForm", false)
this.$refs.unionReimburseInfoRef.onOpen(row, "报销申请详情") this.$refs.unionReimburseInfoRef.onOpen(row, "报销详情")
}, },
onAudit(row) { onAudit(row) {
this.$set(this, "showApprovalForm", true) this.$set(this, "showApprovalForm", true)
@@ -82,6 +82,22 @@
background-color: #ff976a; background-color: #ff976a;
} }
/* 校工会预审核四个操作按钮与 PC 端 Element UI 的 primary、danger、info 配色保持一致。 */
.proposal-pre-audit-actions .proposal-pre-audit-button--primary {
border-color: var(--color-primary);
background-color: var(--color-primary);
}
.proposal-pre-audit-actions .proposal-pre-audit-button--danger {
border-color: #f56c6c;
background-color: #f56c6c;
}
.proposal-pre-audit-actions .proposal-pre-audit-button--info {
border-color: #909399;
background-color: #909399;
}
.proposal-soft-pagination .van-pagination__item { .proposal-soft-pagination .van-pagination__item {
color: var(--proposal-soft-primary); color: var(--proposal-soft-primary);
background-color: #f8fbff; background-color: #f8fbff;
@@ -280,11 +296,18 @@
.proposal-view-card .van-cell__title { .proposal-view-card .van-cell__title {
flex: 0 0 92px; flex: 0 0 92px;
width: 92px; width: 92px;
color: #596779; color: #354255;
font-size: 14px; font-size: 14px;
font-weight: 600;
line-height: 24px; line-height: 24px;
} }
/* 纵向字段中 flex-basis 会转为标题高度,需要取消横向字段使用的 92px 固定占位。 */
.proposal-view-card .direction-column-field .van-cell__title {
flex: none;
width: auto;
}
.proposal-view-card .van-cell__value { .proposal-view-card .van-cell__value {
min-width: 0; min-width: 0;
color: #4f5d70; color: #4f5d70;
@@ -495,7 +518,7 @@
.proposal-view-table-wrap .el-table { .proposal-view-table-wrap .el-table {
min-width: 560px; min-width: 560px;
font-size: 16px; font-size: 14px;
} }
.proposal-view-slot:empty { .proposal-view-slot:empty {
@@ -19,7 +19,8 @@ const PROPOSAL_INFO = {
:value="viewData.caseFilingType"></dict-tag> :value="viewData.caseFilingType"></dict-tag>
</van-cell> </van-cell>
<van-cell title="提案类型">{{ viewData.typeName || '--' }}</van-cell> <van-cell title="提案类型">{{ viewData.typeName || '--' }}</van-cell>
<van-cell title="提案方式">{{ getDictText(dict.type.PROPOSAL_SOURCE, viewData.source) }}</van-cell> <van-cell title="提案方式">{{ getDictText(dict.type.PROPOSAL_SOURCE, viewData.source) }}
</van-cell>
<van-cell title="代表姓名">{{ viewData.createUserName || '--' }}</van-cell> <van-cell title="代表姓名">{{ viewData.createUserName || '--' }}</van-cell>
<van-cell title="所属教代会">{{ viewData.sessionName || viewData.fullName || '--' }}</van-cell> <van-cell title="所属教代会">{{ viewData.sessionName || viewData.fullName || '--' }}</van-cell>
<van-cell :title="viewData.committeeName ? '所属委员会' : '所属代表团'"> <van-cell :title="viewData.committeeName ? '所属委员会' : '所属代表团'">
@@ -28,7 +29,7 @@ const PROPOSAL_INFO = {
<van-cell title="单位">{{ viewData.unitName || '--' }}</van-cell> <van-cell title="单位">{{ viewData.unitName || '--' }}</van-cell>
<van-cell title="联系电话">{{ viewData.mobile || '--' }}</van-cell> <van-cell title="联系电话">{{ viewData.mobile || '--' }}</van-cell>
<van-cell title="提案时间">{{ viewData.createTime || '--' }}</van-cell> <van-cell title="提案时间">{{ viewData.createTime || '--' }}</van-cell>
<van-cell title="提案案由" class="proposal-view-long-cell"> <van-cell title="案由" class="proposal-view-long-cell">
<div class="proposal-view-rich-value" v-html="viewData.brief || '--'"></div> <div class="proposal-view-rich-value" v-html="viewData.brief || '--'"></div>
</van-cell> </van-cell>
<van-cell title="建议措施" class="proposal-view-long-cell"> <van-cell title="建议措施" class="proposal-view-long-cell">
@@ -62,7 +63,9 @@ const PROPOSAL_INFO = {
<h2 class="proposal-view-card__title">委员会成员意见 {{ index + 1 }}</h2> <h2 class="proposal-view-card__title">委员会成员意见 {{ index + 1 }}</h2>
<van-cell-group :border="false"> <van-cell-group :border="false">
<van-cell title="委员"> <van-cell title="委员">
{{ opinion.commissionerName || '--' }}<template v-if="opinion.commissionerLoginName">{{ opinion.commissionerLoginName }}</template> {{ opinion.commissionerName || '--' }}
<template v-if="opinion.commissionerLoginName">{{ opinion.commissionerLoginName }}
</template>
</van-cell> </van-cell>
<van-cell title="立案结果"> <van-cell title="立案结果">
<dict-tag :options="dict.type.PROPOSAL_CASE_FILING_RESULT" <dict-tag :options="dict.type.PROPOSAL_CASE_FILING_RESULT"
@@ -109,12 +112,12 @@ const PROPOSAL_INFO = {
<!-- 点击时间轴节点后只在此处展示该任务原有的完整详情 --> <!-- 点击时间轴节点后只在此处展示该任务原有的完整详情 -->
<section class="proposal-view-card proposal-flow-detail" v-if="selectedTask"> <section class="proposal-view-card proposal-flow-detail" v-if="selectedTask">
<h2 class="proposal-view-card__title">节点详情</h2> <h2 class="proposal-view-card__title">{{ selectedTask.displayName || '--' }}</h2>
<h3 class="proposal-flow-detail__name">{{ selectedTask.displayName || '--' }}</h3>
<van-cell-group :border="false"> <van-cell-group :border="false">
<template v-if="selectedTask.ext && selectedTask.ext.isFirstTaskNode"> <template v-if="selectedTask.ext && selectedTask.ext.isFirstTaskNode">
<van-cell title="申请用户"> <van-cell title="申请用户">
{{ selectedTask.ext.initiatorName || '--' }}{{ selectedTask.ext.initiatorAccount || '--' }} {{ selectedTask.ext.initiatorName || '--' }}{{ selectedTask.ext.initiatorAccount ||
'--' }}
</van-cell> </van-cell>
<van-cell title="申请时间">{{ selectedTask.finishTime || '--' }}</van-cell> <van-cell title="申请时间">{{ selectedTask.finishTime || '--' }}</van-cell>
<van-cell title="办理结果"> <van-cell title="办理结果">
@@ -125,26 +128,35 @@ const PROPOSAL_INFO = {
<template v-else-if="selectedTask.taskName === 'invite'"> <template v-else-if="selectedTask.taskName === 'invite'">
<van-cell title="办理用户"> <van-cell title="办理用户">
{{ selectedTask.taskFormData.userName || '--' }}{{ selectedTask.taskFormData.loginName || '--' }} {{ selectedTask.taskFormData.userName || '--' }}{{ selectedTask.taskFormData.loginName
|| '--' }}
</van-cell> </van-cell>
<van-cell title="办理时间">{{ selectedTask.finishTime || '--' }}</van-cell> <van-cell title="办理时间">{{ selectedTask.finishTime || '--' }}</van-cell>
<van-cell title="附议人" class="proposal-view-long-cell"> <van-cell title="附议人" class="proposal-view-long-cell">
<div class="proposal-view-table-wrap"> <div class="proposal-view-table-wrap">
<el-table :data="selectedTask?.taskFormData?.seconder"> <el-table :data="selectedTask?.taskFormData?.seconder">
<el-table-column label="姓名" prop="userName"></el-table-column> <el-table-column label="姓名" prop="userName" width="80px"
<el-table-column label="工号" prop="loginName"></el-table-column> header-align="center" align="center"></el-table-column>
<el-table-column label="性别" prop="sex"></el-table-column> <el-table-column label="工号" prop="loginName" width="80px"
<el-table-column label="单位" prop="unitName" show-overflow-tooltip></el-table-column> header-align="center" align="center"></el-table-column>
<el-table-column label="分工会" prop="unionName" show-overflow-tooltip></el-table-column> <el-table-column label="性别" prop="sex" width="50px" header-align="center"
<el-table-column label="代表团" prop="delegationName" show-overflow-tooltip></el-table-column> align="center"></el-table-column>
<el-table-column label="单位" prop="unitName"
show-overflow-tooltip header-align="center"
align="center"></el-table-column>
<el-table-column label="代表团" prop="delegationName"
show-overflow-tooltip header-align="center"
align="center"></el-table-column>
</el-table> </el-table>
</div> </div>
</van-cell> </van-cell>
</template> </template>
<template v-else-if="selectedTask.taskName === 'committee' || selectedTask.taskName === 'committeeFilingUnit'"> <template
v-else-if="selectedTask.taskName === 'committee' || selectedTask.taskName === 'committeeFilingUnit'">
<van-cell title="办理用户"> <van-cell title="办理用户">
{{ selectedTask.taskFormData.userName || '--' }}{{ selectedTask.taskFormData.loginName || '--' }} {{ selectedTask.taskFormData.userName || '--' }}{{ selectedTask.taskFormData.loginName
|| '--' }}
</van-cell> </van-cell>
<van-cell title="办理时间">{{ selectedTask.finishTime || '--' }}</van-cell> <van-cell title="办理时间">{{ selectedTask.finishTime || '--' }}</van-cell>
<van-cell title="立案结果"> <van-cell title="立案结果">
@@ -155,19 +167,23 @@ const PROPOSAL_INFO = {
<dict-tag :options="dict.type.PROPOSAL_CASE_FILING_TYPE" <dict-tag :options="dict.type.PROPOSAL_CASE_FILING_TYPE"
:value="selectedTask.ext.tf_caseFilingType"></dict-tag> :value="selectedTask.ext.tf_caseFilingType"></dict-tag>
</van-cell> </van-cell>
<template v-if="['CONFIRM_FILING', 'SUGGESTION'].includes(selectedTask.ext.tf_caseFilingResult)"> <template
v-if="['CONFIRM_FILING', 'SUGGESTION'].includes(selectedTask.ext.tf_caseFilingResult)">
<van-cell v-if="selectedTask.ext.tf_caseFilingResult === 'SUGGESTION'" title="承办单位"> <van-cell v-if="selectedTask.ext.tf_caseFilingResult === 'SUGGESTION'" title="承办单位">
{{ selectedTask?.ext?.tf_masterUnitNameStr || selectedTask?.ext?.tf_slaveUnitNameStr || '--' }} {{ selectedTask?.ext?.tf_masterUnitNameStr || selectedTask?.ext?.tf_slaveUnitNameStr
|| '--' }}
</van-cell> </van-cell>
<template v-else> <template v-else>
<van-cell title="主办单位">{{ selectedTask?.ext?.tf_masterUnitNameStr || '--' }}</van-cell> <van-cell title="主办单位">{{ selectedTask?.ext?.tf_masterUnitNameStr || '--' }}
<van-cell title="协办单位">{{ selectedTask?.ext?.tf_slaveUnitNameStr || '--' }}</van-cell> </van-cell>
<van-cell title="协办单位">{{ selectedTask?.ext?.tf_slaveUnitNameStr || '--' }}
</van-cell>
</template> </template>
</template> </template>
<van-cell title="办理意见" <van-cell title="办理意见"
v-if="selectedTask.taskFormData.tf_opinion" v-if="selectedTask.taskFormData.tf_opinion">
class="proposal-view-long-cell"> <div class="proposal-view-rich-value"
<div class="proposal-view-rich-value" v-html="selectedTask.taskFormData.tf_opinion"></div> v-html="selectedTask.taskFormData.tf_opinion"></div>
</van-cell> </van-cell>
</template> </template>
@@ -177,7 +193,8 @@ const PROPOSAL_INFO = {
{{ selectedTask.ext.underTakeName || '--' }} {{ selectedTask.ext.underTakeName || '--' }}
</van-cell> </van-cell>
<van-cell title="办理用户"> <van-cell title="办理用户">
{{ selectedTask.taskFormData.userName || '--' }}{{ selectedTask.taskFormData.loginName || '--' }} {{ selectedTask.taskFormData.userName || '--' }}{{ selectedTask.taskFormData.loginName
|| '--' }}
</van-cell> </van-cell>
<van-cell title="办理时间">{{ selectedTask.finishTime || '--' }}</van-cell> <van-cell title="办理时间">{{ selectedTask.finishTime || '--' }}</van-cell>
<van-cell title="办理评价" v-if="selectedTask.taskName === 'feedback'"> <van-cell title="办理评价" v-if="selectedTask.taskName === 'feedback'">
@@ -189,9 +206,9 @@ const PROPOSAL_INFO = {
:value="selectedTask.ext.submitType"></dict-tag> :value="selectedTask.ext.submitType"></dict-tag>
</van-cell> </van-cell>
<van-cell title="办理意见" <van-cell title="办理意见"
v-if="selectedTask.taskFormData && selectedTask.taskFormData.tf_opinion" v-if="selectedTask.taskFormData && selectedTask.taskFormData.tf_opinion">
class="proposal-view-long-cell"> <div class="proposal-view-rich-value"
<div class="proposal-view-rich-value" v-html="selectedTask.taskFormData.tf_opinion"></div> v-html="selectedTask.taskFormData.tf_opinion"></div>
</van-cell> </van-cell>
</template> </template>
</van-cell-group> </van-cell-group>
@@ -1,114 +1,389 @@
const PROPOSAL_TASK_POPUP = { const PROPOSAL_TASK_POPUP = {
template: /*language=HTML*/ ` template: /*language=HTML*/ `
<van-popup v-model="visible" position="right" class="proposal-full-popup" :close-on-click-overlay="false" get-container="#app"> <van-popup v-model="visible" position="right" class="proposal-full-popup" :close-on-click-overlay="false"
get-container="#app">
<div class="proposal-full-popup__page"> <div class="proposal-full-popup__page">
<van-nav-bar @click-left="close" left-arrow left-text="返回" :title="title" placeholder fixed></van-nav-bar> <van-nav-bar @click-left="close" left-arrow left-text="返回" :title="title" placeholder
fixed></van-nav-bar>
<div class="proposal-full-popup__scroll"> <div class="proposal-full-popup__scroll">
<proposal-info v-if="bizId" :biz-id="bizId" :visible="visible"> <proposal-info v-if="bizId" :biz-id="bizId" :visible="visible">
<div v-if="isApproval"> <div v-if="isApproval">
<van-form ref="formRef"> <van-form ref="formRef">
<template v-if="kind === 'delegation' || kind === 'preAudit' || kind === 'schoolLeaderApproval'"> <template
v-if="kind === 'delegation' || kind === 'preAudit' || kind === 'schoolLeaderApproval'">
<section class="proposal-view-card"> <section class="proposal-view-card">
<h2 class="proposal-view-card__title">审批意见<span class="proposal-audit-card__required">*</span></h2> <h2 class="proposal-view-card__title">审批意见<span
<van-field v-model="formData.tf_opinion" class="proposal-audit-field" name="tf_opinion" type="textarea" rows="6" label="" placeholder="请输入审批意见" :rules="[{required:true,message:'请填写审批意见'}]" required maxlength="100" show-word-limit></van-field> class="proposal-audit-card__required">*</span></h2>
<van-field v-model="formData.tf_opinion" class="proposal-audit-field"
name="tf_opinion" type="textarea" rows="6" label=""
placeholder="请输入审批意见"
:rules="[{required:true,message:'请填写审批意见'}]" required
maxlength="100" show-word-limit></van-field>
</section> </section>
</template> </template>
<template v-if="kind === 'feedbackEvaluation'"> <template v-if="kind === 'feedbackEvaluation'">
<van-field v-model="formData.tf_feedback_name" label="满意度" placeholder="请选择满意度" :rules="[{required:true,message:'请选择满意度'}]" required readonly is-link @click="openLayer('feedback-picker')"></van-field>
<section class="proposal-view-card"> <section class="proposal-view-card">
<h2 class="proposal-view-card__title">审批意见<span class="proposal-audit-card__required">*</span></h2>
<van-field v-model="formData.tf_opinion" class="proposal-audit-field" name="tf_opinion" type="textarea" rows="6" label="" placeholder="请输入审批意见" :rules="[{required:true,message:'请填写审批意见'}]" required maxlength="100" show-word-limit></van-field> <h2 class="proposal-view-card__title">反馈评价<span
class="proposal-audit-card__required">*</span></h2>
<van-field v-model="formData.tf_feedback_name" label="满意度"
placeholder="请选择满意度"
:rules="[{required:true,message:'请选择满意度'}]" required readonly
is-link @click="openLayer('feedback-picker')"></van-field>
<van-field v-model="formData.tf_opinion" class="proposal-audit-field"
name="tf_opinion" type="textarea" rows="6" label=""
placeholder="请输入反馈意见"
:rules="[{required:true,message:'请填写反馈意见'}]"
maxlength="100" show-word-limit></van-field>
</section> </section>
</template> </template>
<template v-if="kind === 'suggestion'"> <template v-if="kind === 'suggestion'">
<van-field v-model="formData.result" label="承办意向" placeholder="请选择承办意向" :rules="[{required:true,message:'请选择承办意向'}]" required readonly is-link @click="openLayer('result-picker')"></van-field>
<section class="proposal-view-card"> <section class="proposal-view-card">
<h2 class="proposal-view-card__title">反馈意见<span class="proposal-audit-card__required">*</span></h2> <van-field v-model="formData.result" label="承办意向"
<van-field v-model="formData.opinion" class="proposal-audit-field" name="opinion" type="textarea" rows="6" label="" placeholder="请输入反馈意见" :rules="[{required:true,message:'请输入反馈意见'}]" required show-word-limit></van-field> placeholder="请选择承办意向"
:rules="[{required:true,message:'请选择承办意向'}]" required readonly
is-link @click="openLayer('result-picker')"></van-field>
<h2 class="proposal-view-card__title">反馈意见<span
class="proposal-audit-card__required">*</span></h2>
<van-field v-model="formData.opinion" class="proposal-audit-field"
name="opinion" type="textarea" rows="6" label=""
placeholder="请输入反馈意见"
:rules="[{required:true,message:'请输入反馈意见'}]" required
show-word-limit></van-field>
</section> </section>
</template> </template>
<template v-if="kind === 'unitReply'"> <template v-if="kind === 'unitReply'">
<section class="proposal-view-card"> <section class="proposal-view-card">
<van-field :value="formData.underTakeName" label="承办单位" readonly></van-field> <h2 class="proposal-view-card__title">承办单位答复<span
<van-field v-model="formData.tf_implementStateName" label="落实情况" placeholder="请选择落实情况" :rules="[{required:true,message:'请选择落实情况'}]" required readonly is-link @click="openLayer('implement-picker')"></van-field> class="proposal-audit-card__required">*</span></h2>
<h2 class="proposal-view-card__title">答复内容<span class="proposal-audit-card__required">*</span></h2> <van-field :value="formData.underTakeName" label="承办单位"
<van-field v-model="formData.tf_opinion" class="proposal-audit-field" name="tf_opinion" type="textarea" rows="6" label="" placeholder="请输入答复内容" :rules="[{required:true,message:'请填写答复内容'}]" required maxlength="100" show-word-limit></van-field> readonly></van-field>
<van-field v-model="formData.tf_implementStateName" label="落实情况"
placeholder="请选择落实情况"
:rules="[{required:true,message:'请选择落实情况'}]" required readonly
is-link @click="openLayer('implement-picker')"></van-field>
<!-- 承办单位答复附件上传入口暂时停用保留公用上传组件配置便于后续恢复 -->
<!-- <van-field label="附件" name="tf_attachment" class="direction-column-field">-->
<!-- <template #input>-->
<!-- <h5-file-upload-->
<!-- :value.sync="formData.tf_attachment"-->
<!-- :upload_number="5"-->
<!-- upload_mode="file"-->
<!-- upload_result_category="array"-->
<!-- upload_result_type="url"-->
<!-- complete_result>-->
<!-- </h5-file-upload>-->
<!-- </template>-->
<!-- </van-field>-->
<van-field v-model="formData.tf_opinion" class="proposal-audit-field"
name="tf_opinion" type="textarea" rows="6" label=""
placeholder="请输入答复内容"
:rules="[{required:true,message:'请填写答复内容'}]"
maxlength="100" show-word-limit></van-field>
</section> </section>
<van-field v-if="supportCandidate && formData.underTakeIsMaster" v-model="formData.tf_nextNodeOperatorName" label="校领导" placeholder="请选择校领导" :rules="[{required:true,message:'请选择校领导'}]" required readonly is-link @click="openLayer('candidate-picker')"></van-field> <van-field v-if="supportCandidate && formData.underTakeIsMaster"
v-model="formData.tf_nextNodeOperatorName" label="校领导"
placeholder="请选择校领导"
:rules="[{required:true,message:'请选择校领导'}]" required readonly
is-link @click="openLayer('candidate-picker')"></van-field>
</template> </template>
<template v-if="kind === 'caseCheck'"> <template v-if="kind === 'caseCheck'">
<van-field :value="formData.tf_username" label="审核人" readonly></van-field> <van-field :value="formData.tf_username" label="审核人" readonly></van-field>
<van-field :value="formData.tf_auditTime" label="审核时间" readonly></van-field> <van-field :value="formData.tf_auditTime" label="审核时间" readonly></van-field>
<van-field :value="formData.tf_caseCheckNeedSecondReply" label="二次答复" required :rules="[{validator:validateSecondReply,message:'请选择是否需要二次答复'}]"> <van-field :value="formData.tf_caseCheckNeedSecondReply" label="二次答复" required
<template #input><van-radio-group v-model="formData.tf_caseCheckNeedSecondReply" direction="horizontal" @change="onSecondReplyChange"><van-radio :name="0">无需二次答复</van-radio><van-radio :name="1"></van-radio></van-radio-group></template> :rules="[{validator:validateSecondReply,message:'请选择是否需要二次答复'}]">
<template #input>
<van-radio-group v-model="formData.tf_caseCheckNeedSecondReply"
direction="horizontal" @change="onSecondReplyChange">
<van-radio :name="0">无需二次答复</van-radio>
<van-radio :name="1">需要二次答复</van-radio>
</van-radio-group>
</template>
</van-field> </van-field>
<van-field v-if="formData.tf_caseCheckNeedSecondReply === 1" :value="selectedReplyUnitNames" label="答复单位" placeholder="请选择办理单位" readonly required is-link :rules="[{validator:validateSecondReplyUnits,message:'请选择需要二次答复的办理单位'}]" @click="openReplyUnitPicker"></van-field> <van-field v-if="formData.tf_caseCheckNeedSecondReply === 1"
:value="selectedReplyUnitNames" label="答复单位"
placeholder="请选择办理单位" readonly required is-link
:rules="[{validator:validateSecondReplyUnits,message:'请选择需要二次答复的办理单位'}]"
@click="openReplyUnitPicker"></van-field>
<section class="proposal-view-card"> <section class="proposal-view-card">
<h2 class="proposal-view-card__title">审批意见<span class="proposal-audit-card__required">*</span></h2> <h2 class="proposal-view-card__title">审批意见<span
<van-field v-model="formData.tf_opinion" class="proposal-audit-field" name="tf_opinion" type="textarea" rows="6" label="" placeholder="请输入审批意见" required maxlength="100" show-word-limit :rules="[{required:true,message:'请填写审批意见'}]"></van-field> class="proposal-audit-card__required">*</span></h2>
<van-field v-model="formData.tf_opinion" class="proposal-audit-field"
name="tf_opinion" type="textarea" rows="6" label=""
placeholder="请输入审批意见" required maxlength="100" show-word-limit
:rules="[{required:true,message:'请填写审批意见'}]"></van-field>
</section> </section>
</template> </template>
</van-form> </van-form>
<div class="proposal-popup-form-actions" v-if="kind === 'delegation'"> <div class="proposal-popup-form-actions" v-if="kind === 'delegation'">
<van-button type="danger" block :loading="formLoading" class="proposal-soft-button proposal-soft-button--danger" @click="prepareSubmit(6)">退回</van-button><van-button type="danger" block :loading="formLoading" class="proposal-soft-button proposal-soft-button--danger" @click="prepareSubmit(2)"></van-button><van-button type="primary" block :loading="formLoading" class="proposal-soft-button" @click="prepareSubmit(1)"></van-button> <van-button type="primary" block :loading="formLoading" class="proposal-soft-button"
@click="prepareSubmit(1)">同意
</van-button>
<van-button type="danger" block :loading="formLoading"
class="proposal-soft-button proposal-soft-button--danger"
@click="prepareSubmit(2)">不同意
</van-button>
<van-button type="danger" block :loading="formLoading"
class="proposal-soft-button proposal-soft-button--danger"
@click="prepareSubmit(6)">退回
</van-button>
</div> </div>
<div class="proposal-popup-form-actions" v-else-if="kind === 'preAudit'" style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr))"> <div class="proposal-popup-form-actions proposal-pre-audit-actions" v-else-if="kind === 'preAudit'"
<van-button type="warning" :loading="formLoading" class="proposal-soft-button proposal-soft-button--warning" @click="prepareSubmit(4)">退回不审核</van-button><van-button type="warning" :loading="formLoading" class="proposal-soft-button proposal-soft-button--warning" @click="prepareSubmit(6)">退</van-button><van-button type="danger" :loading="formLoading" class="proposal-soft-button proposal-soft-button--danger" @click="prepareSubmit(2)"></van-button><van-button type="primary" :loading="formLoading" class="proposal-soft-button" @click="prepareSubmit(1)"></van-button> style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr))">
<van-button type="primary" :loading="formLoading"
class="proposal-soft-button proposal-pre-audit-button--primary"
@click="prepareSubmit(1)">同意
</van-button>
<van-button type="danger" :loading="formLoading"
class="proposal-soft-button proposal-pre-audit-button--danger"
@click="prepareSubmit(2)">不同意
</van-button>
<van-button type="info" :loading="formLoading"
class="proposal-soft-button proposal-pre-audit-button--info"
@click="prepareSubmit(4)">退回不审核
</van-button>
<van-button type="info" :loading="formLoading"
class="proposal-soft-button proposal-pre-audit-button--info"
@click="prepareSubmit(6)">退回审核
</van-button>
</div>
<div class="proposal-popup-form-actions" v-else-if="kind === 'schoolLeaderApproval'">
<van-button type="primary" block :loading="formLoading" class="proposal-soft-button"
@click="prepareSubmit('agree')">同意答复
</van-button>
<van-button type="danger" block :loading="formLoading"
class="proposal-soft-button proposal-soft-button--danger"
@click="prepareSubmit('back')">退回答复
</van-button>
</div>
<div class="proposal-popup-form-actions" v-else>
<van-button type="primary" block :loading="formLoading" class="proposal-soft-button"
@click="prepareSubmit(1)">提交
</van-button>
<van-button block :disabled="formLoading" class="proposal-rounded-button"
@click="close">返回
</van-button>
</div> </div>
<div class="proposal-popup-form-actions" v-else-if="kind === 'schoolLeaderApproval'"><van-button type="danger" block :loading="formLoading" class="proposal-soft-button proposal-soft-button--danger" @click="prepareSubmit('back')">退回答复</van-button><van-button type="primary" block :loading="formLoading" class="proposal-soft-button" @click="prepareSubmit('agree')"></van-button></div>
<div class="proposal-popup-form-actions" v-else><van-button block :disabled="formLoading" class="proposal-rounded-button" @click="close">取消</van-button><van-button type="primary" block :loading="formLoading" class="proposal-soft-button" @click="prepareSubmit(1)"></van-button></div>
</div> </div>
</proposal-info> </proposal-info>
</div> </div>
</div> </div>
<!-- 字典通过异步请求加载初始化阶段可能为 null使用空数组保证弹层首次渲染不执行 null.map --> <!-- 字典通过异步请求加载初始化阶段可能为 null使用空数组保证弹层首次渲染不执行 null.map -->
<van-popup v-model="feedbackPickerVisible" position="bottom" :close-on-click-overlay="false"><van-picker show-toolbar :columns="(dict.type.PROPOSAL_FEEDBACK || []).map((item) => ({text:item.label,value:item.code}))" @confirm="confirmFeedback" @cancel="closeLayer('feedback-picker')"></van-picker></van-popup> <van-popup v-model="feedbackPickerVisible" position="bottom" :close-on-click-overlay="false">
<van-popup v-model="resultPickerVisible" position="bottom" :close-on-click-overlay="false"><van-picker show-toolbar :columns="(dict.type.UNDERTAKE_SUGGESTION || []).map((item) => item.code)" @confirm="confirmResult" @cancel="closeLayer('result-picker')"></van-picker></van-popup> <van-picker show-toolbar
<van-popup v-model="implementPickerVisible" position="bottom" :close-on-click-overlay="false"><van-picker show-toolbar :columns="(dict.type.PROPOSAL_REPLY_IMPLEMENT || []).map((item) => ({text:item.label,value:item.code}))" @confirm="confirmImplement" @cancel="closeLayer('implement-picker')"></van-picker></van-popup> :columns="(dict.type.PROPOSAL_FEEDBACK || []).map((item) => ({text:item.label,value:item.code}))"
<van-popup v-model="candidatePickerVisible" position="bottom" :close-on-click-overlay="false"><van-picker show-toolbar :columns="candidates.map((item) => ({text:item.userName,value:item.userId}))" @confirm="confirmCandidate" @cancel="closeLayer('candidate-picker')"></van-picker></van-popup> @confirm="confirmFeedback" @cancel="closeLayer('feedback-picker')"></van-picker>
<van-popup v-model="replyUnitPickerVisible" position="bottom" round :close-on-click-overlay="false" :style="{height:'70%'}"> </van-popup>
<div class="reply-unit-popup"><div class="reply-unit-toolbar"><button type="button" @click="closeLayer('reply-unit-picker')">取消</button><div class="reply-unit-toolbar-title"></div><button type="button" @click="confirmReplyUnits"></button></div> <van-popup v-model="resultPickerVisible" position="bottom" :close-on-click-overlay="false">
<div class="reply-unit-list" v-if="replyUnitOptions.length"><van-checkbox-group v-model="pendingReplyUnitIds"><van-cell v-for="item in replyUnitOptions" :key="item.unitId" clickable :title="item.unitName" :label="item.isMaster ? '主办单位' : '协办单位'" @click="toggleReplyUnit(item)"><template #right-icon><van-checkbox :name="item.unitId" @click.stop></van-checkbox></template></van-cell></van-checkbox-group></div> <van-picker show-toolbar :columns="(dict.type.UNDERTAKE_SUGGESTION || []).map((item) => item.code)"
@confirm="confirmResult" @cancel="closeLayer('result-picker')"></van-picker>
</van-popup>
<van-popup v-model="implementPickerVisible" position="bottom" :close-on-click-overlay="false">
<van-picker show-toolbar
:columns="(dict.type.PROPOSAL_REPLY_IMPLEMENT || []).map((item) => ({text:item.label,value:item.code}))"
@confirm="confirmImplement" @cancel="closeLayer('implement-picker')"></van-picker>
</van-popup>
<van-popup v-model="candidatePickerVisible" position="bottom" :close-on-click-overlay="false">
<van-picker show-toolbar :columns="candidates.map((item) => ({text:item.userName,value:item.userId}))"
@confirm="confirmCandidate" @cancel="closeLayer('candidate-picker')"></van-picker>
</van-popup>
<van-popup v-model="replyUnitPickerVisible" position="bottom" round :close-on-click-overlay="false"
:style="{height:'70%'}">
<div class="reply-unit-popup">
<div class="reply-unit-toolbar">
<button type="button" @click="closeLayer('reply-unit-picker')">取消</button>
<div class="reply-unit-toolbar-title">选择答复单位</div>
<button type="button" @click="confirmReplyUnits">确定</button>
</div>
<div class="reply-unit-list" v-if="replyUnitOptions.length">
<van-checkbox-group v-model="pendingReplyUnitIds">
<van-cell v-for="item in replyUnitOptions" :key="item.unitId" clickable
:title="item.unitName" :label="item.isMaster ? '主办单位' : '协办单位'"
@click="toggleReplyUnit(item)">
<template #right-icon>
<van-checkbox :name="item.unitId" @click.stop></van-checkbox>
</template>
</van-cell>
</van-checkbox-group>
</div>
<div class="reply-unit-empty" v-else>暂无可选办理单位</div> <div class="reply-unit-empty" v-else>暂无可选办理单位</div>
</div> </div>
</van-popup> </van-popup>
<van-dialog :value="confirmVisible" title="提示" message="您确定要提交吗?" show-cancel-button @confirm="submit" @cancel="closeLayer('submit-confirm')"></van-dialog> <van-dialog :value="confirmVisible" title="提示" message="您确定要提交吗?" show-cancel-button
@confirm="submit" @cancel="closeLayer('submit-confirm')"></van-dialog>
</van-popup> </van-popup>
`, `,
dicts: ["PROPOSAL_FEEDBACK", "UNDERTAKE_SUGGESTION", "PROPOSAL_REPLY_IMPLEMENT"], dicts: ["PROPOSAL_FEEDBACK", "UNDERTAKE_SUGGESTION", "PROPOSAL_REPLY_IMPLEMENT"],
components: {"proposal-info": PROPOSAL_INFO}, components: {"proposal-info": PROPOSAL_INFO},
props: {kind: {type: String, required: true}, pageKey: {type: String, required: true}}, props: {kind: {type: String, required: true}, pageKey: {type: String, required: true}},
data() { return {visible:false,bizId:"",action:"view",formData:{},formLoading:false,pendingSubmitType:1,feedbackPickerVisible:false,resultPickerVisible:false,implementPickerVisible:false,candidatePickerVisible:false,replyUnitPickerVisible:false,confirmVisible:false,supportCandidate:false,candidates:[],replyUnitOptions:[],pendingReplyUnitIds:[],unsubscribeHistoryLayer:null} }, data() {
return {
visible: false,
bizId: "",
action: "view",
formData: {},
formLoading: false,
pendingSubmitType: 1,
feedbackPickerVisible: false,
resultPickerVisible: false,
implementPickerVisible: false,
candidatePickerVisible: false,
replyUnitPickerVisible: false,
confirmVisible: false,
supportCandidate: false,
candidates: [],
replyUnitOptions: [],
pendingReplyUnitIds: [],
unsubscribeHistoryLayer: null
}
},
computed: { computed: {
isApproval() { return this.action === "approval" }, isApproval() {
title() { const titles={delegation:"团长审核",preAudit:"校工会预审核",schoolLeaderApproval:"分管校领导审批",feedbackEvaluation:"反馈评价",suggestion:"承办单位意见",unitReply:"承办单位答复",caseCheck:"提案委员会审查"}; return this.isApproval ? titles[this.kind] : "提案详情" }, return this.action === "approval"
selectedReplyUnitNames() { const ids=this.formData.tf_secondReplyUnitIds || []; return this.replyUnitOptions.filter((item) => ids.indexOf(item.unitId)>-1).map((item) => item.unitName+""+(item.isMaster?"主办":"协办")+"").join("、") } },
title() {
const titles = {
delegation: "团长审核",
preAudit: "校工会预审核",
schoolLeaderApproval: "分管校领导审批",
feedbackEvaluation: "反馈评价",
suggestion: "承办单位意见",
unitReply: "承办单位答复",
caseCheck: "提案委员会审查"
};
return this.isApproval ? titles[this.kind] : "提案详情"
},
selectedReplyUnitNames() {
const ids = this.formData.tf_secondReplyUnitIds || [];
return this.replyUnitOptions.filter((item) => ids.indexOf(item.unitId) > -1).map((item) => item.unitName + "" + (item.isMaster ? "主办" : "协办") + "").join("、")
}
}, },
methods: { methods: {
syncLayers(stack) { this.$set(this,"visible",stack.includes("detail")); this.$set(this,"feedbackPickerVisible",stack.includes("feedback-picker")); this.$set(this,"resultPickerVisible",stack.includes("result-picker")); this.$set(this,"implementPickerVisible",stack.includes("implement-picker")); this.$set(this,"candidatePickerVisible",stack.includes("candidate-picker")); this.$set(this,"replyUnitPickerVisible",stack.includes("reply-unit-picker")); this.$set(this,"confirmVisible",stack.includes("submit-confirm")) }, syncLayers(stack) {
openLayer(name) { window.h5HistoryLayerManager.open(name) }, closeLayer(name,callback) { window.h5HistoryLayerManager.close(name,callback) }, close() { this.closeLayer("detail") }, this.$set(this, "visible", stack.includes("detail"));
this.$set(this, "feedbackPickerVisible", stack.includes("feedback-picker"));
this.$set(this, "resultPickerVisible", stack.includes("result-picker"));
this.$set(this, "implementPickerVisible", stack.includes("implement-picker"));
this.$set(this, "candidatePickerVisible", stack.includes("candidate-picker"));
this.$set(this, "replyUnitPickerVisible", stack.includes("reply-unit-picker"));
this.$set(this, "confirmVisible", stack.includes("submit-confirm"))
},
openLayer(name) {
window.h5HistoryLayerManager.open(name)
}, closeLayer(name, callback) {
window.h5HistoryLayerManager.close(name, callback)
}, close() {
this.closeLayer("detail")
},
/** row 提供详情主键和当前 WF 任务参数,action 为 view 或 approval。 */ /** row 提供详情主键和当前 WF 任务参数,action 为 view 或 approval。 */
open(row,action) { open(row, action) {
const defaultTaskNames={delegation:"团长审核",preAudit:"校工会预审核",feedbackEvaluation:"反馈评价",unitReply:"承办单位答复",caseCheck:"提案委员会审查"} const defaultTaskNames = {
const taskName=this.kind === "unitReply" ? (row.taskName || defaultTaskNames[this.kind]) : (row.curTaskName || row.taskName || defaultTaskNames[this.kind] || "") delegation: "团长审核",
this.$set(this,"bizId",row.id); this.$set(this,"action",action); this.$set(this,"formData",{proposalId:row.id,processTaskId:row.taskId || "",taskKey:row.taskKey || "",taskName:taskName,instanceId:row.instanceId || "",underTakeName:row.underTakeName || "",underTakeIsMaster:!!row.underTakeIsMaster,tf_opinion:"",opinion:""}) preAudit: "校工会预审核",
if (action === "approval" && this.kind === "unitReply") this.loadCandidates(row.taskId || "") feedbackEvaluation: "反馈评价",
if (action === "approval" && this.kind === "caseCheck") { this.$set(this.formData,"tf_auditTime",moment().format("YYYY-MM-DD")); this.$set(this.formData,"tf_username",(this.$store.state.user && this.$store.state.user.username) || ""); this.$set(this.formData,"tf_caseCheckNeedSecondReply",0); this.$set(this.formData,"tf_secondReplyUnitIds",[]); this.loadReplyUnits(row.id) } unitReply: "承办单位答复",
caseCheck: "提案委员会审查"
}
const taskName = this.kind === "unitReply" ? (row.taskName || defaultTaskNames[this.kind]) : (row.curTaskName || row.taskName || defaultTaskNames[this.kind] || "")
this.$set(this, "bizId", row.id);
this.$set(this, "action", action);
this.$set(this, "formData", {
proposalId: row.id,
processTaskId: row.taskId || "",
taskKey: row.taskKey || "",
taskName: taskName,
instanceId: row.instanceId || "",
underTakeName: row.underTakeName || "",
underTakeIsMaster: !!row.underTakeIsMaster,
tf_opinion: "",
opinion: ""
})
if (action === "approval" && this.kind === "unitReply") {
// 承办单位答复附件上传入口暂时停用,保留初始化代码便于后续恢复。
// this.$set(this.formData, "tf_attachment", [])
this.loadCandidates(row.taskId || "")
}
if (action === "approval" && this.kind === "caseCheck") {
this.$set(this.formData, "tf_auditTime", moment().format("YYYY-MM-DD"));
this.$set(this.formData, "tf_username", (this.$store.state.user && this.$store.state.user.username) || "");
this.$set(this.formData, "tf_caseCheckNeedSecondReply", 0);
this.$set(this.formData, "tf_secondReplyUnitIds", []);
this.loadReplyUnits(row.id)
}
this.openLayer("detail") this.openLayer("detail")
}, },
loadCandidates(taskId) { this.$axios.post("/flow/common/candidate",{taskId:taskId}).then((res) => { if (res.code === 0) { this.$set(this,"candidates",res.data.candidates || []); this.$set(this,"supportCandidate",res.data.support) } }) }, loadCandidates(taskId) {
confirmFeedback(value) { this.$set(this.formData,"tf_feedback_name",value.text); this.$set(this.formData,"tf_feedback",value.value); this.closeLayer("feedback-picker") }, this.$axios.post("/flow/common/candidate", {taskId: taskId}).then((res) => {
confirmResult(value) { this.$set(this.formData,"result",value); this.closeLayer("result-picker") }, if (res.code === 0) {
confirmImplement(value) { this.$set(this.formData,"tf_implementStateName",value.text); this.$set(this.formData,"tf_implementState",value.value); this.closeLayer("implement-picker") }, this.$set(this, "candidates", res.data.candidates || []);
confirmCandidate(value) { this.$set(this.formData,"tf_nextNodeOperatorName",value.text); this.$set(this.formData,"tf_nextNodeOperator",value.value); this.closeLayer("candidate-picker") }, this.$set(this, "supportCandidate", res.data.support)
validateSecondReply(value) { return value === 0 || value === 1 }, }
validateSecondReplyUnits() { return this.formData.tf_caseCheckNeedSecondReply !== 1 || (this.formData.tf_secondReplyUnitIds || []).length > 0 }, })
onSecondReplyChange(value) { if (value !== 1) this.$set(this.formData,"tf_secondReplyUnitIds",[]) }, },
loadReplyUnits(proposalId) { this.$axios.post("/platform/proposal/case/check/listReplyUnits",{proposalId:proposalId}).then((res) => { if (res && res.code === 0) this.$set(this,"replyUnitOptions",res.data || []) }) }, confirmFeedback(value) {
openReplyUnitPicker() { this.$set(this,"pendingReplyUnitIds",[...(this.formData.tf_secondReplyUnitIds || [])]); this.openLayer("reply-unit-picker") }, this.$set(this.formData, "tf_feedback_name", value.text);
toggleReplyUnit(item) { const ids=[...this.pendingReplyUnitIds]; const index=ids.indexOf(item.unitId); if (index>-1) ids.splice(index,1); else ids.push(item.unitId); this.$set(this,"pendingReplyUnitIds",ids) }, this.$set(this.formData, "tf_feedback", value.value);
confirmReplyUnits() { this.$set(this.formData,"tf_secondReplyUnitIds",[...this.pendingReplyUnitIds]); this.closeLayer("reply-unit-picker") }, this.closeLayer("feedback-picker")
buildCaseCheckData() { const data={...this.formData}; const units=data.tf_caseCheckNeedSecondReply === 1 ? this.replyUnitOptions.filter((item) => data.tf_secondReplyUnitIds.indexOf(item.unitId)>-1) : []; const masters=units.filter((item) => item.isMaster); const slaves=units.filter((item) => !item.isMaster); data.tf_masterUnitIds=masters.map((item) => item.unitId); data.tf_masterUnitId=data.tf_masterUnitIds[0] || ""; data.tf_masterUnitNames=masters.map((item) => item.unitName); data.tf_masterUnitName=data.tf_masterUnitNames[0] || ""; data.tf_masterUnitNameStr=data.tf_masterUnitNames.join(","); data.tf_slaveUnitIds=slaves.map((item) => item.unitId); data.tf_slaveUnitNames=slaves.map((item) => item.unitName); data.tf_slaveUnitNameStr=data.tf_slaveUnitNames.join(","); return data }, },
confirmResult(value) {
this.$set(this.formData, "result", value);
this.closeLayer("result-picker")
},
confirmImplement(value) {
this.$set(this.formData, "tf_implementStateName", value.text);
this.$set(this.formData, "tf_implementState", value.value);
this.closeLayer("implement-picker")
},
confirmCandidate(value) {
this.$set(this.formData, "tf_nextNodeOperatorName", value.text);
this.$set(this.formData, "tf_nextNodeOperator", value.value);
this.closeLayer("candidate-picker")
},
validateSecondReply(value) {
return value === 0 || value === 1
},
validateSecondReplyUnits() {
return this.formData.tf_caseCheckNeedSecondReply !== 1 || (this.formData.tf_secondReplyUnitIds || []).length > 0
},
onSecondReplyChange(value) {
if (value !== 1) this.$set(this.formData, "tf_secondReplyUnitIds", [])
},
loadReplyUnits(proposalId) {
this.$axios.post("/platform/proposal/case/check/listReplyUnits", {proposalId: proposalId}).then((res) => {
if (res && res.code === 0) this.$set(this, "replyUnitOptions", res.data || [])
})
},
openReplyUnitPicker() {
this.$set(this, "pendingReplyUnitIds", [...(this.formData.tf_secondReplyUnitIds || [])]);
this.openLayer("reply-unit-picker")
},
toggleReplyUnit(item) {
const ids = [...this.pendingReplyUnitIds];
const index = ids.indexOf(item.unitId);
if (index > -1) ids.splice(index, 1); else ids.push(item.unitId);
this.$set(this, "pendingReplyUnitIds", ids)
},
confirmReplyUnits() {
this.$set(this.formData, "tf_secondReplyUnitIds", [...this.pendingReplyUnitIds]);
this.closeLayer("reply-unit-picker")
},
buildCaseCheckData() {
const data = {...this.formData};
const units = data.tf_caseCheckNeedSecondReply === 1 ? this.replyUnitOptions.filter((item) => data.tf_secondReplyUnitIds.indexOf(item.unitId) > -1) : [];
const masters = units.filter((item) => item.isMaster);
const slaves = units.filter((item) => !item.isMaster);
data.tf_masterUnitIds = masters.map((item) => item.unitId);
data.tf_masterUnitId = data.tf_masterUnitIds[0] || "";
data.tf_masterUnitNames = masters.map((item) => item.unitName);
data.tf_masterUnitName = data.tf_masterUnitNames[0] || "";
data.tf_masterUnitNameStr = data.tf_masterUnitNames.join(",");
data.tf_slaveUnitIds = slaves.map((item) => item.unitId);
data.tf_slaveUnitNames = slaves.map((item) => item.unitName);
data.tf_slaveUnitNameStr = data.tf_slaveUnitNames.join(",");
return data
},
prepareSubmit(value) { prepareSubmit(value) {
// 各办理页面提交前统一校验意见字段;空白字符按未填写处理,提示后不再打开确认弹框。 // 各办理页面提交前统一校验意见字段;空白字符按未填写处理,提示后不再打开确认弹框。
const opinionConfig = this.kind === "suggestion" const opinionConfig = this.kind === "suggestion"
@@ -120,24 +395,63 @@ const PROPOSAL_TASK_POPUP = {
this.$toast(opinionConfig.message) this.$toast(opinionConfig.message)
return return
} }
this.$refs.formRef.validate().then(() => { this.$set(this,"pendingSubmitType",value); this.openLayer("submit-confirm") }).catch(() => {}) this.$refs.formRef.validate().then(() => {
this.$set(this, "pendingSubmitType", value);
this.openLayer("submit-confirm")
}).catch(() => {
})
}, },
/** 根据页面类型保留原接口和参数分支,成功后清理全部弹层历史并通知列表刷新。 */ /** 根据页面类型保留原接口和参数分支,成功后清理全部弹层历史并通知列表刷新。 */
submit() { submit() {
this.$set(this,"formLoading",true) this.$set(this, "formLoading", true)
let request let request
if (this.kind === "suggestion") request=this.$axios.post("/platform/proposal/suggestion/suggest",this.formData) if (this.kind === "suggestion") request = this.$axios.post("/platform/proposal/suggestion/suggest", this.formData)
else if (this.kind === "schoolLeaderApproval") { const data={...this.formData,tf_approval:this.pendingSubmitType}; delete data.taskKey; delete data.taskName; request=this.$axios.post("/platform/proposal/schoolLeaderApproval/executeTask",{data:JSON.stringify(data)}) } else if (this.kind === "schoolLeaderApproval") {
else { const data = {...this.formData, tf_approval: this.pendingSubmitType};
const data=this.kind === "caseCheck" ? {...this.buildCaseCheckData(),submitType:this.pendingSubmitType} : {...this.formData,submitType:this.pendingSubmitType} delete data.taskKey;
if (this.kind === "preAudit") { data.taskName="startTask"; if (this.pendingSubmitType === 4) { data.tf_sourceTaskKey=this.formData.taskKey; data.tf_audit=false } } delete data.taskName;
if (this.kind === "unitReply") { delete data.underTakeIsMaster; delete data.underTakeName } request = this.$axios.post("/platform/proposal/schoolLeaderApproval/executeTask", {data: JSON.stringify(data)})
if (this.kind === "feedbackEvaluation" && data.tf_feedback === "DISSATISFIED") request=this.$axios.post("/platform/proposal/feedbackEvaluation/caseInfo",{instId:data.instanceId}).then((res) => { if (res.code !== 0) return Promise.reject(new Error(res.msg || "查询立案信息失败")); data.tf_masterUnitId=res.data.tf_masterUnitId; return this.$axios.post("/flow/common/executeTask",{data:JSON.stringify(data)}) }) } else {
else request=this.$axios.post("/flow/common/executeTask",{data:JSON.stringify(data)}) const data = this.kind === "caseCheck" ? {
...this.buildCaseCheckData(),
submitType: this.pendingSubmitType
} : {...this.formData, submitType: this.pendingSubmitType}
if (this.kind === "preAudit") {
data.taskName = "startTask";
if (this.pendingSubmitType === 4) {
data.tf_sourceTaskKey = this.formData.taskKey;
data.tf_audit = false
} }
request.then((res) => { if (res && res.code === 0) { this.$toast.success(res.msg); window.h5HistoryLayerManager.closeAll(() => this.$emit("success")) } }).catch((error) => { if (error && error.message && error.message !== "cancel") this.$toast.fail(error.message) }).finally(() => { this.$set(this,"formLoading",false) }) }
if (this.kind === "unitReply") {
delete data.underTakeIsMaster;
delete data.underTakeName
}
if (this.kind === "feedbackEvaluation" && data.tf_feedback === "DISSATISFIED") request = this.$axios.post("/platform/proposal/feedbackEvaluation/caseInfo", {instId: data.instanceId}).then((res) => {
if (res.code !== 0) return Promise.reject(new Error(res.msg || "查询立案信息失败"));
data.tf_masterUnitId = res.data.tf_masterUnitId;
return this.$axios.post("/flow/common/executeTask", {data: JSON.stringify(data)})
})
else request = this.$axios.post("/flow/common/executeTask", {data: JSON.stringify(data)})
}
request.then((res) => {
if (res && res.code === 0) {
this.$toast.success(res.msg);
window.h5HistoryLayerManager.closeAll(() => this.$emit("success"))
}
}).catch((error) => {
if (error && error.message && error.message !== "cancel") this.$toast.fail(error.message)
}).finally(() => {
this.$set(this, "formLoading", false)
})
} }
}, },
created() { window.h5HistoryLayerManager.ensureRegistered(this.pageKey); this.$set(this,"unsubscribeHistoryLayer",window.h5HistoryLayerManager.subscribe((stack) => this.syncLayers(stack))) }, created() {
beforeDestroy() { if (this.unsubscribeHistoryLayer) this.unsubscribeHistoryLayer(); if (window.h5HistoryLayerManager.pageKey === this.pageKey) window.h5HistoryLayerManager.unregister(this.pageKey) } window.h5HistoryLayerManager.ensureRegistered(this.pageKey);
this.$set(this, "unsubscribeHistoryLayer", window.h5HistoryLayerManager.subscribe((stack) => this.syncLayers(stack)))
},
beforeDestroy() {
if (this.unsubscribeHistoryLayer) this.unsubscribeHistoryLayer();
if (window.h5HistoryLayerManager.pageKey === this.pageKey) window.h5HistoryLayerManager.unregister(this.pageKey)
}
} }
@@ -47,6 +47,190 @@ layout("/layouts/platform_h5.html"){
color: #1989fa; color: #1989fa;
} }
/* Popup 内固定导航栏不依赖 placeholder 占位,避免遮挡邀请附议筛选框。 */
.proposal-invite-popup {
display: flex;
padding-top: 46px;
overflow: hidden;
box-sizing: border-box;
flex-direction: column;
}
/* 邀请附议人弹框固定展示工号、姓名查询入口,清空关键词后恢复完整人员列表。 */
.proposal-invite-search {
padding: 12px 12px 8px;
background-color: #f3f7fd;
}
.proposal-invite-search .van-search__content {
height: 42px;
padding-left: 12px;
align-items: center;
border-radius: 12px;
background-color: #fff;
box-shadow: 0 5px 16px rgba(43, 73, 112, .1);
}
.proposal-invite-search .van-field__control {
color: #263548;
font-size: 14px;
}
.proposal-invite-search .van-field__control::placeholder {
color: #a7b0bf;
}
/* 邀请状态使用双分段切换,当前状态通过白色底和蓝色短线突出展示。 */
.proposal-invite-tabs {
padding: 4px 12px 8px;
background-color: #f3f7fd;
}
.proposal-invite-tabs .van-tabs__wrap {
height: 48px;
}
.proposal-invite-tabs .van-tabs__nav {
padding: 4px;
box-sizing: border-box;
border-radius: 14px;
background-color: #edf4ff;
}
.proposal-invite-tabs .van-tab {
height: 40px;
color: #6e7d92;
font-size: 14px;
line-height: 40px;
}
.proposal-invite-tabs .van-tab--active {
border-radius: 10px;
color: #1989fa;
font-weight: 600;
background-color: #fff;
box-shadow: 0 4px 10px rgba(31, 89, 164, .08);
}
.proposal-invite-tabs .van-tabs__line {
bottom: 4px;
width: 30px;
height: 3px;
border-radius: 3px;
background-color: #1989fa;
}
/* 人员卡片区域独立滚动,顶部筛选区和底部操作区始终保留在视口内。 */
.proposal-invite-scroll {
min-height: 0;
overflow-y: auto;
flex: 1;
-webkit-overflow-scrolling: touch;
}
/* 人员列表以独立卡片呈现,姓名、工号、单位和勾选区域层级清晰。 */
.proposal-invite-list {
padding: 0 12px 12px;
}
.proposal-invite-list .van-cell-group {
background-color: transparent;
}
.proposal-invite-person {
min-height: 70px;
margin-top: 12px;
padding: 12px 14px;
align-items: center;
border-radius: 12px;
background-color: #fff;
box-shadow: 0 6px 16px rgba(43, 73, 112, .08);
}
.proposal-invite-person::after {
display: none;
}
.proposal-invite-person__title {
display: block;
color: #253247;
font-size: 15px;
font-weight: 600;
line-height: 22px;
}
.proposal-invite-person__login-name {
color: #79879a;
font-size: 14px;
font-weight: 400;
}
.proposal-invite-person__unit {
display: block;
margin-top: 3px;
color: #8c99aa;
font-size: 12px;
line-height: 18px;
}
.proposal-invite-person__checkbox {
display: flex;
width: 36px;
height: 36px;
align-items: center;
justify-content: center;
}
.proposal-invite-person__checkbox .van-icon {
width: 20px;
height: 20px;
box-sizing: border-box;
border: 1px solid #4b8cff;
border-radius: 50%;
background-color: #fff;
}
.proposal-invite-person__checkbox .van-checkbox__icon--checked .van-icon {
border-color: #1989fa;
background-color: #1989fa;
}
/* 分页和邀请按钮沿用同一圆角语言,与人员卡片形成统一的操作区域。 */
.proposal-invite-actions {
padding: 0 12px calc(12px + env(safe-area-inset-bottom));
background-color: #f3f7fd;
flex: none;
}
.proposal-invite-pagination {
margin-top: 2px;
}
.proposal-invite-pagination .van-pagination__item {
min-height: 36px;
border-radius: 10px;
background-color: #fff;
box-shadow: 0 4px 12px rgba(43, 73, 112, .06);
}
.proposal-invite-pagination .van-pagination__item--active,
.proposal-invite-pagination .van-pagination__item:not(.van-pagination__item--disabled):active {
color: #fff;
background-color: #1989fa;
}
.proposal-invite-submit {
height: 42px;
margin-top: 12px;
border-radius: 12px;
}
.proposal-invite-empty {
min-height: 0;
overflow-y: auto;
flex: 1;
}
/* 详情弹框复用项目全屏 Popup 的固定导航结构,内容区域单独滚动。 */ /* 详情弹框复用项目全屏 Popup 的固定导航结构,内容区域单独滚动。 */
.proposal-detail-popup { .proposal-detail-popup {
overflow: hidden; overflow: hidden;
@@ -11,45 +11,64 @@ const PROPOSAL_INVITE_SECONDER_COMPONENT = {
left-arrow left-arrow
left-text="返回" left-text="返回"
fixed fixed
placeholder
@click-left="$emit('close')"> @click-left="$emit('close')">
</van-nav-bar> </van-nav-bar>
<van-search <van-search
v-model="pageForm.searchKeyword" v-model="pageForm.searchKeyword"
class="proposal-invite-search"
:show-action="false" :show-action="false"
shape="round"
clearable
placeholder="请输入工号或者姓名查询" placeholder="请输入工号或者姓名查询"
@search="doSearch"> @search="doSearch"
@clear="doSearch">
</van-search> </van-search>
<van-tabs v-model="isInvite" @click="isInviteClick"> <van-tabs v-model="isInvite" class="proposal-invite-tabs" @click="isInviteClick">
<van-tab title="已邀请" name="1"></van-tab> <van-tab title="已邀请" name="1"></van-tab>
<van-tab title="可邀请" name="0"></van-tab> <van-tab title="可邀请" name="0"></van-tab>
</van-tabs> </van-tabs>
<van-checkbox-group v-model="tableSelection" v-if="tableData && tableData.length > 0"> <div class="proposal-invite-scroll" v-if="tableData && tableData.length > 0">
<van-checkbox-group class="proposal-invite-list" v-model="tableSelection">
<van-cell-group> <van-cell-group>
<van-cell <van-cell
class="proposal-invite-person"
v-for="(item, index) in tableData" v-for="(item, index) in tableData"
clickable clickable
:key="item.loginName" :key="item.loginName"
@click="tableRowToggle(index)"> @click="tableRowToggle(index)">
<span slot="title"> <span slot="title" class="proposal-invite-person__title">
{{item.userName}}{{item.loginName}} {{item.userName}} <span class="proposal-invite-person__login-name">{{item.loginName}}</span>
</span> </span>
<span slot="label" class="font-size0 van-color-text-assist"> <span slot="label" class="proposal-invite-person__unit">
{{item.unitName}} {{item.unitName}}
</span> </span>
<van-checkbox <van-checkbox
:name="item" :name="item"
:ref="'checkboxes' + item.loginName" :ref="'checkboxes' + item.loginName"
class="proposal-invite-person__checkbox"
slot="right-icon" slot="right-icon"
@click.stop
v-if="!pageForm.isInvite"> v-if="!pageForm.isInvite">
</van-checkbox> </van-checkbox>
</van-cell> </van-cell>
</van-cell-group> </van-cell-group>
<div class="p10"> </van-checkbox-group>
</div>
<!-- 空状态提示随已邀请 / 可邀请标签切换便于用户判断当前无数据的具体范围 -->
<div v-else class="proposal-invite-empty">
<img src="/assets/mobile/img/proposal/proposal-empty.png" alt="暂无附议人插画">
<div class="proposal-invite-empty__title">暂无附议人</div>
<div class="proposal-invite-empty__hint">
{{ isInvite === "1" ? "当前暂无已邀请的附议人" : "当前暂无可邀请的附议人" }}
</div>
</div>
<div class="proposal-invite-actions" v-if="tableData && tableData.length > 0">
<van-pagination <van-pagination
class="proposal-soft-pagination" class="proposal-soft-pagination proposal-invite-pagination"
v-model="pageForm.pageNumber" v-model="pageForm.pageNumber"
:total-items="pageForm.totalCount" :total-items="pageForm.totalCount"
:items-per-page="pageForm.pageSize" :items-per-page="pageForm.pageSize"
@@ -60,20 +79,10 @@ const PROPOSAL_INVITE_SECONDER_COMPONENT = {
type="primary" type="primary"
block block
:disabled="pageForm.isInvite" :disabled="pageForm.isInvite"
class="mt10 proposal-soft-button"> class="proposal-soft-button proposal-invite-submit">
邀请 邀请
</van-button> </van-button>
</div> </div>
</van-checkbox-group>
<!-- 空状态提示随已邀请 / 可邀请标签切换便于用户判断当前无数据的具体范围 -->
<div v-else class="proposal-invite-empty">
<img src="/assets/mobile/img/proposal/proposal-empty.png" alt="暂无附议人插画">
<div class="proposal-invite-empty__title">暂无附议人</div>
<div class="proposal-invite-empty__hint">
{{ isInvite === "1" ? "当前暂无已邀请的附议人" : "当前暂无可邀请的附议人" }}
</div>
</div>
<van-dialog <van-dialog
:value="confirmVisible" :value="confirmVisible"
@@ -102,7 +111,7 @@ const PROPOSAL_INVITE_SECONDER_COMPONENT = {
tableData: [], tableData: [],
pageForm: { pageForm: {
pageNumber: 1, pageNumber: 1,
pageSize: 5, pageSize: 6,
totalCount: 0, totalCount: 0,
pageOrderName: "", pageOrderName: "",
pageOrderBy: "", pageOrderBy: "",
@@ -207,8 +216,16 @@ const PROPOSAL_INVITE_SECONDER_COMPONENT = {
this.doSearch() this.doSearch()
}, },
/**
* 点击人员卡片时切换对应的附议人勾选状态
* index 为当前页人员索引已邀请状态或找不到对应复选框时不处理无返回值
*/
tableRowToggle(index) { tableRowToggle(index) {
if (this.pageForm.isInvite || index < 0) return if (this.pageForm.isInvite || index < 0) return
const item = this.tableData[index]
const checkboxRef = item ? this.$refs["checkboxes" + item.loginName] : null
if (!checkboxRef || !checkboxRef[0]) return
checkboxRef[0].toggle()
}, },
/** /**
@@ -411,8 +411,8 @@ layout("/layouts/platform_h5.html"){
</van-form> </van-form>
</section> </section>
<div class="proposal-seconded-form-actions"> <div class="proposal-seconded-form-actions">
<van-button type="danger" block :loading="formLoading" class="proposal-soft-button proposal-soft-button--danger" @click="submitSeconded(false)">不同意</van-button>
<van-button type="primary" block :loading="formLoading" class="proposal-soft-button" @click="submitSeconded(true)">同意</van-button> <van-button type="primary" block :loading="formLoading" class="proposal-soft-button" @click="submitSeconded(true)">同意</van-button>
<van-button type="danger" block :loading="formLoading" class="proposal-soft-button proposal-soft-button--danger" @click="submitSeconded(false)">不同意</van-button>
</div> </div>
</div> </div>
</proposal-info> </proposal-info>
@@ -237,6 +237,109 @@ layout("/layouts/platform_h5.html"){
box-shadow: 0 4px 12px rgba(43, 73, 112, .08), inset 0 1px 1px rgba(255, 255, 255, .82); box-shadow: 0 4px 12px rgba(43, 73, 112, .08), inset 0 1px 1px rgba(255, 255, 255, .82);
} }
/* 选择转交人弹框沿用提案列表的圆角搜索框、人员卡片和插图空状态。 */
.proposal-transfer-user-popup .proposal-full-popup__page {
background-color: #f3f7fd;
}
.proposal-transfer-user-search {
padding: 12px 12px 8px;
background-color: #f3f7fd;
flex: none;
}
.proposal-transfer-user-search .van-search__content {
height: 42px;
padding-left: 12px;
align-items: center;
border-radius: 12px;
background-color: #fff;
box-shadow: 0 5px 16px rgba(43, 73, 112, .1);
}
.proposal-transfer-user-search .van-field__control {
color: #263548;
font-size: 14px;
}
.proposal-transfer-user-search .van-field__control::placeholder {
color: #a7b0bf;
}
.proposal-transfer-user-scroll {
min-height: 0;
overflow-y: auto;
flex: 1;
-webkit-overflow-scrolling: touch;
}
.proposal-transfer-user-list {
padding: 0 12px 12px;
background-color: transparent;
}
.proposal-transfer-user-item {
min-height: 64px;
margin-top: 12px;
padding: 12px 14px;
align-items: center;
border-radius: 12px;
background-color: #fff;
box-shadow: 0 6px 16px rgba(43, 73, 112, .08);
}
.proposal-transfer-user-item::after {
display: none;
}
.proposal-transfer-user-item__name {
display: block;
color: #253247;
font-size: 15px;
font-weight: 600;
line-height: 22px;
}
.proposal-transfer-user-item__unit {
color: #79879a;
font-size: 13px;
font-weight: 400;
}
.proposal-transfer-user-empty {
display: flex;
min-height: 420px;
padding: 40px 18px 36px;
align-items: center;
justify-content: center;
flex-direction: column;
box-sizing: border-box;
text-align: center;
}
.proposal-transfer-user-empty img {
display: block;
width: 82%;
max-width: 270px;
height: auto;
object-fit: contain;
}
.proposal-transfer-user-empty__title {
margin-top: 14px;
color: #50627a;
font-size: 15px;
font-weight: 600;
line-height: 22px;
}
.proposal-transfer-user-empty__hint {
margin-top: 5px;
color: #9aa7b8;
font-size: 12px;
line-height: 18px;
}
@media (max-width: 350px) { @media (max-width: 350px) {
.proposal-unit-reply-content, .proposal-unit-reply-content,
.proposal-unit-reply-sticky { .proposal-unit-reply-sticky {
@@ -340,7 +443,7 @@ layout("/layouts/platform_h5.html"){
<div class="action-btn" v-if="!pageForm.approval && $auth.hasRoleOr('SYSADMIN,PROPOSAL_UNIT_LEADER')" <div class="action-btn" v-if="!pageForm.approval && $auth.hasRoleOr('SYSADMIN,PROPOSAL_UNIT_LEADER')"
@click="openTransfer(row)"> @click="openTransfer(row)">
<van-icon class="proposal-action-icon" name="exchange"></van-icon> <van-icon class="proposal-action-icon" name="exchange"></van-icon>
<span></span> <span></span>
</div> </div>
</template> </template>
</table-list> </table-list>
@@ -350,6 +453,16 @@ layout("/layouts/platform_h5.html"){
<transfer ref="transferRef" @success="doSearch"></transfer> <transfer ref="transferRef" @success="doSearch"></transfer>
<proposal-task-popup ref="taskPopup" kind="unitReply" page-key="proposal-unit-reply-task" @success="doSearch"></proposal-task-popup> <proposal-task-popup ref="taskPopup" kind="unitReply" page-key="proposal-unit-reply-task" @success="doSearch"></proposal-task-popup>
<van-dialog
:value="communicationConfirmVisible"
title="提示"
message="是否已与提案代表进行充分沟通?"
confirm-button-text="是"
cancel-button-text="否"
show-cancel-button
@confirm="confirmCommunication"
@cancel="closeCommunicationConfirm">
</van-dialog>
</div> </div>
<script nonce="${cspNonce!}"> <script nonce="${cspNonce!}">
<!--#include("transfer.js"){}#--> <!--#include("transfer.js"){}#-->
@@ -378,6 +491,10 @@ layout("/layouts/platform_h5.html"){
{text: "未审核", value: "0"}, {text: "未审核", value: "0"},
{text: "已审核", value: "1"} {text: "已审核", value: "1"}
], ],
communicationConfirmVisible: false,
pendingApprovalRow: null,
historyLayerPageKey: "proposal-unit-reply",
unsubscribeHistoryLayer: null
} }
}, },
components: { components: {
@@ -414,7 +531,29 @@ layout("/layouts/platform_h5.html"){
}, },
onApproval(row, index) { onApproval(row, index) {
this.$refs.taskPopup.open(row, "approval") // row 为当前待答复提案;确认已充分沟通后,才进入原有答复表单。
this.$set(this, "pendingApprovalRow", row)
window.h5HistoryLayerManager.open("communication-confirm")
},
/** stack 为当前页面弹层名称数组;同步沟通确认框状态,无返回值。 */
syncHistoryLayers(stack) {
const visible = stack.includes("communication-confirm")
this.$set(this, "communicationConfirmVisible", visible)
if (!visible) this.$set(this, "pendingApprovalRow", null)
},
// 取消确认时同步回退浏览器历史,手机返回键与弹窗按钮行为保持一致。
closeCommunicationConfirm() {
window.h5HistoryLayerManager.close("communication-confirm")
},
// 确认后先关闭沟通弹层,再使用暂存的提案数据打开原有答复表单。
confirmCommunication() {
const row = this.pendingApprovalRow
window.h5HistoryLayerManager.close("communication-confirm", () => {
if (row) this.$refs.taskPopup.open(row, "approval")
})
}, },
onRevoke(row) { onRevoke(row) {
@@ -445,6 +584,10 @@ layout("/layouts/platform_h5.html"){
} }
}, },
created() { created() {
window.h5HistoryLayerManager.ensureRegistered(this.historyLayerPageKey)
this.$set(this, "unsubscribeHistoryLayer", window.h5HistoryLayerManager.subscribe((stack) => {
this.syncHistoryLayers(stack)
}))
const tab = new URLSearchParams(window.location.search).get("tab") const tab = new URLSearchParams(window.location.search).get("tab")
if (tab === "done") { if (tab === "done") {
this.$set(this.pageForm, "approval", true) this.$set(this.pageForm, "approval", true)
@@ -453,6 +596,12 @@ layout("/layouts/platform_h5.html"){
this.$set(this.pageForm, "approval", false) this.$set(this.pageForm, "approval", false)
this.$set(this.pageForm, "approvalText", "0") this.$set(this.pageForm, "approvalText", "0")
} }
},
beforeDestroy() {
if (this.unsubscribeHistoryLayer) this.unsubscribeHistoryLayer()
if (window.h5HistoryLayerManager.pageKey === this.historyLayerPageKey) {
window.h5HistoryLayerManager.unregister(this.historyLayerPageKey)
}
} }
}) })
</script> </script>
@@ -1,28 +1,58 @@
const transfer = { const transfer = {
template: /*language=HTML*/ ` template: /*language=HTML*/ `
<van-popup v-model="showTransferDialog" position="right" class="proposal-full-popup" :close-on-click-overlay="false" get-container="#app"> <van-popup v-model="showTransferDialog" position="right" class="proposal-full-popup proposal-transfer-dialog" :close-on-click-overlay="false" get-container="#app">
<div class="proposal-full-popup__page"> <div class="proposal-full-popup__page">
<van-nav-bar @click-left="closeTransfer" left-arrow left-text="返回" title="提案转办" placeholder fixed></van-nav-bar> <van-nav-bar @click-left="closeTransfer" left-arrow left-text="返回" title="提案转办" placeholder fixed></van-nav-bar>
<div class="proposal-full-popup__scroll transfer-dialog"> <div class="proposal-full-popup__scroll transfer-dialog">
<div class="proposal-info-card"> <section class="proposal-view-card proposal-transfer-info-card">
<div class="info-item"><label>提案编号</label><span>{{transferForm.code}}</span></div> <van-cell-group :border="false">
<div class="info-item"><label>提案名称</label><span>{{transferForm.name}}</span></div> <van-cell title="提案编号">{{transferForm.code || '--'}}</van-cell>
<div class="info-item"><label>代表团</label><span>{{transferForm.delegationName}}</span></div> <van-cell title="提案名称">{{transferForm.name || '--'}}</van-cell>
</div> <van-cell title="代表团">{{transferForm.delegationName || '--'}}</van-cell>
<div class="proposal-info-card"> </van-cell-group>
</section>
<section class="proposal-view-card proposal-transfer-user-card">
<van-cell-group :border="false">
<van-field v-model="searchKeyword" label="转交给" placeholder="搜索用户" required readonly is-link @click="openUserList"></van-field> <van-field v-model="searchKeyword" label="转交给" placeholder="搜索用户" required readonly is-link @click="openUserList"></van-field>
</van-cell-group>
</section>
</div>
<div class="button-group proposal-transfer-button-group">
<van-button block type="info" class="proposal-soft-button" @click="handleTransfer">提交</van-button>
<van-button block plain class="proposal-transfer-cancel-button" @click="closeTransfer">返回</van-button>
</div> </div>
</div> </div>
<div class="button-group proposal-transfer-button-group"><van-button block plain class="proposal-transfer-cancel-button" @click="closeTransfer">取消</van-button><van-button block type="info" class="proposal-soft-button" @click="handleTransfer"></van-button></div> <van-popup v-model="showUserList" position="right" class="proposal-full-popup proposal-transfer-user-popup" :close-on-click-overlay="false" get-container="#app">
</div>
<van-popup v-model="showUserList" position="right" class="proposal-full-popup" :close-on-click-overlay="false" get-container="#app">
<div class="proposal-full-popup__page"> <div class="proposal-full-popup__page">
<van-nav-bar @click-left="closeUserList" left-arrow left-text="返回" title="选择转交人" placeholder fixed></van-nav-bar> <van-nav-bar @click-left="closeUserList" left-arrow left-text="返回" title="选择转交人" placeholder fixed></van-nav-bar>
<div class="proposal-full-popup__scroll"> <van-search
<van-search v-model="keyword" placeholder="搜索用户" @search="selectTransferUser(keyword)"></van-search> v-model="keyword"
<div class="proposal-view-main"> class="proposal-transfer-user-search"
<div v-if="transferUserOptions.length" class="proposal-info-card"><van-cell v-for="item in transferUserOptions" :key="item.id" :title="item.username" @click="selectUser(item)"></van-cell></div> shape="round"
<van-empty v-else class="empty-tip">请搜索需要转办的用户</van-empty> clearable
placeholder="请输入姓名或工号搜索"
@search="selectTransferUser(keyword)"
@clear="resetTransferUserSearch">
</van-search>
<div class="proposal-transfer-user-scroll">
<van-cell-group v-if="transferUserOptions.length" class="proposal-transfer-user-list" :border="false">
<van-cell
v-for="item in transferUserOptions"
:key="item.id"
class="proposal-transfer-user-item"
clickable
@click="selectUser(item)">
<span slot="title" class="proposal-transfer-user-item__name">
{{item.username}}<span class="proposal-transfer-user-item__unit">{{item.unitName || '--'}}</span>
</span>
</van-cell>
</van-cell-group>
<div v-else class="proposal-transfer-user-empty">
<img src="/assets/mobile/img/proposal/proposal-empty.png" alt="暂无转交用户插画">
<div class="proposal-transfer-user-empty__title">
{{hasUserSearched ? '暂无搜索记录' : '请搜索需要转办的用户'}}
</div>
<div v-if="hasUserSearched" class="proposal-transfer-user-empty__hint">请调整搜索条件后重试</div>
</div> </div>
</div> </div>
</div> </div>
@@ -31,7 +61,7 @@ const transfer = {
</van-popup> </van-popup>
`, `,
data() { data() {
return {showTransferDialog:false,transferForm:{},transferUserOptions:[],searchKeyword:"",showUserList:false,keyword:"",confirmVisible:false,historyLayerPageKey:"proposal-unit-reply-transfer",unsubscribeHistoryLayer:null} return {showTransferDialog:false,transferForm:{},transferUserOptions:[],searchKeyword:"",showUserList:false,keyword:"",hasUserSearched:false,confirmVisible:false,historyLayerPageKey:"proposal-unit-reply-transfer",unsubscribeHistoryLayer:null}
}, },
computed: { computed: {
confirmMessage() { confirmMessage() {
@@ -43,12 +73,15 @@ const transfer = {
syncLayers(stack) { this.$set(this,"showTransferDialog",stack.includes("transfer")); this.$set(this,"showUserList",stack.includes("user-list")); this.$set(this,"confirmVisible",stack.includes("confirm")) }, syncLayers(stack) { this.$set(this,"showTransferDialog",stack.includes("transfer")); this.$set(this,"showUserList",stack.includes("user-list")); this.$set(this,"confirmVisible",stack.includes("confirm")) },
openTransfer(row) { this.$set(this,"transferForm",{taskId:row.taskId,name:row.name,code:row.code,delegationName:row.delegationName}); this.$set(this,"searchKeyword",""); window.h5HistoryLayerManager.open("transfer") }, openTransfer(row) { this.$set(this,"transferForm",{taskId:row.taskId,name:row.name,code:row.code,delegationName:row.delegationName}); this.$set(this,"searchKeyword",""); window.h5HistoryLayerManager.open("transfer") },
closeTransfer() { window.h5HistoryLayerManager.close("transfer") }, closeTransfer() { window.h5HistoryLayerManager.close("transfer") },
openUserList() { window.h5HistoryLayerManager.open("user-list") }, /** 打开用户列表前重置搜索条件和结果,确保首次进入展示搜索引导空状态。 */
openUserList() { this.resetTransferUserSearch(); window.h5HistoryLayerManager.open("user-list") },
closeUserList() { window.h5HistoryLayerManager.close("user-list") }, closeUserList() { window.h5HistoryLayerManager.close("user-list") },
closeConfirm() { window.h5HistoryLayerManager.close("confirm") }, closeConfirm() { window.h5HistoryLayerManager.close("confirm") },
selectUser(item) { this.$set(this.transferForm,"userId",item.id); this.$set(this,"searchKeyword",item.username); this.closeUserList() }, selectUser(item) { this.$set(this.transferForm,"userId",item.id); this.$set(this,"searchKeyword",item.username); this.closeUserList() },
/** keyword 为姓名关键字,接口返回可转交用户数组。 */ /** 清空姓名或工号关键词、搜索状态和人员结果,无返回值。 */
selectTransferUser(keyword) { this.$axios.post("/platform/proposal/unitReply/selectViceUser",{keyword:keyword}).then((res) => { if (res.code === 0) this.$set(this,"transferUserOptions",res.data || []) }) }, resetTransferUserSearch() { this.$set(this,"keyword",""); this.$set(this,"hasUserSearched",false); this.$set(this,"transferUserOptions",[]) },
/** keyword 为姓名或工号关键词;空值仅恢复搜索引导,非空值查询并返回可转交用户数组。 */
selectTransferUser(keyword) { const searchText=(keyword || "").trim(); if (!searchText) { this.resetTransferUserSearch(); return } this.$set(this,"hasUserSearched",true); this.$axios.post("/platform/proposal/unitReply/selectViceUser",{keyword:searchText}).then((res) => { if (res.code === 0) this.$set(this,"transferUserOptions",res.data || []) }) },
handleTransfer() { if (!this.transferForm.userId) { this.$toast("请选择转交用户"); return } window.h5HistoryLayerManager.open("confirm") }, handleTransfer() { if (!this.transferForm.userId) { this.$toast("请选择转交用户"); return } window.h5HistoryLayerManager.open("confirm") },
/** 提交 taskId 和 userId 完成转办,成功后清理弹层并通知父列表刷新。 */ /** 提交 taskId 和 userId 完成转办,成功后清理弹层并通知父列表刷新。 */
confirmTransfer() { this.$axios.post("/platform/proposal/unitReply/transfer",this.transferForm).then((res) => { if (res.code === 0) { this.$toast.success(res.msg); window.h5HistoryLayerManager.closeAll(() => this.$emit("success")) } }) } confirmTransfer() { this.$axios.post("/platform/proposal/unitReply/transfer",this.transferForm).then((res) => { if (res.code === 0) { this.$toast.success(res.msg); window.h5HistoryLayerManager.closeAll(() => this.$emit("success")) } }) }
@@ -278,11 +278,6 @@ layout("/layouts/platform_h5.html"){
.welfare-list-card__choices-arrow { .welfare-list-card__choices-arrow {
margin-left: 10px; margin-left: 10px;
flex: none; flex: none;
transition: transform .2s ease;
}
.welfare-list-card__choices-arrow.is-expanded {
transform: rotate(90deg);
} }
.welfare-list-card__choices > .welfare-inline-loading, .welfare-list-card__choices > .welfare-inline-loading,
@@ -446,32 +441,6 @@ layout("/layouts/platform_h5.html"){
white-space: nowrap; white-space: nowrap;
} }
.welfare-list-card__actions {
display: flex;
min-height: 42px;
margin-top: 10px;
padding-top: 10px;
align-items: center;
justify-content: flex-end;
border-top: 1px solid #edf1f6;
box-sizing: border-box;
}
.welfare-list-card__detail-button {
display: inline-flex;
height: 30px;
padding: 0 14px;
align-items: center;
justify-content: center;
border: 1px solid #1989fa;
border-radius: 16px;
color: #1989fa;
background-color: #fff;
font-size: 13px;
line-height: 28px;
box-sizing: border-box;
}
.welfare-list-card__time .van-icon { .welfare-list-card__time .van-icon {
margin-right: 5px; margin-right: 5px;
flex: none; flex: none;
@@ -486,76 +455,6 @@ layout("/layouts/platform_h5.html"){
font-size: 17px; font-size: 17px;
} }
.welfare-list-card__expanded {
padding: 12px;
border-top: 1px solid #edf1f6;
background-color: #f8fbff;
}
.welfare-inline-card {
margin-bottom: 10px;
padding: 0 12px;
overflow: hidden;
border-radius: 10px;
background-color: #fff;
box-shadow: 0 4px 14px rgba(55, 85, 126, .06);
}
.welfare-inline-card:last-child {
margin-bottom: 0;
}
.welfare-inline-card__title {
position: relative;
height: 42px;
padding-left: 8px;
border-bottom: 1px solid #edf1f6;
color: #263548;
font-size: 14px;
font-weight: 600;
line-height: 42px;
}
.welfare-inline-card__title::before {
position: absolute;
top: 14px;
bottom: 14px;
left: 0;
width: 3px;
border-radius: 2px;
background-color: #1989fa;
content: '';
}
.welfare-inline-row {
display: flex;
min-height: 40px;
padding: 6px 0;
align-items: center;
border-bottom: 1px solid #edf1f6;
box-sizing: border-box;
}
.welfare-inline-row:last-child {
border-bottom: 0;
}
.welfare-inline-row__label {
width: 72px;
flex: none;
color: #7f8da1;
font-size: 12px;
}
.welfare-inline-row__value {
min-width: 0;
flex: 1;
color: #263548;
font-size: 13px;
line-height: 19px;
word-break: break-word;
}
.welfare-inline-selection { .welfare-inline-selection {
display: flex; display: flex;
min-height: 72px; min-height: 72px;
@@ -780,8 +679,10 @@ layout("/layouts/platform_h5.html"){
v-for="row in tableData" v-for="row in tableData"
:key="row.id" :key="row.id"
class="welfare-list-card" class="welfare-list-card"
role="button"
@click="modifySelection(row)"
> >
<div class="welfare-list-card__summary" @click="toggleRow(row)"> <div class="welfare-list-card__summary">
<div class="welfare-list-card__order-head"> <div class="welfare-list-card__order-head">
<div class="welfare-list-card__order-info"> <div class="welfare-list-card__order-info">
<div class="welfare-list-card__title">{{ row.name }}</div> <div class="welfare-list-card__title">{{ row.name }}</div>
@@ -812,66 +713,9 @@ layout("/layouts/platform_h5.html"){
</template> </template>
<div v-else class="welfare-inline-empty">暂无福利选择记录</div> <div v-else class="welfare-inline-empty">暂无福利选择记录</div>
</div> </div>
<van-icon class="welfare-list-card__arrow welfare-list-card__choices-arrow" :class="{'is-expanded': row.expanded}" name="arrow"></van-icon> <van-icon class="welfare-list-card__arrow welfare-list-card__choices-arrow" name="arrow"></van-icon>
</div> </div>
</div> </div>
<div class="welfare-list-card__expanded" v-show="row.expanded">
<div class="welfare-inline-loading" v-if="row.detailLoading">
<van-loading size="20" color="#1989fa">加载中...</van-loading>
</div>
<template v-else-if="row.projectInfo">
<section class="welfare-inline-card">
<div class="welfare-inline-card__title">项目基本信息</div>
<div class="welfare-inline-row">
<span class="welfare-inline-row__label">项目名称</span>
<span class="welfare-inline-row__value">{{ row.projectInfo.name || row.name }}</span>
</div>
<div class="welfare-inline-row">
<span class="welfare-inline-row__label">选择时间</span>
<span class="welfare-inline-row__value">{{ formatRange(row.projectInfo.choiceTimeStart, row.projectInfo.choiceTimeEnd) }}</span>
</div>
<div class="welfare-inline-row">
<span class="welfare-inline-row__label">发放时间</span>
<span class="welfare-inline-row__value">{{ formatRange(row.projectInfo.provideTimeStart, row.projectInfo.provideTimeEnd) }}</span>
</div>
<div class="welfare-inline-row">
<span class="welfare-inline-row__label">发放地点</span>
<span class="welfare-inline-row__value">{{ row.projectInfo.provideAddress || '暂无' }}</span>
</div>
</section>
<section class="welfare-inline-card">
<div class="welfare-inline-card__title">联系信息</div>
<div class="welfare-inline-row">
<span class="welfare-inline-row__label">联系电话</span>
<span class="welfare-inline-row__value">{{ firstSelection(row).mobile || '暂无' }}</span>
</div>
<div class="welfare-inline-row">
<span class="welfare-inline-row__label">收货地址</span>
<span class="welfare-inline-row__value">{{ firstSelection(row).receiveAddress || '暂无' }}</span>
</div>
</section>
<section class="welfare-inline-card">
<div class="welfare-inline-card__title">我的选择</div>
<div v-if="row.selections.length > 0">
<div class="welfare-inline-selection" v-for="item in row.selections" :key="item.selectId || item.id">
<van-image class="welfare-inline-selection__image" :src="item.imgUrl" fit="cover" @click.stop="previewSelectionImage(item.imgUrl)">
<template #error><van-icon name="photo-o" size="24"></van-icon></template>
</van-image>
<div class="welfare-inline-selection__content">
<div class="welfare-inline-selection__name">{{ item.optionName || '未知选项' }}</div>
<div class="welfare-inline-selection__meta">
<span class="welfare-inline-selection__tag">× {{ item.selectNum || 0 }}</span>
</div>
</div>
</div>
</div>
<div v-else class="welfare-inline-empty">暂无福利选择记录</div>
</section>
</template>
</div>
</article> </article>
</div> </div>
</van-list> </van-list>
@@ -928,10 +772,8 @@ layout("/layouts/platform_h5.html"){
if (res.code !== 0 || !res.data) return if (res.code !== 0 || !res.data) return
const list = (res.data.list || []).map((row) => ({ const list = (res.data.list || []).map((row) => ({
...row, ...row,
expanded: false,
detailLoading: false, detailLoading: false,
detailLoaded: false, detailLoaded: false,
projectInfo: null,
selections: [] selections: []
})) }))
this.tableData = this.tableData.concat(list) this.tableData = this.tableData.concat(list)
@@ -993,7 +835,6 @@ layout("/layouts/platform_h5.html"){
description: option.description description: option.description
} }
}) : [] }) : []
this.$set(row, "projectInfo", projectInfo)
this.$set(row, "selections", selections) this.$set(row, "selections", selections)
this.$set(row, "detailLoaded", true) this.$set(row, "detailLoaded", true)
} finally { } finally {
@@ -1001,36 +842,8 @@ layout("/layouts/platform_h5.html"){
} }
}, },
/** modifySelection(row) {
* 展开或收起当前福利卡片的内容详情。 this.$pjaxReplace("/platform/h5/welfare/userSelect/index?id=" + (row.projectId || row.id) + "&from=mine")
* @param row 当前福利项目
*/
toggleRow(row) {
row.expanded = !row.expanded
},
previewSelectionImage(imageUrl) {
if (imageUrl) vant.ImagePreview([imageUrl])
},
/**
* 获取当前福利的首条选择记录,用于展示联系电话和收货地址。
* @param row 当前福利项目
* @return {Object} 首条选择记录;无记录时返回空对象
*/
firstSelection(row) {
return row.selections && row.selections.length > 0 ? row.selections[0] : {}
},
/**
* 格式化项目起止时间。
* @param startTime 开始时间
* @param endTime 结束时间
* @return {string} 起止时间文本
*/
formatRange(startTime, endTime) {
if (!startTime || !endTime) return "暂无"
return this.$moment(startTime).format("YYYY-MM-DD HH:mm") + " 至 " + this.$moment(endTime).format("YYYY-MM-DD HH:mm")
}, },
/** /**
@@ -26,6 +26,10 @@ layout("/layouts/platform_h5.html"){
background: var(--bg-color); background: var(--bg-color);
} }
.page-container.has-footer-contact {
padding-bottom: calc(126px + env(safe-area-inset-bottom));
}
.page-container .van-nav-bar { .page-container .van-nav-bar {
background-color: #fff; background-color: #fff;
} }
@@ -383,27 +387,6 @@ layout("/layouts/platform_h5.html"){
text-align: center; text-align: center;
} }
.welfare-option-selected-mark {
position: absolute;
right: 0;
top: 0;
z-index: 2;
display: flex;
width: 32px;
height: 32px;
padding: 0 0 4px 4px;
align-items: center;
justify-content: center;
border-radius: 0 11px 0 18px;
color: #fff;
background: #35b878;
box-shadow: none;
box-sizing: border-box;
font-size: 14px;
line-height: 32px;
text-align: center;
}
.welfare-deadline { .welfare-deadline {
margin: 0 12px 8px; margin: 0 12px 8px;
padding: 9px 11px; padding: 9px 11px;
@@ -742,10 +725,6 @@ layout("/layouts/platform_h5.html"){
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
} }
.van-sticky--fixed .welfare-section-title {
margin-top: 8px;
}
.welfare-section-title .title-text { .welfare-section-title .title-text {
position: relative; position: relative;
display: flex; display: flex;
@@ -773,10 +752,482 @@ layout("/layouts/platform_h5.html"){
font-weight: 600; font-weight: 600;
} }
/* 项目封面保持单图全宽展示,下拉时通过动态高度逐步露出完整画面。 */
.welfare-header {
height: 232px;
margin: 0;
overflow: hidden;
border-radius: 0;
box-shadow: none;
transition: height .28s ease-out;
}
.welfare-header.is-pulling {
transition: none;
}
.welfare-header.is-expanded {
background-color: #f3f7fd;
}
.welfare-header-img .van-image__img {
object-position: center;
}
.welfare-cover-pull-hint {
position: absolute;
right: 0;
bottom: 28px;
left: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, .92);
font-size: 12px;
line-height: 18px;
text-shadow: 0 1px 5px rgba(20, 50, 88, .5);
pointer-events: none;
}
.welfare-cover-pull-hint::before {
position: absolute;
right: 0;
bottom: -28px;
left: 0;
z-index: -1;
height: 76px;
background: linear-gradient(180deg, rgba(18, 45, 82, 0), rgba(18, 45, 82, .42));
content: "";
}
.welfare-cover-pull-hint .van-icon {
margin-right: 6px;
font-size: 16px;
}
.main-content {
z-index: 3;
}
.welfare-project-card {
margin: -20px 12px 12px;
padding: 16px;
border-radius: 14px;
background-color: #fff;
box-shadow: 0 8px 24px rgba(50, 83, 126, .12);
box-sizing: border-box;
}
.welfare-project-card__head {
display: flex;
min-width: 0;
align-items: center;
}
.welfare-project-card__title-wrap {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
flex-wrap: wrap;
}
.welfare-project-card__title {
min-width: 0;
overflow: hidden;
color: var(--text-primary);
font-size: 18px;
line-height: 25px;
text-overflow: ellipsis;
white-space: nowrap;
}
.welfare-project-card__festival {
height: 22px;
margin-left: 8px;
padding: 0 7px;
flex: none;
border: 1px solid #acd3ff;
border-radius: 5px;
color: #1989fa;
background-color: #f4f9ff;
font-size: 12px;
line-height: 20px;
box-sizing: border-box;
}
.welfare-project-card__status {
display: flex;
margin-top: 8px;
align-items: center;
color: #1989fa;
font-size: 12px;
line-height: 18px;
}
.welfare-project-card__status::before {
width: 6px;
height: 6px;
margin-right: 5px;
border-radius: 50%;
background-color: currentColor;
content: "";
}
.welfare-project-card__status.is-ended {
color: #a8b0bc;
}
.welfare-project-card__facts {
display: grid;
margin-top: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.welfare-project-fact {
display: flex;
min-width: 0;
padding: 10px;
align-items: center;
border-radius: 8px;
background: #f7faff;
box-sizing: border-box;
}
.welfare-project-fact__icon {
display: flex;
width: 32px;
height: 32px;
margin-right: 9px;
flex: none;
align-items: center;
justify-content: center;
border-radius: 8px;
color: #fff;
background: #4c9cff;
box-shadow: 0 4px 10px rgba(25, 137, 250, .18);
font-size: 18px;
}
.welfare-project-fact__content {
min-width: 0;
}
.welfare-project-fact__label {
color: #8491a5;
font-size: 11px;
line-height: 17px;
}
.welfare-project-fact__value {
margin-top: 2px;
overflow: hidden;
color: #1989fa;
font-size: 13px;
font-weight: 600;
line-height: 19px;
text-overflow: ellipsis;
white-space: nowrap;
}
.welfare-project-card__meta {
display: flex;
min-width: 0;
margin-top: 10px;
align-items: center;
}
.welfare-project-card__address {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
overflow: hidden;
color: #4c596b;
font-size: 13px;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.welfare-project-card__address .van-icon {
margin-right: 5px;
flex: none;
color: #66758a;
font-size: 15px;
}
.welfare-project-card__tags {
display: flex;
margin-top: 8px;
flex-wrap: wrap;
column-gap: 5px;
row-gap: 5px;
}
.welfare-project-card__tag {
height: 22px;
padding: 0 7px;
border: 1px solid #b8d9ff;
border-radius: 4px;
color: #1989fa;
background-color: #f7fbff;
font-size: 11px;
line-height: 20px;
box-sizing: border-box;
}
.welfare-deadline {
margin-top: 0;
}
.welfare-section-title {
min-height: 42px;
margin: 0 12px 8px;
padding: 7px 0;
border-radius: 0;
background: rgba(243, 247, 253, .96);
box-shadow: none;
}
.welfare-section-title .title-text {
align-self: stretch;
}
.welfare-section-title .title-label {
color: var(--text-primary);
font-size: 16px;
line-height: 28px;
}
.welfare-section-title .title-count {
margin-left: 5px;
color: #7d899b;
font-size: 13px;
}
.welfare-option {
border-color: #e2eaf4;
box-shadow: none;
}
.welfare-option.selected {
border-color: #c5dfff;
background: linear-gradient(90deg, #f5faff 0%, #fff 100%);
box-shadow: none;
}
.welfare-option-main {
min-height: 128px;
}
.welfare-option-image {
width: 94px;
height: 94px;
margin: 16px 0 16px 14px;
}
.welfare-option-content {
padding: 14px 12px 10px;
}
.welfare-option-title {
margin-bottom: 4px;
}
.welfare-option-supplier {
overflow: hidden;
margin-bottom: 4px;
color: #7a8799;
font-size: 11px;
line-height: 17px;
text-overflow: ellipsis;
white-space: nowrap;
}
.welfare-option-rank {
margin-bottom: 5px;
}
.welfare-option-desc {
-webkit-line-clamp: 1;
}
.welfare-option-meta {
display: flex;
min-width: 0;
margin-top: 7px;
align-items: center;
overflow: hidden;
}
.welfare-option-meta__tags {
display: flex;
min-width: 0;
flex: 1;
column-gap: 5px;
overflow: hidden;
}
.welfare-option-meta__tag {
height: 19px;
padding: 0 5px;
flex: none;
border: 1px solid #b8d9ff;
border-radius: 3px;
color: #1989fa;
background-color: #f7fbff;
font-size: 10px;
line-height: 17px;
box-sizing: border-box;
}
.welfare-option-meta__total {
margin-left: 6px;
flex: none;
color: #8a96a8;
font-size: 10px;
line-height: 18px;
}
.welfare-option-select-row {
min-height: 44px;
padding: 7px 12px;
justify-content: space-between;
background: transparent;
}
.welfare-footer {
display: block;
}
.welfare-footer.has-contact {
padding-top: 8px;
padding-bottom: calc(8px + env(safe-area-inset-bottom));
}
.welfare-footer__actions {
display: flex;
align-items: center;
column-gap: 12px;
}
.welfare-footer__selection {
min-width: 92px;
color: #66758a;
font-size: 13px;
white-space: nowrap;
}
.welfare-footer__selection strong {
margin-left: 3px;
color: #1989fa;
font-size: 20px;
font-weight: 600;
}
.welfare-footer__contact {
display: flex;
min-width: 0;
min-height: 50px;
margin-bottom: 8px;
padding: 5px 8px;
align-items: center;
color: #66758a;
background-color: #fff;
font-size: 12px;
line-height: 18px;
box-sizing: border-box;
}
.welfare-footer__contact-icon {
display: flex;
width: 36px;
height: 36px;
margin-right: 10px;
flex: none;
align-items: center;
justify-content: center;
border-radius: 8px;
color: #1989fa;
background-color: #edf5ff;
font-size: 20px;
}
.welfare-footer__contact-info {
min-width: 0;
flex: 1;
}
.welfare-footer__contact-primary,
.welfare-footer__contact-address {
display: flex;
min-width: 0;
align-items: baseline;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.welfare-footer__contact-primary {
color: #1f2937;
font-size: 12px;
line-height: 20px;
}
.welfare-footer__contact-address {
margin-top: 2px;
font-size: 11px;
line-height: 16px;
}
.welfare-footer__contact-label {
margin-right: 3px;
flex: none;
color: #929cab;
}
.welfare-footer__contact-name,
.welfare-footer__contact-mobile,
.welfare-footer__contact-address-value {
min-width: 0;
overflow: hidden;
color: #1f2937;
font-weight: 500;
text-overflow: ellipsis;
}
.welfare-footer__contact-name {
flex: none;
}
.welfare-footer__contact-mobile {
margin-left: 12px;
flex: none;
}
.welfare-footer__contact-address-value {
flex: 1;
}
.welfare-submit-btn {
width: auto;
flex: 1;
}
.welfare-submit-btn.is-deadline-passed.van-button--disabled {
color: #a8b0bc;
background: #edf0f4;
border-color: #edf0f4;
opacity: 1;
}
</style> </style>
<div id="app" v-cloak> <div id="app" v-cloak>
<div class="page-container"> <div class="page-container" :class="{'has-footer-contact': fromMine && hasSubmittedBefore}">
<van-nav-bar title="福利选择" left-text="返回" left-arrow @click-left="handlePageBack" fixed <van-nav-bar title="福利选择" left-text="返回" left-arrow @click-left="handlePageBack" fixed
placeholder></van-nav-bar> placeholder></van-nav-bar>
@@ -794,8 +1245,17 @@ layout("/layouts/platform_h5.html"){
<van-empty v-else-if="!projectInfo.id" description="暂无福利信息"></van-empty> <van-empty v-else-if="!projectInfo.id" description="暂无福利信息"></van-empty>
<!-- 项目头部信息 --> <!-- 项目头部信息 -->
<div class="welfare-header" v-if="!pageLoading && projectInfo.id"> <div
<van-image v-if="projectInfo.cover" :src="projectInfo.cover" class="welfare-header-img" fit="cover"> class="welfare-header"
:class="{'is-pulling': coverPullEnabled, 'is-expanded': coverExpanded}"
:style="coverHeaderStyle"
v-if="!pageLoading && projectInfo.id"
@touchstart="handleCoverTouchStart"
@touchmove="handleCoverTouchMove"
@touchend="handleCoverTouchEnd"
@touchcancel="handleCoverTouchEnd"
>
<van-image v-if="projectInfo.cover" :src="projectInfo.cover" class="welfare-header-img" :fit="coverImageFit">
<template #error> <template #error>
<div class="welfare-image-state"> <div class="welfare-image-state">
<van-icon name="photo-o"></van-icon> <van-icon name="photo-o"></van-icon>
@@ -807,24 +1267,67 @@ layout("/layouts/platform_h5.html"){
<van-icon name="photo-o"></van-icon> <van-icon name="photo-o"></van-icon>
<span>暂无图片</span> <span>暂无图片</span>
</div> </div>
<div class="welfare-cover-pull-hint" v-if="projectInfo.cover">
<van-icon :name="coverExpanded ? 'arrow-up' : 'arrow-down'"></van-icon>
<span>{{ coverExpanded ? '上滑收起封面' : '下拉查看完整封面' }}</span>
</div>
</div> </div>
<div class="main-content" v-if="!pageLoading && projectInfo.id"> <div class="main-content" v-if="!pageLoading && projectInfo.id">
<section class="welfare-project-card"
@touchstart="handleCoverTouchStart"
@touchmove="handleCoverTouchMove"
@touchend="handleCoverTouchEnd"
@touchcancel="handleCoverTouchEnd">
<div class="welfare-project-card__head">
<div class="welfare-project-card__title-wrap">
<div class="welfare-project-card__title">{{ projectInfo.name }}</div>
<span class="welfare-project-card__festival" v-if="projectInfo.festival">{{ projectInfo.festival }}</span>
</div>
</div>
<div class="welfare-project-card__status" :class="{'is-ended': isDeadlinePassed}">{{ projectChoiceStatus }}</div>
<div class="welfare-project-card__facts">
<div class="welfare-project-fact">
<van-icon class="welfare-project-fact__icon" name="calendar-o"></van-icon>
<div class="welfare-project-fact__content">
<div class="welfare-project-fact__label">选择截止</div>
<div class="welfare-project-fact__value">{{ formatProjectDateTime(projectInfo.choiceTimeEnd) }}</div>
</div>
</div>
<div class="welfare-project-fact">
<van-icon class="welfare-project-fact__icon" name="clock-o"></van-icon>
<div class="welfare-project-fact__content">
<div class="welfare-project-fact__label">发放时间</div>
<div class="welfare-project-fact__value">{{ formatProjectDateRange(projectInfo.provideTimeStart, projectInfo.provideTimeEnd) }}</div>
</div>
</div>
</div>
<div class="welfare-project-card__meta">
<div class="welfare-project-card__address">
<van-icon name="location-o"></van-icon>
<span>发放地点:{{ projectInfo.provideAddress || '待定' }}</span>
</div>
</div>
<div class="welfare-project-card__tags">
<span class="welfare-project-card__tag">{{ projectInfo.isCheckBox === 'radio' ? '单选' : '多选' }}</span>
<span class="welfare-project-card__tag">最多{{ selectionLimit }}份</span>
<span class="welfare-project-card__tag" v-if="hasExpressOption">支持快递</span>
</div>
</section>
<!-- 截止时间提醒 --> <!-- 截止时间提醒 -->
<div class="welfare-deadline" v-if="isDeadlineSoon"> <div class="welfare-deadline" v-if="isDeadlineSoon">
<van-icon name="warning-o"/> <van-icon name="warning-o"/>
<span>选择截止时间即将到期,请尽快选择!</span> <span>选择截止时间即将到期,请尽快选择!</span>
</div> </div>
<!-- 标题到达顶部导航栏下方后固定,福利卡片继续在标题下方滚动。 -->
<van-sticky :offset-top="46" :z-index="30">
<div class="welfare-section-title"> <div class="welfare-section-title">
<div class="title-text"> <div class="title-text">
<span class="title-count">共 {{ projectInfo.options.length }} </span> <span class="title-label">福利选</span>
<span class="title-count">{{ projectInfo.options.length }}</span>
</div> </div>
<div class="title-selection">已选 <strong>{{ selectedCount }}</strong>/{{ selectionLimit }}</div> <div class="title-selection">已选 <strong>{{ selectedCount }}</strong>/{{ selectionLimit }}</div>
</div> </div>
</van-sticky>
<!-- 福利选项列表 --> <!-- 福利选项列表 -->
<div class="welfare-options-container"> <div class="welfare-options-container">
@@ -834,12 +1337,6 @@ layout("/layouts/platform_h5.html"){
class="welfare-option" class="welfare-option"
:class="{ selected: projectInfo.isCheckBox === 'radio' ? selectedRadioId === option.id : option.selectNum > 0 }" :class="{ selected: projectInfo.isCheckBox === 'radio' ? selectedRadioId === option.id : option.selectNum > 0 }"
> >
<van-icon
v-if="projectInfo.isCheckBox === 'radio' ? selectedRadioId === option.id : option.selectNum > 0"
class="welfare-option-selected-mark"
name="success"
></van-icon>
<div class="welfare-option-main"> <div class="welfare-option-main">
<div class="welfare-option-image" role="button" aria-label="查看福利图片" <div class="welfare-option-image" role="button" aria-label="查看福利图片"
@click.stop="previewOptionImage(option)"> @click.stop="previewOptionImage(option)">
@@ -862,6 +1359,10 @@ layout("/layouts/platform_h5.html"){
{{ option.optionName }} {{ option.optionName }}
</div> </div>
<div class="welfare-option-supplier" v-if="option.supplier || option.shoppingName">
供应商:{{ option.supplier || option.shoppingName }}
</div>
<!-- <div class="welfare-option-tag-row">--> <!-- <div class="welfare-option-tag-row">-->
<!-- <span class="welfare-option-type-tag">套餐</span>--> <!-- <span class="welfare-option-type-tag">套餐</span>-->
<!-- </div>--> <!-- </div>-->
@@ -880,10 +1381,21 @@ layout("/layouts/platform_h5.html"){
<van-icon name="arrow"/> <van-icon name="arrow"/>
</div> </div>
</div> </div>
<div class="welfare-option-meta">
<div class="welfare-option-meta__tags">
<span class="welfare-option-meta__tag" v-if="option.isExpressDelivery">支持快递</span>
<span class="welfare-option-meta__tag" v-if="option.isSystemDefault">系统默认</span>
</div>
<span class="welfare-option-meta__total" v-if="option.selectedTotal !== undefined && option.selectedTotal !== null">
累计选择 {{ option.selectedTotal }} 份
</span>
</div>
</div> </div>
</div> </div>
<div class="welfare-option-select-row" @click.stop> <div class="welfare-option-select-row" @click.stop>
<div class="welfare-option-select-label">选择数量</div>
<div class="welfare-option-checkbox"> <div class="welfare-option-checkbox">
<van-stepper <van-stepper
:key="'input-number-'+index+'-'+(option.selectNumKey || 0)" :key="'input-number-'+index+'-'+(option.selectNumKey || 0)"
@@ -906,12 +1418,29 @@ layout("/layouts/platform_h5.html"){
</div> </div>
<!-- 底部提交按钮 --> <!-- 底部提交按钮 -->
<div class="welfare-footer" v-if="!pageLoading && projectInfo.id"> <div class="welfare-footer" :class="{'has-contact': fromMine && hasSubmittedBefore}" v-if="!pageLoading && projectInfo.id">
<van-button type="primary" class="welfare-submit-btn" :disabled="submitButtonDisabled" <div class="welfare-footer__contact" v-if="fromMine && hasSubmittedBefore">
<div class="welfare-footer__contact-icon"><van-icon name="location"></van-icon></div>
<div class="welfare-footer__contact-info">
<div class="welfare-footer__contact-primary">
<span class="welfare-footer__contact-label">收货人:</span>
<span class="welfare-footer__contact-name">{{ formData.userName || '暂无' }}</span>
<span class="welfare-footer__contact-mobile">{{ formData.mobile || '暂无' }}</span>
</div>
<div class="welfare-footer__contact-address">
<span class="welfare-footer__contact-label">收货地址:</span>
<span class="welfare-footer__contact-address-value">{{ formData.receiveAddress || '暂无' }}</span>
</div>
</div>
</div>
<div class="welfare-footer__actions">
<div class="welfare-footer__selection">已选 <strong>{{ selectedCount }}</strong>/{{ selectionLimit }}</div>
<van-button type="primary" class="welfare-submit-btn" :class="{'is-deadline-passed': isDeadlinePassed}" :disabled="submitButtonDisabled"
@click="submitSelection" round> @click="submitSelection" round>
{{ isDeadlinePassed ? '已截止' : '确认选择' }} {{ isDeadlinePassed ? '已截止' : '确认选择' }}
</van-button> </van-button>
</div> </div>
</div>
<!-- 全屏确认页从右侧滑入;顶部导航栏固定,表单内容在中间区域独立滚动。 --> <!-- 全屏确认页从右侧滑入;顶部导航栏固定,表单内容在中间区域独立滚动。 -->
<van-popup v-model="showConfirmDialog" class="confirm-full-popup" position="right" <van-popup v-model="showConfirmDialog" class="confirm-full-popup" position="right"
@@ -1057,6 +1586,12 @@ layout("/layouts/platform_h5.html"){
projectInfo: {}, projectInfo: {},
userSelection: [], userSelection: [],
projectId: "", projectId: "",
fromMine: false,
coverPullStartY: 0,
coverPullStartDistance: 0,
coverPullDistance: 0,
coverPullEnabled: false,
coverExpanded: false,
selectedRadioId: "", // 单选模式下选中的选项ID selectedRadioId: "", // 单选模式下选中的选项ID
showConfirmDialog: false, showConfirmDialog: false,
isSubmitting: false, isSubmitting: false,
@@ -1069,6 +1604,7 @@ layout("/layouts/platform_h5.html"){
userSign: "", // 用户签名 userSign: "", // 用户签名
mobile: "", // 手机号码 mobile: "", // 手机号码
address: "",// 收货地址 address: "",// 收货地址
receiveAddress: "", // 已提交的收货地址
userName: "",//收货人 userName: "",//收货人
}, },
@@ -1080,6 +1616,34 @@ layout("/layouts/platform_h5.html"){
computed: { computed: {
coverHeaderStyle() {
const viewportWidth = Math.min(document.documentElement.clientWidth || window.innerWidth || 390, 430)
const baseHeight = Math.max(230, Math.min(Math.round(viewportWidth * 0.66), 280))
return {height: baseHeight + this.coverPullDistance + "px"}
},
coverMaxPullDistance() {
const viewportHeight = document.documentElement.clientHeight || window.innerHeight || 800
return Math.max(170, Math.min(Math.round(viewportHeight * 0.25), 220))
},
coverImageFit() {
return this.coverPullDistance > 48 ? "contain" : "cover"
},
projectChoiceStatus() {
const now = Date.now()
const startTime = this.projectInfo.choiceTimeStart ? new Date(this.projectInfo.choiceTimeStart).getTime() : null
const endTime = this.projectInfo.choiceTimeEnd ? new Date(this.projectInfo.choiceTimeEnd).getTime() : null
if (startTime && now < startTime) return "选择未开始"
if (endTime && now > endTime) return "选择已截止"
return "选择进行中"
},
hasExpressOption() {
return !!(this.projectInfo.options || []).find((option) => option.isExpressDelivery)
},
// 系统默认福利必须被保留,管理端限制每个项目最多配置一个。 // 系统默认福利必须被保留,管理端限制每个项目最多配置一个。
systemDefaultOption() { systemDefaultOption() {
if (!this.projectInfo.options) return null if (!this.projectInfo.options) return null
@@ -1152,6 +1716,64 @@ layout("/layouts/platform_h5.html"){
}, },
methods: { methods: {
/**
* H5 布局由 page-wrapper 承担实际滚动,页面逻辑统一读取该容器而不是 window。
*/
getPageScroller() {
return document.querySelector("body > .main-page .page-wrapper") || document.querySelector(".page-wrapper")
},
resetPageScroll() {
const pageScroller = this.getPageScroller()
if (pageScroller) pageScroller.scrollTop = 0
window.scrollTo(0, 0)
},
/**
* 仅在页面位于顶部时启用封面下拉,避免与正常列表滚动冲突。
*/
handleCoverTouchStart(event) {
const pageScroller = this.getPageScroller()
const scrollTop = pageScroller ? pageScroller.scrollTop : window.pageYOffset
if (scrollTop > 0 || !event.touches || !event.touches.length) return
this.coverPullStartY = event.touches[0].clientY
this.coverPullStartDistance = this.coverPullDistance
this.coverPullEnabled = true
},
/**
* 封面展开后保留展示高度,主题信息卡片随封面底边移动;反向上滑可逐步收起。
*/
handleCoverTouchMove(event) {
if (!this.coverPullEnabled || !event.touches || !event.touches.length) return
const distance = event.touches[0].clientY - this.coverPullStartY
if (this.coverPullStartDistance === 0 && distance <= 0) return
this.coverPullDistance = Math.max(0, Math.min(
this.coverPullStartDistance + Math.round(distance * 0.65),
this.coverMaxPullDistance
))
if (event.cancelable) event.preventDefault()
},
handleCoverTouchEnd() {
if (!this.coverPullEnabled) return
const shouldExpand = this.coverPullDistance >= this.coverMaxPullDistance * 0.5
this.coverPullEnabled = false
this.coverExpanded = shouldExpand
this.coverPullDistance = shouldExpand ? this.coverMaxPullDistance : 0
},
formatProjectDateTime(value) {
return value ? this.$moment(value).format("MM/DD HH:mm") : "待定"
},
formatProjectDateRange(startTime, endTime) {
if (!startTime && !endTime) return "待定"
if (!startTime) return this.$moment(endTime).format("MM/DD")
if (!endTime) return this.$moment(startTime).format("MM/DD")
return this.$moment(startTime).format("MM/DD") + "" + this.$moment(endTime).format("MM/DD")
},
/** /**
* 注册当前福利页面的弹层历史。 * 注册当前福利页面的弹层历史。
* 页面标识由固定前缀和福利项目 ID 组成;回调参数是当前弹层名称数组,无返回值。 * 页面标识由固定前缀和福利项目 ID 组成;回调参数是当前弹层名称数组,无返回值。
@@ -1724,6 +2346,7 @@ layout("/layouts/platform_h5.html"){
created() { created() {
this.projectId = GetQueryString("id") this.projectId = GetQueryString("id")
this.fromMine = GetQueryString("from") === "mine"
if (this.projectId) { if (this.projectId) {
this.registerHistoryLayers() this.registerHistoryLayers()
this.loadPageData() this.loadPageData()
@@ -1735,6 +2358,11 @@ layout("/layouts/platform_h5.html"){
}, 1500) }, 1500)
} }
}, },
mounted() {
this.$nextTick(() => {
this.resetPageScroll()
})
},
beforeDestroy() { beforeDestroy() {
if (window.h5HistoryLayerManager) { if (window.h5HistoryLayerManager) {
window.h5HistoryLayerManager.unregister(this.historyLayerPageKey) window.h5HistoryLayerManager.unregister(this.historyLayerPageKey)
@@ -265,35 +265,6 @@ layout("/layouts/platform_h5.html"){
white-space: nowrap; white-space: nowrap;
} }
.welfare-list-card__info {
display: flex;
min-width: 0;
margin-top: 8px;
align-items: center;
color: #718198;
font-size: 12px;
line-height: 18px;
}
.welfare-list-card__info .van-icon {
margin-right: 5px;
flex: none;
color: #8da2bd;
font-size: 14px;
}
.welfare-list-card__info-label {
flex: none;
}
.welfare-list-card__info-value {
min-width: 0;
overflow: hidden;
color: #42546c;
text-overflow: ellipsis;
white-space: nowrap;
}
.welfare-list-card__status { .welfare-list-card__status {
display: inline-flex; display: inline-flex;
min-width: 54px; min-width: 54px;
@@ -517,11 +488,6 @@ layout("/layouts/platform_h5.html"){
{{ getChoiceModeText(row) }} {{ getChoiceModeText(row) }}
</span> </span>
</div> </div>
<div class="welfare-list-card__info" v-if="row.gift">
<van-icon name="gift-o"></van-icon>
<span class="welfare-list-card__info-label">福利:</span>
<span class="welfare-list-card__info-value">{{ row.gift }}</span>
</div>
<div class="welfare-list-card__time" v-if="row.choiceTimeStart && row.choiceTimeEnd"> <div class="welfare-list-card__time" v-if="row.choiceTimeStart && row.choiceTimeEnd">
<van-icon name="clock-o"></van-icon> <van-icon name="clock-o"></van-icon>
<span>选择时间:{{ $moment(row.choiceTimeStart).format('MM/DD HH:mm') }} - {{ $moment(row.choiceTimeEnd).format('MM/DD HH:mm') }}</span> <span>选择时间:{{ $moment(row.choiceTimeStart).format('MM/DD HH:mm') }} - {{ $moment(row.choiceTimeEnd).format('MM/DD HH:mm') }}</span>
@@ -116,7 +116,6 @@ layout("/layouts/platform_h5.html"){
</van-cell> </van-cell>
<van-cell :border="false" :value="projectInfo.name" title="福利名称"></van-cell> <van-cell :border="false" :value="projectInfo.name" title="福利名称"></van-cell>
<van-cell :border="false" :value="projectInfo.festival" title="发放节日"></van-cell> <van-cell :border="false" :value="projectInfo.festival" title="发放节日"></van-cell>
<van-cell :border="false" :value="projectInfo.gift" title="福利礼品" v-if="!projectInfo.flexible"></van-cell>
<van-cell :border="false" title="选择形式">{{projectInfo.isCheckBox==='checkBox'?'多选':'单选'}}</van-cell> <van-cell :border="false" title="选择形式">{{projectInfo.isCheckBox==='checkBox'?'多选':'单选'}}</van-cell>
<van-cell <van-cell
:border="false" :border="false"
@@ -0,0 +1 @@
71eb5eaecf2b7fd91d9dd7409a56314b9f2f132b3dffbb9e13ee56d0999b8c59
@@ -0,0 +1 @@
0d9b4629144a1fd15ed01362ce0dc2c9c5246e4f57250dd95fb52dbbbb75c532
@@ -0,0 +1,150 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-project</artifactId>
<version>9.4.57.v20241219</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>jetty-client</artifactId>
<version>9.4.57.v20241219-cug-patch1</version>
<name>Jetty :: Asynchronous HTTP Client</name>
<properties>
<bundle-symbolic-name>${project.groupId}.client</bundle-symbolic-name>
<jetty.test.policy.loc>target/test-policy</jetty.test.policy.loc>
<spotbugs.onlyAnalyze>org.eclipse.client.*</spotbugs.onlyAnalyze>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>unpack</id>
<phase>generate-test-resources</phase>
<goals>
<goal>unpack</goal>
</goals>
<configuration>
<artifactItems>
<artifactItem>
<groupId>org.eclipse.jetty.toolchain</groupId>
<artifactId>jetty-test-policy</artifactId>
<version>${jetty-test-policy.version}</version>
<type>jar</type>
<overWrite>true</overWrite>
<includes>**/*.keystore,**/*.pem</includes>
<outputDirectory>${jetty.test.policy.loc}</outputDirectory>
</artifactItem>
</artifactItems>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<minimizeJar>true</minimizeJar>
<shadedArtifactAttached>true</shadedArtifactAttached>
<shadedClassifierName>hybrid</shadedClassifierName>
<artifactSet>
<includes>
<include>org.eclipse.jetty:jetty-http</include>
<include>org.eclipse.jetty:jetty-io</include>
<include>org.eclipse.jetty:jetty-util</include>
</includes>
</artifactSet>
<relocations>
<relocation>
<pattern>org.eclipse.jetty.http</pattern>
<shadedPattern>org.eclipse.jetty.client.shaded.http</shadedPattern>
</relocation>
<relocation>
<pattern>org.eclipse.jetty.io</pattern>
<shadedPattern>org.eclipse.jetty.client.shaded.io</shadedPattern>
</relocation>
<relocation>
<pattern>org.eclipse.jetty.util</pattern>
<shadedPattern>org.eclipse.jetty.client.shaded.util</shadedPattern>
</relocation>
</relocations>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-http</artifactId>
<version>9.4.57.v20241219-cug-patch1</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-io</artifactId>
<version>9.4.57.v20241219</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-jmx</artifactId>
<version>9.4.57.v20241219</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-server</artifactId>
<version>9.4.57.v20241219-cug-patch1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-security</artifactId>
<version>9.4.57.v20241219-cug-patch1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.kerby</groupId>
<artifactId>kerb-simplekdc</artifactId>
<version>2.1.0</version>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.jline</groupId>
<artifactId>jline</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- transitive dependency defined as a range and we don't want that -->
<dependency>
<groupId>net.minidev</groupId>
<artifactId>json-smart</artifactId>
<version>2.5.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.toolchain</groupId>
<artifactId>jetty-test-helper</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1 @@
989a013455688dc528a988c41514fbfece0e3354a54398af5a404cda582c94bd
@@ -0,0 +1 @@
dcfe694ef6f27d9d6268e2fb135a1c9a7aabdfa55e7d738fc8df79f573086027
@@ -0,0 +1 @@
90058e595b73d85b96e192b7e6806098f83467680a95466974f445d6706ed682
@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<artifactId>jetty-project</artifactId>
<groupId>org.eclipse.jetty</groupId>
<version>9.4.57.v20241219</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>jetty-http</artifactId>
<version>9.4.57.v20241219-cug-patch1</version>
<name>Jetty :: Http Utility</name>
<properties>
<bundle-symbolic-name>${project.groupId}.http</bundle-symbolic-name>
<spotbugs.onlyAnalyze>org.eclipse.jetty.http.*</spotbugs.onlyAnalyze>
</properties>
<dependencies>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-util</artifactId>
<version>9.4.57.v20241219</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-io</artifactId>
<version>9.4.57.v20241219</version>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.toolchain</groupId>
<artifactId>jetty-test-helper</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.felix</groupId>
<artifactId>maven-bundle-plugin</artifactId>
<extensions>true</extensions>
<configuration>
<instructions>
<Require-Capability>osgi.serviceloader; filter:="(osgi.serviceloader=org.eclipse.jetty.http.HttpFieldPreEncoder)";resolution:=optional;cardinality:=multiple, osgi.extender; filter:="(osgi.extender=osgi.serviceloader.processor)";resolution:=optional, osgi.extender; filter:="(osgi.extender=osgi.serviceloader.registrar)";resolution:=optional</Require-Capability>
<Provide-Capability>osgi.serviceloader; osgi.serviceloader=org.eclipse.jetty.http.HttpFieldPreEncoder</Provide-Capability>
</instructions>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<executions>
<execution>
<id>test-jar</id>
<goals>
<goal>test-jar</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<finalName>${jmhjar.name}</finalName>
<shadeTestJar>true</shadeTestJar>
<artifactSet>
<includes>
<include>org.openjdk.jmh:jmh-core</include>
</includes>
</artifactSet>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>org.openjdk.jmh.Main</mainClass>
</transformer>
</transformers>
<filters>
<filter>
<artifact>org.openjdk.jmh:jmh-core</artifact>
<includes>
<include>**</include>
</includes>
</filter>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1 @@
967fff3457fb62eb7485fe303cc4162edc1c02b15498c2f8401fbca263649ab8

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