59 changed files with 2165 additions and 637 deletions
@@ -19,6 +19,7 @@ RoleConstant {
SCHOOL_UNION_ADMIN("校工会管理员"),
SCHOOL_UNION_CHAIRMAN("校工会主席"),
SCHOOL_UNION_VICE_CHAIRMAN("校工会副主席"),
SCHOOL_UNION_FINANCE("校工会财务"),
SCHOOL_OFFICE_DIRECTOR("校工会办公室主任"),
SCHOOL_UNION_WELFARE_ADMIN("校工会福利管理员"),
SCHOOL_UNION_MEMBER_ADMIN("校工会会员管理员"),
@@ -6,6 +6,8 @@ import com.budwk.app.base.exception.BaseException;
import com.budwk.app.sys.models.Sys_file;
import com.budwk.app.sys.models.Sys_office_template;
import com.budwk.app.sys.utils.SysFileMinIoUtil;
import com.deepoove.poi.XWPFTemplate;
import com.deepoove.poi.config.Configure;
import com.deepoove.poi.data.ByteArrayPictureRenderData;
import com.deepoove.poi.data.PictureRenderData;
import com.deepoove.poi.data.PictureType;
@@ -18,6 +20,12 @@ import org.nutz.ioc.loader.annotation.IocBean;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.util.List;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -66,6 +74,88 @@ public class SysOfficeTemplateUtil {
return new ByteArrayInputStream(fileBytes);
}
/**
* 统一渲染 DOC/DOCX 模板,保留调用方的循环表格等策略;模板输入流由调用方关闭。
*/
public byte[] renderTemplate(InputStream input, Object data, Configure config, boolean pdf, String context) throws Exception {
if (input == null) throw new BaseException("模板文件不存在");
byte[] templateBytes = input.readAllBytes();
try (ByteArrayInputStream formatInput = new ByteArrayInputStream(templateBytes)) {
if (com.aspose.words.FileFormatUtil.detectFileFormat(formatInput).getLoadFormat() != com.aspose.words.LoadFormat.DOCX) {
try (ByteArrayInputStream source = new ByteArrayInputStream(templateBytes);
ByteArrayOutputStream converted = new ByteArrayOutputStream()) {
new com.aspose.words.Document(source).save(converted, com.aspose.words.SaveFormat.DOCX);
templateBytes = converted.toByteArray();
}
}
}
byte[] wordBytes;
try (ByteArrayInputStream templateInput = new ByteArrayInputStream(templateBytes);
ByteArrayOutputStream output = new ByteArrayOutputStream();
XWPFTemplate template = XWPFTemplate.compile(templateInput, config == null ? Configure.builder().build() : config)) {
template.render(data).write(output);
wordBytes = output.toByteArray();
} catch (Exception e) {
log.error("[Office模板渲染] Word模板渲染失败,context={}", context, e);
throw e;
}
log.info("[Office模板渲染] Word渲染完成,context={}, templateBytes={}, outputBytes={}", context, templateBytes.length, wordBytes.length);
if (!pdf) return wordBytes;
try (ByteArrayInputStream wordInput = new ByteArrayInputStream(wordBytes);
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
com.aspose.words.Document document = new com.aspose.words.Document(wordInput);
configureDocumentFonts(document, context);
com.aspose.words.PdfSaveOptions options = new com.aspose.words.PdfSaveOptions();
options.setFontEmbeddingMode(com.aspose.words.PdfFontEmbeddingMode.EMBED_ALL);
document.save(output, options);
log.info("[Office模板渲染] PDF转换完成,context={}, outputBytes={}", context, output.size());
return output.toByteArray();
} catch (Exception e) {
log.error("[Office模板渲染] PDF转换失败,context={}", context, e);
throw e;
}
}
private void configureDocumentFonts(com.aspose.words.Document document, String context) throws Exception {
com.aspose.words.FontSettings settings = new com.aspose.words.FontSettings();
List<com.aspose.words.FontSourceBase> sources = new ArrayList<>(List.of(settings.getFontsSources()));
Set<String> folders = new LinkedHashSet<>();
String windowsDir = System.getenv("WINDIR");
if (StrUtil.isNotBlank(windowsDir)) folders.add(new File(windowsDir, "Fonts").getPath());
String localAppData = System.getenv("LOCALAPPDATA");
if (StrUtil.isNotBlank(localAppData)) folders.add(new File(localAppData, "Microsoft/Windows/Fonts").getPath());
folders.add(new File(System.getProperty("user.dir"), "fonts").getPath());
for (String folder : folders) {
File directory = new File(folder);
log.info("[Office模板渲染] 字体目录,context={}, path={}, readable={}", context, folder, directory.isDirectory() && directory.canRead());
if (directory.isDirectory() && directory.canRead()) sources.add(new com.aspose.words.FolderFontSource(folder, true));
}
settings.setFontsSources(sources.toArray(new com.aspose.words.FontSourceBase[0]));
Set<String> availableFonts = new LinkedHashSet<>();
for (com.aspose.words.FontSourceBase source : sources) {
for (com.aspose.words.PhysicalFontInfo font : source.getAvailableFonts()) availableFonts.add(font.getFontFamilyName());
}
String chineseFont = null;
for (String candidate : List.of("SimSun", "宋体", "Microsoft YaHei", "微软雅黑", "DengXian", "等线", "SimHei", "黑体", "FangSong", "仿宋", "Noto Serif CJK SC", "Noto Sans CJK SC")) {
if (availableFonts.contains(candidate)) {
chineseFont = candidate;
break;
}
}
log.info("[Office模板渲染] 中文字体选择,context={}, fontCount={}, chineseFont={}", context, availableFonts.size(), chineseFont);
if (chineseFont != null) {
settings.getSubstitutionSettings().getDefaultFontSubstitution().setDefaultFontName(chineseFont);
for (String name : List.of("宋体", "SimSun", "新宋体", "NSimSun", "黑体", "SimHei", "仿宋", "FangSong", "楷体", "KaiTi", "微软雅黑", "Microsoft YaHei")) {
if (!availableFonts.contains(name)) settings.getSubstitutionSettings().getTableSubstitution().setSubstitutes(name, chineseFont);
}
} else {
log.warn("[Office模板渲染] 未发现常用中文字体,context={}, availableFonts={}", context, availableFonts);
}
settings.getFallbackSettings().loadMsOfficeFallbackSettings();
document.setFontSettings(settings);
document.setWarningCallback(warning -> log.warn("[Office模板渲染] 字体或排版警告,context={}, type={}, detail={}", context, warning.getWarningType(), warning.getDescription()));
}
/**
* 签字url转换为poi的图片对象
*
@@ -77,13 +167,7 @@ public class SysOfficeTemplateUtil {
public PictureRenderData createPictureRenderData(int width, int height, String url) {
try {
byte[] bytes = getFileBytesByUrl(url);
// if (StrUtil.isBlank(url)) {
// return null;
// }
// int startIndex = url.indexOf("id=") + "id=".length();
// String id = url.substring(startIndex);
// Sys_file sysFile = dao.fetch(Sys_file.class, id);
// byte[] bytes = SysFileMinIoUtil.getFileBytes(sysFile.getBucket(), sysFile.getStoragePath());
log.info("[Office图片读取] 创建图片渲染数据,referenceLength={}, byteCount={}, width={}, height={}", StrUtil.nullToEmpty(url).length(), bytes == null ? null : bytes.length, width, height);
ByteArrayPictureRenderData pictureRenderData = new ByteArrayPictureRenderData(bytes, PictureType.PNG);
PictureStyle pictureStyle = new PictureStyle();
pictureStyle.setWidth(width);
@@ -91,7 +175,7 @@ public class SysOfficeTemplateUtil {
pictureRenderData.setPictureStyle(pictureStyle);
return pictureRenderData;
} catch (Exception e) {
log.error("文件转换PictureRenderData错误,文件路径:{},{}", url, e.getMessage());
log.error("[Office图片读取] 创建图片渲染数据失败,referenceLength={}", StrUtil.nullToEmpty(url).length(), e);
return null;
}
}
@@ -140,13 +224,20 @@ public class SysOfficeTemplateUtil {
public byte[] getFileBytesByUrl(String url) {
try {
if (StrUtil.isBlank(url)) {
log.warn("[Office图片读取] 图片引用为空");
return null;
}
int startIndex = url.indexOf("id=") + "id=".length();
String id = url.substring(startIndex);
String logFileId = url.contains("id=") ? id : "未识别id参数";
log.info("[Office图片读取] 解析文件引用,fileId={}, hasIdParameter={}", logFileId, url.contains("id="));
Sys_file sysFile = dao.fetch(Sys_file.class, id);
return SysFileMinIoUtil.getFileBytes(sysFile.getBucket(), sysFile.getStoragePath());
log.info("[Office图片读取] 查询文件记录,fileId={}, found={}, bucket={}, storagePathPresent={}", logFileId, sysFile != null, sysFile == null ? null : sysFile.getBucket(), sysFile != null && StrUtil.isNotBlank(sysFile.getStoragePath()));
byte[] bytes = SysFileMinIoUtil.getFileBytes(sysFile.getBucket(), sysFile.getStoragePath());
log.info("[Office图片读取] 读取存储文件完成,fileId={}, byteCount={}", logFileId, bytes == null ? null : bytes.length);
return bytes;
} catch (Exception e) {
log.error("[Office图片读取] 读取图片失败,referenceLength={}", StrUtil.nullToEmpty(url).length(), e);
return null;
}
}
@@ -0,0 +1,29 @@
package com.budwk.app.base.wxwork;
import org.nutz.lang.util.NutMap;
import java.util.List;
/** 企业微信自建应用消息服务,接收人的 loginname 必须对应学校企业微信 userid。 */
public interface WxWorkMessageService {
/**
* 单人发送;link 为空发送文本,否则发送文本卡片,相对链接使用配置域名补全。
* @param loginname 接收人的企业微信 userid,当前学校直接使用系统 loginname
* @param title 消息标题,为空使用配置的默认标题
* @param content 消息正文,可含富文本标签,发送前转为纯文本
* @param link 业务详情链接,可为空、站内相对路径或完整 HTTP(S) 地址
* @return 与 massSend 相同的结果结构;skipped 表示关闭开关而未发送
*/
NutMap send(String loginname, String title, String content, String link);
/**
* 多人发送相同消息,去重并分批调用企业微信接口。
* @param loginNames 接收人 loginname 列表,每项须为企业微信 userid,不接受 @all
* @param title 消息标题,为空使用默认标题
* @param content 消息正文,清理标签后不能为空
* @param link 详情链接;空值为文本消息,非空为文本卡片
* @return NutMap:success 为全部批次被接口接受且无无效接收人;skipped 为未发送;
* batches 为批次结果列表,每项包含 errcode、errmsg、msgid、invaliduser 等接口字段。
* 接口接受不代表用户已读;网络或配置错误抛出业务异常,不自动重发已接受批次。
*/
NutMap massSend(List<String> loginNames, String title, String content, String link);
}
@@ -0,0 +1,189 @@
package com.budwk.app.base.wxwork.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HtmlUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.budwk.app.base.exception.BaseException;
import com.budwk.app.base.constant.RedisConstant;
import com.budwk.app.base.wxwork.WxWorkMessageService;
import com.budwk.app.web.commons.base.Globals;
import lombok.extern.slf4j.Slf4j;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.integration.jedis.RedisService;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.impl.PropertiesProxy;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
/** 企业微信官方消息接口;参考 ypi 的单发、群发及 token 缓存方式,配置与业务调用分离。 */
@Slf4j
@IocBean
public class WxWorkMessageServiceImpl implements WxWorkMessageService {
private static final String PREFIX = "wxwork.message.";
private static final String API = "https://qyapi.weixin.qq.com/cgi-bin/";
@Inject
private PropertiesProxy conf;
@Inject
private RedisService redisService;
/** 参数和返回结构见接口;发送是外部操作,数据库事务无法撤回已发送的消息。 */
@Override
@Aop(TransAop.READ_COMMITTED)
public NutMap send(String loginname, String title, String content, String link) {
return massSend(loginname == null ? null : List.of(loginname), title, content, link);
}
/** 先判断独立消息开关,关闭时不读取凭据、不取 token,也不调用外部接口。 */
@Override
@Aop(TransAop.READ_COMMITTED)
public NutMap massSend(List<String> loginNames, String title, String content, String link) {
Object switchValue = Globals.MyConfig.get("AppWxWorkMessage");
boolean enabled = Boolean.parseBoolean(switchValue == null
? conf.get(PREFIX + "enabled", "false") : switchValue.toString());
if (!enabled) {
return NutMap.NEW().addv("success", false).addv("skipped", true).addv("batches", List.of());
}
if (loginNames == null || loginNames.isEmpty()
|| loginNames.stream().anyMatch(name -> StrUtil.isBlank(name) || name.contains("|") || name.trim().startsWith("@"))) {
throw new BaseException("企业微信接收人须为非空 loginname,不允许分隔符或群发标记");
}
List<String> receivers = loginNames.stream().map(String::trim).distinct().toList();
String body = plainText(content);
String heading = plainText(StrUtil.blankToDefault(title, conf.get(PREFIX + "default-title", "智慧工会")));
if (StrUtil.isBlank(body)) throw new BaseException("企业微信消息正文不能为空");
String url = resolveLink(link);
long agentId;
try {
agentId = Long.parseLong(required("wxwork.agentId"));
if (agentId <= 0) throw new NumberFormatException();
} catch (NumberFormatException e) {
throw new BaseException("企业微信 agentId 必须为正整数");
}
JSONObject payload = JSONUtil.createObj().set("agentid", agentId);
if (url == null) {
String text = heading + "\n" + body;
requireLength(text, 2048, "文本消息");
payload.set("msgtype", "text").set("text", JSONUtil.createObj().set("content", text));
} else {
requireLength(heading, 128, "卡片标题");
String description = HtmlUtil.escape(body).replace("\n", "<br>");
requireLength(description, 512, "卡片正文");
requireLength(url, 2048, "卡片链接");
payload.set("msgtype", "textcard").set("textcard", JSONUtil.createObj()
.set("title", heading).set("description", description).set("url", url).set("btntxt", "查看详情"));
}
List<JSONObject> batches = new ArrayList<>();
boolean success = true;
// 企业微信 touser 单次最多 1000 人;按去重后的接收人分批,禁止自动重复整个批次列表。
for (int offset = 0; offset < receivers.size(); offset += 1000) {
payload.set("touser", String.join("|", receivers.subList(offset, Math.min(offset + 1000, receivers.size()))));
String token = getAccessToken(false);
JSONObject result = sendPayload(payload, token);
int code = result.getInt("errcode", -1);
// 仅在明确的 token 无效/过期返回时刷新一次;超时的发送结果不确定,不能盲目重发。
if (code == 40014 || code == 42001 || code == 40001) {
result = sendPayload(payload, getAccessToken(true));
}
batches.add(result);
boolean accepted = result.getInt("errcode", -1) == 0
&& StrUtil.isBlank(result.getStr("invaliduser"))
&& StrUtil.isBlank(result.getStr("unlicenseduser"));
success &= accepted;
log.info("企业微信消息批次完成:offset={},success={},errcode={},msgid={}",
offset, accepted, result.getInt("errcode"), result.getStr("msgid"));
}
return NutMap.NEW().addv("success", success).addv("skipped", false).addv("batches", batches);
}
/** 标题和富文本正文统一转换为可读纯文本,保留常用段落换行。 */
private String plainText(String value) {
return HtmlUtil.unescape(HtmlUtil.cleanHtmlTag(StrUtil.blankToDefault(value, "")
.replaceAll("(?i)<br\\s*/?>|</p>|</div>", "\n"))).trim();
}
/** 使用 UTF-8 字节数校验接口上限,避免中文按字符计数造成发送失败。 */
private void requireLength(String value, int limit, String label) {
if (value.getBytes(StandardCharsets.UTF_8).length > limit) {
throw new BaseException(label + "超过 " + limit + " 字节,请精简内容");
}
}
/** 相对路径按配置的系统域名补全;限制 HTTP(S),避免生成不可打开的卡片链接。 */
private String resolveLink(String link) {
if (StrUtil.isBlank(link)) return null;
try {
URI uri = URI.create(link.trim());
if (!uri.isAbsolute()) {
if (uri.getRawAuthority() != null) throw new IllegalArgumentException();
String base = required(PREFIX + "link-base-url").replaceAll("/+$", "") + "/";
uri = URI.create(base).resolve(uri);
}
if (!("https".equalsIgnoreCase(uri.getScheme()) || "http".equalsIgnoreCase(uri.getScheme()))
|| uri.getHost() == null || uri.getUserInfo() != null) throw new IllegalArgumentException();
return uri.toASCIIString();
} catch (IllegalArgumentException e) {
throw new BaseException("企业微信详情链接或系统域名配置无效");
}
}
/** 按企业及应用隔离缓存,与登录 token 缓存分开;提前过期以避开有效期临界点。 */
private synchronized String getAccessToken(boolean refresh) {
String corpId = required("wxwork.corpId");
String agentId = required("wxwork.agentId");
String secret = required("wxwork.corpSecret");
String key = RedisConstant.REDIS_KEY_WX_WORK_TOKEN + "message:" + corpId + ":" + agentId;
String cached = refresh ? null : redisService.get(key);
if (StrUtil.isNotBlank(cached)) return cached;
JSONObject response = execute(HttpRequest.get(API + "gettoken?corpid=" + encode(corpId)
+ "&corpsecret=" + encode(secret)));
String token = response.getStr("access_token");
if (response.getInt("errcode", -1) != 0 || StrUtil.isBlank(token)) {
throw new BaseException("企业微信获取 token 失败,errcode=" + response.getInt("errcode", -1));
}
int expires = response.getInt("expires_in", 7200);
redisService.setex(key, Math.max(1, expires - 120), token);
return token;
}
/** 提交已校验的消息 JSON;返回企业微信原始业务字段供调用方判断部分失败。 */
private JSONObject sendPayload(JSONObject payload, String token) {
return execute(HttpRequest.post(API + "message/send?access_token=" + encode(token))
.header("Content-Type", "application/json; charset=UTF-8").body(payload.toString()));
}
/** 关闭响应资源并校验 HTTP/JSON;异常不输出带 Secret 或 token 的请求 URL。 */
protected JSONObject execute(HttpRequest request) {
request.setConnectionTimeout(conf.getInt(PREFIX + "connect-timeout", 5000));
request.setReadTimeout(conf.getInt(PREFIX + "read-timeout", 10000));
try (HttpResponse response = request.execute()) {
if (!response.isOk()) throw new BaseException("企业微信接口 HTTP 状态异常:" + response.getStatus());
if (!JSONUtil.isTypeJSONObject(response.body())) throw new BaseException("企业微信接口返回非 JSON 对象");
return JSONUtil.parseObj(response.body());
} catch (BaseException e) {
throw e;
} catch (Exception e) {
throw new BaseException("企业微信请求失败,请检查网络及接口配置;异常类型:" + e.getClass().getSimpleName());
}
}
/** 必填参数不允许为空或使用原有占位符 1,防止错误配置请求外部接口。 */
private String required(String key) {
String value = conf.get(key, "").trim();
if (StrUtil.isBlank(value) || "1".equals(value)) throw new BaseException("请配置有效的 " + key);
return value;
}
private String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}
@@ -55,7 +55,7 @@ public class FlowRecipientUnitSchoolLeaderHandler implements AssignmentHandler {
.and(Sys_user_role::getUnitId, "=", unitId)
);
if (Lang.isEmpty(roles)) {
throw new BaseException("受助人所在单位未设置单位分管校领导,请联系校工会进行设置。");
throw new BaseException("受助人/申请人所在单位未设置单位分管校领导,请联系校工会进行设置。");
}
return roles.stream().map(Sys_user_role::getUserId).distinct().toList();
}
@@ -0,0 +1,54 @@
package com.budwk.app.flow.handler;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.flow.engine.AssignmentHandler;
import com.budwk.app.flow.engine.core.Execution;
import com.budwk.app.flow.engine.core.ServiceContext;
import com.budwk.app.flow.engine.model.TaskModel;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import java.util.List;
/**
* 为流程任务分配拥有校工会财务角色的用户。
*/
public class FlowSchoolUnionFinanceHandler implements AssignmentHandler {
/**
* 按校工会财务角色查询参与者,并去除重复的用户 ID。
*
* @param model 当前流程任务模型,无需额外传入人员参数
* @param execution 当前流程执行上下文,无需额外传入业务参数
* @return 用户 ID 列表;未配置角色或角色用户时返回空列表
*/
@Override
public List<String> assign(TaskModel model, Execution execution) {
Dao dao = ServiceContext.find(Dao.class);
Sql sql = Sqls.create("""
SELECT
userRole.userId
FROM
`sys_user_role` userRole
LEFT JOIN sys_role role ON role.id = userRole.roleId
WHERE
role.`code` = @roleCode
GROUP BY
userRole.userId
""").setParam("roleCode", RoleConstant.SCHOOL_UNION_FINANCE.name());
sql.setCallback(Sqls.callback.strList());
dao.execute(sql);
return sql.getList(String.class);
}
/**
* 返回流程设计器中展示的参与者处理类名称。
*
* @return 流程设计器下拉选项名称
*/
@Override
public String getMessage() {
return "获取校工会财务";
}
}
@@ -4,6 +4,7 @@ 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.service.SchoolOaTodoService;
import org.nutz.dao.Dao;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
@@ -17,11 +18,16 @@ public class ProcessTaskEndEventListener implements ProcessEventListener {
@Inject
private Dao dao;
@Inject
private SchoolOaTodoService schoolOaTodoService;
@Override
public void onEvent(ProcessEvent event) {
if (event.getEventType() == ProcessEventTypeEnum.PROCESS_TASK_END) {
Long sourceId = event.getSourceId();
ProcessTask task = dao.fetch(ProcessTask.class, sourceId);
// service 区分正常完成与废弃,只有正常完成才发送通达 status=2。
schoolOaTodoService.completeTodo(task);
System.out.println("流程任务完成事件:" + task.getDisplayName());
}
}
@@ -9,6 +9,7 @@ import com.budwk.app.flow.engine.event.ProcessEventListener;
import com.budwk.app.flow.entity.ProcessInstance;
import com.budwk.app.flow.entity.ProcessTask;
import com.budwk.app.flow.enums.ProcessEventTypeEnum;
import com.budwk.app.flow.service.SchoolOaTodoService;
import org.nutz.dao.Dao;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
@@ -22,11 +23,16 @@ public class ProcessTaskRevokeEventListener implements ProcessEventListener {
@Inject
private Dao dao;
@Inject
private SchoolOaTodoService schoolOaTodoService;
@Override
public void onEvent(ProcessEvent event) {
if(event.getEventType()== ProcessEventTypeEnum.PROCESS_TASK_REVOKE){
Long taskId = event.getSourceId();
ProcessTask task = dao.fetch(ProcessTask.class, taskId);
// 公开通达协议未定义取消,集中记录未同步事项,不把撤回冒充正常已办。
schoolOaTodoService.cancelTodo(task);
String taskDisplayName = task.getDisplayName();
@@ -9,6 +9,7 @@ import com.budwk.app.flow.entity.ProcessTask;
import com.budwk.app.flow.entity.ProcessTaskActor;
import com.budwk.app.flow.enums.ProcessEventTypeEnum;
import com.budwk.app.zhgh.dayofficework.message.service.GlobalMessageSendService;
import com.budwk.app.zhgh.staffbenefit.difficulthelp.service.DifficultHelpCommonService;
import lombok.extern.slf4j.Slf4j;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
@@ -30,13 +31,22 @@ public class ProcessTaskStartEventListener implements ProcessEventListener {
private Dao dao;
@Inject
private GlobalMessageSendService globalMessageSendService;
@Inject
private DifficultHelpCommonService difficultHelpCommonService;
@Override
public void onEvent(ProcessEvent event) {
if (event.getEventType() == ProcessEventTypeEnum.PROCESS_TASK_START) {
Long sourceId = event.getSourceId();
ProcessTask task = dao.fetch(ProcessTask.class, sourceId);
if (task == null) return;
sendMessage(task);
// 节点及接收人判断集中在业务 service;通知失败不向流程提交接口抛出异常。
try {
difficultHelpCommonService.notifyOfficeApproval(task);
} catch (Exception e) {
log.error("困难帮扶企业微信待办通知异常,taskId={}", sourceId, e);
}
}
}
@@ -0,0 +1,27 @@
package com.budwk.app.flow.service;
import com.budwk.app.flow.entity.ProcessTask;
/** 通达 OA 待办同步入口,WF 调用方无需处理鉴权、人员映射或接口参数。 */
public interface SchoolOaTodoService {
/**
* 推送已创建且已分配参与人的 WF 任务;首次自动提交的 startTask 不发送。
* @param task 包含任务 ID、实例 ID、状态和表单地址的完整任务
* @return 无返回值;开关关闭时跳过,开启后请求失败抛出业务异常
*/
void createTodo(ProcessTask task);
/**
* 将已完成任务对应的接收人待办同步为 status=2;非完成状态不冒充已办。
* @param task 数据库中的完整任务,使用原发起人、接收人、标题和地址组装请求
* @return 无返回值;errcode=0 表示接口接受请求,原待办匹配规则仍需学校联调确认
*/
void completeTodo(ProcessTask task);
/**
* 记录需取消的 OA 待办。通达公开文档未提供取消接口,本方法不发送虚构的删除请求。
* @param task 撤回、废弃或转办的原任务,用于记录任务 ID 和状态
* @return 无返回值;仅在开关开启时输出待人工处理提示,不代表 OA 已取消
*/
void cancelTodo(ProcessTask task);
}
@@ -29,6 +29,7 @@ import com.budwk.app.flow.enums.*;
import com.budwk.app.flow.service.ProcessDefineService;
import com.budwk.app.flow.service.ProcessInstanceService;
import com.budwk.app.flow.service.ProcessTaskService;
import com.budwk.app.flow.service.SchoolOaTodoService;
import com.budwk.app.flow.vo.ProcessTaskVO;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import org.nutz.aop.interceptor.ioc.TransAop;
@@ -56,6 +57,9 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
@Inject
private ProcessDefineService processDefineService;
@Inject
private SchoolOaTodoService schoolOaTodoService;
@Override
public boolean save(ProcessTask param) {
param.setId(null);
@@ -213,6 +217,8 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
System.out.println("创建任务:" + processTask.getTaskName() + "," + processTask.getDisplayName());
processTaskList.add(processTask);
addTaskActor(processTask.getId(), getTaskActors(taskModel, execution));
// 参与人落库后统一推送通达待办,开始监听器不再重复推送。
schoolOaTodoService.createTodo(processTask);
return processTaskList;
}
@@ -284,6 +290,8 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
clone.setUpdatedBy(null);
dao().insert(clone);
addTaskActor(clone.getId(), CollectionUtil.newArrayList(actor));
// 并行加签绕过普通节点创建入口,需要为新增的个人任务同步待办。
schoolOaTodoService.createTodo(clone);
});
}
@@ -318,6 +326,7 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
}
@Override
@Aop(TransAop.READ_COMMITTED)
public ProcessTask rejectTask(ProcessModel model, ProcessTask currentTask) {
Long taskParentId = currentTask.getTaskParentId();
if (ObjectUtil.isEmpty(taskParentId) || taskParentId.equals(0L)) {
@@ -351,6 +360,8 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
}
saveProcessTask(task);
addTaskActor(task.getId(), CollectionUtil.newArrayList(task.getOperator()));
// 驳回创建了新的任务 ID,按新任务推送,避免复用历史待办。
schoolOaTodoService.createTodo(task);
return task;
}
@@ -424,6 +435,8 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
System.out.println("创建会签任务:" + processTask.getTaskName() + "," + processTask.getDisplayName());
processTaskList.add(processTask);
addTaskActor(processTask.getId(), CollectionUtil.newArrayList(taskActor));
// 串行和并行会签均按实际生成的个人任务发送待办。
schoolOaTodoService.createTodo(processTask);
});
ProcessInstanceService processInstanceService = ServiceContext.find(ProcessInstanceService.class);
// 更新会签变量到流程实例参数中
@@ -496,9 +509,12 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
// 创建转办任务参与者
addTaskActor(newTask.getId(), List.of(transferUserId));
// 通达取消协议待学校确认;先明确记录原待办未清理,再向新办理人推送。
schoolOaTodoService.cancelTodo(task);
schoolOaTodoService.createTodo(newTask);
// 发布流程任务开始事件
List<ProcessTask> processTaskList = List.of(task);
List<ProcessTask> processTaskList = List.of(newTask);
processTaskList.forEach(processTask -> {
ProcessPublisher.notify(ProcessEvent.builder().eventType(ProcessEventTypeEnum.PROCESS_TASK_START).sourceId(processTask.getId()).build());
});
@@ -0,0 +1,344 @@
package com.budwk.app.flow.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.budwk.app.base.exception.BaseException;
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.flow.service.SchoolOaTodoService;
import com.budwk.app.sys.models.Sys_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.impl.PropertiesProxy;
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.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.IdentityHashMap;
import java.util.stream.Collectors;
/**
* 通达 V13 流程中心接口适配。所有配置、token 和 HTTP 请求集中在本服务。
* 接口依据:https://open.tongda2000.com/oa/v13/interface.html
* 同步请求失败向调用方抛出异常;本地事务不能撤销已经成功的远端请求。
*/
@Slf4j
@IocBean
public class SchoolOaTodoServiceImpl implements SchoolOaTodoService {
private static final String PREFIX = "school-oa.todo.";
@Inject
private Dao dao;
@Inject
private PropertiesProxy conf;
// token 由同步方法保护;实例内缓存有效期,避免每位接收人重复申请凭据。
private String accessToken;
private long tokenExpiresAt;
private String tokenConfig;
@Override
@Aop(TransAop.READ_COMMITTED)
public void createTodo(ProcessTask task) {
if (!isEnabled()) return;
requireTask(task);
if (isInitialTask(task) || !ProcessTaskStateEnum.DOING.getCode().equals(task.getTaskState())) return;
sendTask(task, 1);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void completeTodo(ProcessTask task) {
if (!isEnabled()) return;
requireTask(task);
if (isInitialTask(task)) return;
// 废弃任务也会发布结束事件;尤其提案附议仍可能可办理,不能直接改为 OA 已办。
if (!ProcessTaskStateEnum.FINISHED.getCode().equals(task.getTaskState())) {
cancelTodo(task);
return;
}
sendTask(task, 2);
}
@Override
public void cancelTodo(ProcessTask task) {
if (!isEnabled()) return;
requireTask(task);
if (isInitialTask(task)) return;
log.warn("通达OA取消规则尚未提供,本次未发送取消请求,请核对OA原待办:taskId={},instanceId={},state={}",
task.getId(), task.getProcessInstanceId(), task.getTaskState());
}
/** 系统参数优先于文件配置,只有明确为 true 才启用;未配置不访问数据库或 OA。 */
private boolean isEnabled() {
Object enabled = Globals.MyConfig.get("AppSchoolOa");
return Boolean.parseBoolean(enabled == null ? conf.get(PREFIX + "enabled", "false") : enabled.toString());
}
private void requireTask(ProcessTask task) {
if (task == null || task.getId() == null || task.getProcessInstanceId() == null) {
throw new BaseException("通达OA同步失败:任务或流程实例ID为空");
}
}
private boolean isInitialTask(ProcessTask task) {
return Long.valueOf(0L).equals(task.getTaskParentId()) && "startTask".equals(task.getTaskName());
}
/**
* task 为完整 WF 任务;status 只允许本服务传入 1(待办)或 2(已办)。
* sender/recipient 使用学校绑定的第三方用户标识;content 为 JSON 字符串,url 为 UTF-8 Base64。
* 每个接收人单独请求,响应 JSON 中必须有 errcode=0;不采用参考项目的 uniqueId/state 协议。
*/
private void sendTask(ProcessTask task, int status) {
ProcessInstance instance = dao.fetch(ProcessInstance.class, task.getProcessInstanceId());
if (instance == null) throw new BaseException("通达OA同步失败:流程实例不存在");
Sys_user sender = dao.fetch(Sys_user.class, instance.getOperator());
if (sender == null) throw new BaseException("通达OA同步失败:流程发起人不存在");
List<ProcessTaskActor> actors = dao.query(ProcessTaskActor.class,
Cnd.where(ProcessTaskActor::getProcessTaskId, "=", task.getId()));
if (actors.isEmpty()) throw new BaseException("通达OA同步失败:任务参与人为空");
String senderId = userIdentifier(sender.getId(), sender.getLoginname());
String title = StrUtil.blankToDefault(JSONUtil.parseObj(StrUtil.blankToDefault(instance.getVariable(), "{}"))
.getStr(FlowConst.PROCESS_INSTANCE_NAME), "流程申请") + " - " + task.getDisplayName();
String targetUrl = buildTaskUrl(task, instance);
String systemParam = conf.get(PREFIX + "system-id-param", "clientid");
if (!"clientid".equals(systemParam) && !"exsId".equals(systemParam)) {
throw new BaseException("通达OA配置 system-id-param 仅支持 clientid 或 exsId");
}
Map<String, Object> params = new LinkedHashMap<>();
params.put(systemParam, requireConfig("system-id"));
params.put("status", status);
params.put("sender", senderId);
params.put("content", JSONUtil.quote(title));
params.put("url", Base64.getEncoder().encodeToString(targetUrl.getBytes(StandardCharsets.UTF_8)));
// 先校验全部人员,避免处理到中途才发现缺少账号;不假设本地用户 ID 就是 OA 用户 ID。
List<String> recipients = actors.stream()
.filter(actor -> !FlowConst.ADMIN_ID.equals(actor.getActorId())
&& !"superadmin".equalsIgnoreCase(actor.getActorAccount()))
.map(actor -> userIdentifier(actor.getActorId(), actor.getActorAccount())).distinct().toList();
for (String recipient : recipients) {
params.put("recipient", recipient);
String endpoint = baseUrl() + "/restapi/1_0/appcenter/affairprocessing?" + encodeParams(params);
JSONObject result = execute(HttpRequest.get(endpoint).header("Authorization", "Bearer " + getAccessToken()), "待办同步");
Integer errorCode = result.getInt("errcode");
if (errorCode == null || errorCode != 0) {
String detail = responseError(result);
log.error("通达OA待办业务失败,taskId={},instanceId={},status={},errcode={},detail={}",
task.getId(), task.getProcessInstanceId(), status, errorCode, detail);
throw new BaseException("通达OA待办同步失败:errcode={},taskId={},{}", errorCode, task.getId(), detail);
}
log.info("通达OA接受待办同步请求,taskId={},status={}", task.getId(), status);
}
}
/** 用户映射按学校绑定选择 loginname 或 id,发送人与接收人使用同一规则。 */
private String userIdentifier(String id, String account) {
String mode = conf.get(PREFIX + "user-id-mode", "loginname");
if (!"loginname".equals(mode) && !"id".equals(mode)) {
throw new BaseException("通达OA配置 user-id-mode 仅支持 loginname 或 id");
}
String value = "id".equals(mode) ? id : account;
if (StrUtil.isBlank(value) || value.contains(",")) {
throw new BaseException("通达OA同步失败:绑定用户标识为空或包含逗号");
}
return value;
}
/** 通达仅有一个 url 字段,沿用当前 PC 审核页并携带任务、实例及业务参数。 */
private String buildTaskUrl(ProcessTask task, ProcessInstance instance) {
String form = task.getFormKey();
if (StrUtil.isBlank(form)) throw new BaseException("通达OA同步失败:任务PC表单地址为空");
String url = form.startsWith("http://") || form.startsWith("https://") ? form
: StrUtil.removeSuffix(Globals.AppDomain, "/") + "/" + StrUtil.removePrefix(form, "/");
// 原表单带锚点时,查询参数必须放在 # 前,否则服务端收不到 taskId。
int fragmentIndex = url.indexOf('#');
String fragment = fragmentIndex < 0 ? "" : url.substring(fragmentIndex);
if (fragmentIndex >= 0) url = url.substring(0, fragmentIndex);
Map<String, Object> query = new LinkedHashMap<>();
query.put("taskId", task.getId());
query.put("instanceId", instance.getId());
query.put("bizId", StrUtil.nullToEmpty(instance.getBusinessNo()));
query.put("taskKey", StrUtil.nullToEmpty(task.getTaskName()));
return url + (url.contains("?") ? "&" : "?") + encodeParams(query) + fragment;
}
/**
* POST /oauth2/token:client_secret 必须是学校提供的 AES 加密结果,不是明文密钥。
* 返回 access_token 和 expires_in(秒);缓存提前过期,配置变化立即重新申请。
*/
private synchronized String getAccessToken() {
String base = baseUrl();
String clientId = requireConfig("client-id");
String secret = requireConfig("client-secret-encrypted");
String username = conf.get(PREFIX + "token-username", "username");
String password = conf.get(PREFIX + "token-password", "password");
String currentConfig = JSONUtil.toJsonStr(List.of(base, clientId, secret, username, password));
long now = System.currentTimeMillis();
if (currentConfig.equals(tokenConfig) && StrUtil.isNotBlank(accessToken) && now < tokenExpiresAt) {
// 只记录缓存剩余有效期,便于区分复用与重新获取,不输出凭据。
log.info("通达OA复用token缓存,remainingSeconds={}", (tokenExpiresAt - now) / 1000L);
return accessToken;
}
Map<String, Object> form = new LinkedHashMap<>();
form.put("grant_type", "password");
form.put("client_id", clientId);
form.put("client_secret", secret);
form.put("username", username);
form.put("password", password);
long startedAt = System.nanoTime();
JSONObject result = execute(HttpRequest.post(base + "/oauth2/token")
.header("Content-Type", "application/x-www-form-urlencoded")
.body(encodeParams(form)), "获取凭据");
String token = result.getStr("access_token");
Long expires = result.getLong("expires_in");
if (StrUtil.isBlank(token) || expires == null || expires <= 0 || expires > Integer.MAX_VALUE) {
String detail = responseError(result);
log.error("通达OA获取凭据业务失败,缺少有效的 access_token 或 expires_in,detail={}", detail);
throw new BaseException("通达OA获取凭据失败:缺少有效的 access_token 或 expires_in,{}", detail);
}
accessToken = token;
tokenConfig = currentConfig;
tokenExpiresAt = now + (expires - Math.min(60, expires / 10)) * 1000L;
log.info("通达OA获取token成功,elapsedMs={},expiresInSeconds={}",
(System.nanoTime() - startedAt) / 1_000_000, expires);
return token;
}
/** HTTP 成功不代表业务成功;本方法只验证状态码和 JSON,业务码由调用处校验。 */
private JSONObject execute(HttpRequest request, String action) {
long startedAt = System.nanoTime();
int timeout = conf.getInt(PREFIX + "timeout-ms", 10000);
// 只记录接口地址,不记录 GET 查询参数、请求头或 token 请求正文。
String endpoint = request.getUrl().split("[?#]", 2)[0];
try (HttpResponse response = request.timeout(timeout).execute()) {
String body = response.body();
boolean jsonObject = JSONUtil.isTypeJSONObject(body);
// 非 JSON 错误常为 OA 或网关的 HTML 页面;仅在日志保留脱敏摘要,不把页面返回给前端。
if (!jsonObject) {
log.error("通达OA非JSON响应,action={},method={},endpoint={},httpStatus={},contentType={},bodySummary={}",
action, request.getMethod(), endpoint, response.getStatus(),
redact(response.header("Content-Type")), responseBodySummary(body));
}
if (!response.isOk()) {
if (response.getStatus() == 401) invalidateToken();
String detail = jsonObject
? responseError(JSONUtil.parseObj(body)) : "响应不是JSON对象,脱敏响应摘要请查看服务端日志";
throw new BaseException("通达OA{}失败:HTTP {},{}", action, response.getStatus(), detail);
}
if (!jsonObject) throw new BaseException("通达OA{}失败:响应不是JSON对象,脱敏响应摘要请查看服务端日志", action);
return JSONUtil.parseObj(body);
} catch (Exception e) {
long elapsed = (System.nanoTime() - startedAt) / 1_000_000;
Throwable root = e;
Map<Throwable, Boolean> visited = new IdentityHashMap<>();
while (root.getCause() != null && visited.put(root, Boolean.TRUE) == null
&& !visited.containsKey(root.getCause())) root = root.getCause();
String detail = root.getClass().getName() + ": " + redact(root.getMessage());
// 保留各层原因、原始栈帧及 suppressed 异常;先复制脱敏,避免全局异常处理器泄露凭证。
Throwable safeCause = diagnosticCause(e, new IdentityHashMap<>());
log.error("通达OA请求失败,action={},method={},endpoint={},elapsedMs={},timeoutMs={},rootCause={}",
action, request.getMethod(), endpoint, elapsed, timeout, detail, safeCause);
// 不自动重放写请求,超时可能发生在远端已成功之后;避免重复待办。
BaseException failure = new BaseException("通达OA{}失败,耗时{}ms:{};请核对远端结果后再重试", action, elapsed, detail);
failure.initCause(safeCause);
throw failure;
}
}
/** body 为远端非 JSON 正文;先脱敏再截取最多 2000 字符,避免截断凭据后无法匹配脱敏。 */
private String responseBodySummary(String body) {
if (StrUtil.isBlank(body)) return "[空响应正文]";
String safe = redact(body);
return safe.length() > 2000 ? safe.substring(0, 2000) + "[已截断]" : safe;
}
/** 只提取接口错误字段,不输出含 access_token、refresh_token 等内容的整个响应。 */
private String responseError(JSONObject response) {
Map<String, Object> errors = new LinkedHashMap<>();
for (String key : List.of("error", "error_description", "errcode", "errmsg", "message")) {
if (response.containsKey(key)) errors.put(key, redact(String.valueOf(response.get(key))));
}
return errors.isEmpty() ? "OA未返回错误说明" : JSONUtil.toJsonStr(errors);
}
/**
* 复制异常诊断信息,message 标注原异常类型,保留原始栈帧和原因链。
* visited 防止第三方异常存在循环引用;原异常不修改,也不直接写入日志。
*/
private Throwable diagnosticCause(Throwable source, Map<Throwable, Throwable> visited) {
if (visited.containsKey(source)) return visited.get(source);
RuntimeException copy = new RuntimeException(source.getClass().getName() + ": " + redact(source.getMessage()));
visited.put(source, copy);
copy.setStackTrace(source.getStackTrace());
if (source.getCause() != null && !visited.containsKey(source.getCause())) {
copy.initCause(diagnosticCause(source.getCause(), visited));
}
for (Throwable suppressed : source.getSuppressed()) {
if (!visited.containsKey(suppressed)) copy.addSuppressed(diagnosticCause(suppressed, visited));
}
return copy;
}
/** 清除已知凭证及可能被远端回显的认证字段、URL查询参数,并移除日志换行。 */
private String redact(String message) {
if (message == null) return "无异常说明";
String text = message;
for (String key : List.of("client-secret-encrypted", "token-password", "client-id")) {
String value = conf.get(PREFIX + key);
if (StrUtil.isNotBlank(value)) {
text = text.replace(value, "[REDACTED]")
.replace(URLEncoder.encode(value, StandardCharsets.UTF_8), "[REDACTED]");
}
}
if (StrUtil.isNotBlank(accessToken)) text = text.replace(accessToken, "[REDACTED]");
text = text.replaceAll("(?i)Bearer\\s+[^\\s,;\\\"']+", "Bearer [REDACTED]");
text = text.replaceAll("(?i)((?:client_secret|access_token|refresh_token|password|authorization)[\\\"']?\\s*[:=]\\s*)[\\\"']?[^\\s&,;}\\\"']+[\\\"']?", "$1[REDACTED]");
text = text.replaceAll("(https?://[^\\s?#]+)\\?[^\\s]+", "$1?[REDACTED]");
return text.replace('\r', ' ').replace('\n', ' ');
}
private synchronized void invalidateToken() {
accessToken = null;
tokenExpiresAt = 0;
}
private String baseUrl() {
String value = requireConfig("base-url");
URI uri = URI.create(value);
if ((!"http".equals(uri.getScheme()) && !"https".equals(uri.getScheme()))
|| uri.getHost() == null || uri.getQuery() != null || uri.getFragment() != null || uri.getUserInfo() != null) {
throw new BaseException("通达OA base-url 必须为不含账号、查询参数和锚点的HTTP(S)地址");
}
return StrUtil.removeSuffix(value, "/");
}
private String requireConfig(String key) {
String value = conf.get(PREFIX + key);
if (StrUtil.isBlank(value)) throw new BaseException("通达OA配置 {} 未填写", PREFIX + key);
return value.trim();
}
/** 每个参数单独编码,保留标题中的中文、引号及 Base64 中的 +、/、=。 */
private String encodeParams(Map<String, Object> params) {
return params.entrySet().stream().map(entry -> URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)
+ "=" + URLEncoder.encode(String.valueOf(entry.getValue()), StandardCharsets.UTF_8))
.collect(Collectors.joining("&"));
}
}
@@ -6,6 +6,7 @@ import cn.hutool.http.HtmlUtil;
import com.budwk.app.base.constant.RedisConstant;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.base.sms.SmsService;
import com.budwk.app.base.wxwork.WxWorkMessageService;
import com.budwk.app.base.sms.model.MsgPlatformWechatRequest;
import com.budwk.app.base.utils.ManyAddOrRenewUtil;
import com.budwk.app.sys.models.Sys_msg;
@@ -62,6 +63,8 @@ public class SysMsgServiceImpl extends BaseServiceImpl<Sys_msg> implements SysMs
private ManyAddOrRenewUtil manyAddOrRenewUtil;
@Inject
private SmsService smsService;
@Inject
private WxWorkMessageService wxWorkMessageService;
private static NutMap typeMap = NutMap.NEW().addv("system", "系统消息").addv("user", "用户消息");
@@ -102,14 +105,29 @@ public class SysMsgServiceImpl extends BaseServiceImpl<Sys_msg> implements SysMs
}
});
//发送学校平台消息
// isExternal=false 为仅站内通知;外部通道分别处理异常,避免短信失败阻断企业微信。
if (isExternal) {
ThreadUtil.execute(() -> {
for (String loginName : loginNames) {
smsService.sendSmsByAccount(loginName,sysMsg.getNote());
smsService.sendWechatTextByAccountOrMobile(loginName,sysMsg.getNote());
try {
smsService.sendSmsByAccount(loginName, sysMsg.getNote());
} catch (Exception e) {
log.error("消息短信发送失败,msgId=" + sysMsg.getId(), e);
}
}
});
ThreadUtil.execute(() -> {
try {
// 学校企业微信 userid 直接使用 loginname;批量发送标题、正文及业务链接。
NutMap result = wxWorkMessageService.massSend(loginNames, sysMsg.getTitle(), sysMsg.getNote(), sysMsg.getUrl());
if (!result.getBoolean("skipped", false) && !result.getBoolean("success", false)) {
log.warn("企业微信消息存在失败接收人,msgId=" + sysMsg.getId() + ",result=" + result);
}
} catch (Exception e) {
log.error("企业微信消息发送失败,msgId=" + sysMsg.getId(), e);
}
});
}
return sysMsg;
}
@@ -292,11 +310,13 @@ public class SysMsgServiceImpl extends BaseServiceImpl<Sys_msg> implements SysMs
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void sendMsg(String loginname, String title, String body, String sender) {
sendMsg(List.of(loginname), title, body, SecurityUtil.getUserId());
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void sendMsg(List<String> loginNames, String title, String body, String sender) {
Sys_msg sysMsg = new Sys_msg();
sysMsg.setTitle(title);
@@ -309,6 +329,7 @@ public class SysMsgServiceImpl extends BaseServiceImpl<Sys_msg> implements SysMs
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void sendMsgInSys(List<String> loginNames, String title, String body, String sender) {
Sys_msg sysMsg = new Sys_msg();
sysMsg.setTitle(title);
@@ -189,25 +189,16 @@ public class WkFailProcessor extends ViewProcessor {
if (Globals.sso) {
//1.判断是否为企业微信浏览器并且对接了企业微信
if (browserPlatform == BrowserPlatform.QI_YE_WEI_XIN && propertiesProxy.getBoolean("wxwork.enable", false)) {
//进行企业微信授权
// String token = getWxWorkToken();
// if (StrUtil.isNotBlank(token)) {
// //跳转到企业微信授权地址
// new ServerRedirectView(buildWxWorkOauth2Url()).render(request, response, null);
// } else {
// //跳转到错误页 别问我为啥错了 我也不知道 首先去检查配置文件里的参数有没有写对
//// new ServerRedirectView(Globals.AppDomain + errorUri).render(request, response, null);
// //跳转到cas登录页
// String callBackUrl = propertiesProxy.get("cas.client-host-url") + propertiesProxy.get("cas.client-call-back-url") + "?redirect=" + redirect;
// String authUrl = propertiesProxy.get("cas.qi-wx-server-login-url") + "?service=" + URLEncoder.encode(callBackUrl, StandardCharsets.UTF_8);
// new ServerRedirectView(authUrl).render(request, response, null);
// }
// 武汉城市职业学院 跳转到企业微信cas登录页
String callBackUrl = propertiesProxy.get("wecom.wecom-client-host-url") + propertiesProxy.get("wecom.wecom-client-call-back-url") + "?redirect=" + redirect;
String authUrl = propertiesProxy.get("wecom.wecom-server-login-url") + "?service=" + URLEncoder.encode(callBackUrl, StandardCharsets.UTF_8);
log.info("auth:"+authUrl);
// 专用企业微信认证配置缺失时整组回退到已接通的 CAS,保留卡片原始页面。
try {
String authUrl = buildEnterpriseCasUrl(redirect);
log.info("auth:" + authUrl);
new ServerRedirectView(authUrl).render(request, response, null);
} catch (IllegalArgumentException e) {
log.error("企业微信登录跳转配置无效:" + e.getMessage());
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"统一身份认证配置不完整或无效,请联系管理员检查认证地址及回调地址");
}
} else if (browserPlatform == BrowserPlatform.WEI_XIN && propertiesProxy.getBoolean("wx.enable", false)) {
//进行微信公众号授权
//跳转到微信授权页
@@ -225,6 +216,53 @@ public class WkFailProcessor extends ViewProcessor {
}
}
/**
* 构造企业微信内的 CAS 登录地址。redirect 为已 URL 编码的原始站内路径及查询参数;
* 返回携带 service 参数的完整认证 URL,认证回调通过 redirect 返回卡片页面。
* 三个 wecom 配置必须同时存在,否则整体使用 cas 配置,避免混用两套认证平台。
*/
private String buildEnterpriseCasUrl(String redirect) {
String loginUrl = propertiesProxy.get("wecom.wecom-server-login-url");
String clientHost = propertiesProxy.get("wecom.wecom-client-host-url");
String callbackPath = propertiesProxy.get("wecom.wecom-client-call-back-url");
if (missingAuthConfig(loginUrl) || missingAuthConfig(clientHost) || missingAuthConfig(callbackPath)) {
loginUrl = propertiesProxy.get("cas.server-login-url");
clientHost = propertiesProxy.get("cas.client-host-url");
callbackPath = propertiesProxy.get("cas.client-call-back-url");
}
if (missingAuthConfig(loginUrl) || missingAuthConfig(clientHost) || missingAuthConfig(callbackPath)) {
throw new IllegalArgumentException("CAS 登录地址、系统域名或回调路径为空");
}
loginUrl = loginUrl.trim();
clientHost = clientHost.trim();
callbackPath = callbackPath.trim();
validateAuthUrl(loginUrl);
validateAuthUrl(clientHost);
// 回调必须是本系统路径,不能被绝对 URL 或双斜线路径覆盖到其他主机。
if (!callbackPath.startsWith("/") || callbackPath.startsWith("//") || callbackPath.contains("#")) {
throw new IllegalArgumentException("认证回调必须是以单斜线开头的站内路径");
}
String callback = clientHost.replaceAll("/+$", "") + callbackPath
+ (callbackPath.contains("?") ? "&" : "?") + "redirect=" + redirect;
// 保留部署配置中的 HTTP/HTTPS 协议;工会 HTTPS 回调不会被改为 HTTP。
return loginUrl + (loginUrl.contains("?") ? "&" : "?") + "service="
+ URLEncoder.encode(callback, StandardCharsets.UTF_8);
}
/** 空值和字面量 null 都视为未配置,防止拼接出浏览器相对地址 null?service=...。 */
private boolean missingAuthConfig(String value) {
return StrUtil.isBlank(value) || "null".equalsIgnoreCase(value.trim());
}
/** 认证地址和回调域名必须为完整 HTTP(S) URL,拒绝无主机名、片段或用户信息的地址。 */
private void validateAuthUrl(String value) {
java.net.URI uri = java.net.URI.create(value);
if (!("https".equalsIgnoreCase(uri.getScheme()) || "http".equalsIgnoreCase(uri.getScheme()))
|| uri.getHost() == null || uri.getFragment() != null || uri.getUserInfo() != null) {
throw new IllegalArgumentException("认证配置不是有效的 HTTP(S) 地址");
}
}
/**
* 判断浏览器平台
*
@@ -229,14 +229,18 @@ public class ProposalMineController {
}
/**
* 删除当前用户可删除的提案。
*
* @param id 提案 ID;仅允许删除本人未发起流程或退回发起节点的提案
* @return Result,成功时返回成功结果,不满足删除条件时返回原因
*/
@At
@SaCheckPermission("proposal.mine")
@Aop(TransAop.READ_COMMITTED)
@SLog(tag = "提案管理系统-我的提案", msg = "删除提案")
public Result delete(@Valid String id) {
dao.delete(ProposalInfo.class, id);
flowEngine.processInstanceService().deleteProcessInstanceByBusinessKey(id);
return Result.success();
return proposalCommonService.deleteMine(id, SecurityUtil.getUserId());
}
@At
@@ -3,6 +3,7 @@ package com.budwk.app.zhgh.democratic.proposal.service.common;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.param.ExportTableColumns;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.democratic.proposal.models.ProposalInfo;
import com.budwk.app.zhgh.democratic.proposal.models.ProposalUndertake;
import com.budwk.app.zhgh.democratic.proposal.param.ProposalQueryComprehensiveParam;
@@ -19,6 +20,15 @@ import java.util.Set;
public interface ProposalCommonService extends BaseService<ProposalInfo> {
/**
* 删除当前用户尚未发起流程或已退回发起节点的提案。
*
* @param id 待删除的提案 ID
* @param userId 当前登录用户 ID,用于核对提案创建人
* @return Result,删除成功返回 success;提案不存在、非本人或流程不允许删除时返回错误信息
*/
Result deleteMine(String id, String userId);
/**
* 按页面允许的字段白名单添加排序条件,避免前端字段直接进入SQL。
*
@@ -17,6 +17,7 @@ import com.budwk.app.base.exception.BaseException;
import com.budwk.app.base.param.ExportTableColumns;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.base.utils.CommonDownloadUtil;
import com.budwk.app.base.utils.PageUtil;
@@ -52,6 +53,7 @@ import com.deepoove.poi.config.Configure;
import com.deepoove.poi.plugin.table.LoopRowTableRenderPolicy;
import lombok.extern.slf4j.Slf4j;
import org.ddr.poi.html.HtmlRenderPolicy;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.FieldFilter;
@@ -62,6 +64,7 @@ import org.nutz.dao.util.cri.SqlExpressionGroup;
import org.nutz.dao.util.cri.Static;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.ioc.aop.Aop;
import org.nutz.json.Json;
import org.nutz.lang.Lang;
import org.nutz.lang.Strings;
@@ -82,16 +85,7 @@ import java.util.stream.Collectors;
@IocBean(args = {"refer:dao"})
public class ProposalCommonServiceImpl extends BaseServiceImpl<ProposalInfo> implements ProposalCommonService {
/** 确定立案提案的 Word 模板编码。 */
private static final String PROPOSAL_CONFIRM_FILING_TEMPLATE_CODE = "proposal_confirm_filing";
/** 工作建议提案的 Word 模板编码。 */
private static final String PROPOSAL_SUGGESTION_TEMPLATE_CODE = "proposal_suggestion";
/** 不予立案提案的 Word 模板编码。 */
private static final String PROPOSAL_NOT_TEMPLATE_CODE = "proposal_not";
/** 历史提案立案结果为空或无法识别时使用的原 Word 模板编码。 */
/** 所有立案结果统一使用重庆工贸提案征集表模板。 */
private static final String PROPOSAL_DEFAULT_TEMPLATE_CODE = "proposal";
/** PC端提案列表页面的排序字段白名单,页面代码由对应Controller固定传入。 */
@@ -193,6 +187,45 @@ public class ProposalCommonServiceImpl extends BaseServiceImpl<ProposalInfo> imp
super(dao);
}
/**
* 仅允许创建人删除草稿或退回发起节点的提案,并清理其 WF 流程。
*
* @param id 待删除的提案 ID
* @param userId 当前登录用户 ID,用于核对提案创建人
* @return Result,删除成功返回 success;提案不存在、非本人或流程不允许删除时返回错误信息
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public Result deleteMine(String id, String userId) {
if (StrUtil.isBlank(id) || StrUtil.isBlank(userId)) {
return Result.error("提案不存在或无权删除");
}
ProposalInfo proposal = dao().fetch(ProposalInfo.class, id);
if (proposal == null || !Objects.equals(proposal.getCreatedBy(), userId)) {
return Result.error("提案不存在或无权删除");
}
List<ProcessInstance> instances = dao().query(ProcessInstance.class,
Cnd.where(ProcessInstance::getBusinessNo, "=", id));
// 一个提案出现多个流程实例时无法安全判断当前节点,拒绝删除并保留数据。
if (instances.size() > 1) {
return Result.error("提案流程数据异常,无法删除");
}
if (!instances.isEmpty()) {
ProcessInstance instance = instances.get(0);
List<ProcessTask> tasks = flowEngine.processTaskService().getDoingTaskList(instance.getId(), null);
// 存在并行任务时,只有所有活动任务均位于发起节点才允许删除。
if (!Objects.equals(instance.getState(), ProcessInstanceStateEnum.DOING.getCode())
|| tasks.isEmpty() || tasks.stream().anyMatch(task -> !Objects.equals(task.getTaskName(), "startTask"))) {
return Result.error("仅未提交或退回发起节点的提案可以删除");
}
}
dao().delete(ProposalInfo.class, id);
flowEngine.processInstanceService().deleteProcessInstanceByBusinessKey(id);
return Result.success();
}
/**
* 分页查询提案综合列表。
* 普通查询不关联历史任务表;仅在 origins 包含 answer 且 commonKeyword 非空时,
@@ -1367,6 +1400,11 @@ public class ProposalCommonServiceImpl extends BaseServiceImpl<ProposalInfo> imp
execute(sql);
NutMap docData = (NutMap) sql.getResult();
// 提案可能已被删除,避免在读取模板字段时产生空指针。
if (docData == null) {
throw new BaseException("提案信息不存在");
}
//处理下富文本
String brief = docData.getString("brief");
String measures = docData.getString("measures");
@@ -1381,11 +1419,22 @@ public class ProposalCommonServiceImpl extends BaseServiceImpl<ProposalInfo> imp
ProcessInstance processInstance = dao().fetch(ProcessInstance.class, Cnd.where(ProcessInstance::getBusinessNo, "=", id));
List<ProcessTaskVO> doneTaskVos = new ArrayList<>();
// 尚未启动或缺少 WF 实例的提案仍导出基础信息,附议和审批栏保持为空。
if (processInstance != null) {
List<ProcessTask> doneTaskList = flowEngine.processTaskService().getDoneTaskList(processInstance.getId(), null);
for (ProcessTask doneTask : doneTaskList) {
ProcessTaskVO taskVO = flowEngine.processTaskService().findById(doneTask.getId());
if (taskVO != null && taskVO.getExt() != null) {
doneTaskVos.add(taskVO);
}
}
}
// 附议人单元格仅展示已完成附议任务中的姓名,多人以顿号分隔并去重。
docData.put("secondNames", doneTaskVos.stream()
.filter(task -> "second".equals(task.getTaskName()))
.map(task -> task.getTaskFormData().getStr("userName"))
.filter(StrUtil::isNotBlank).distinct().collect(Collectors.joining("、")));
// 按任务节点分组
Map<String, List<ProcessTaskVO>> taskGroups = doneTaskVos.stream()
@@ -1394,6 +1443,17 @@ public class ProposalCommonServiceImpl extends BaseServiceImpl<ProposalInfo> imp
docData.putAll(taskGroups);
putProposalExportTaskAliases(id, taskGroups, docData);
// 中文区块名称保持与现有模板一致,实际审核记录按 WF 节点编码匹配。
// 区块内继续读取 taskFormData 的意见、姓名和日期;无记录时写入空列表,避免同名节点误填。
Map<String, String> approvalTemplateNodes = Map.of(
"分工会主席审批", "delegation",
"校工会主席审批", "schoolUnionChairApproval",
"分管校领导审批", "school_leader");
approvalTemplateNodes.forEach((templateKey, taskName) -> docData.put(templateKey, doneTaskVos.stream()
.filter(task -> taskName.equals(task.getTaskName()))
.sorted(Comparator.comparing(ProcessTaskVO::getFinishTime, Comparator.nullsLast(Comparator.naturalOrder())))
.collect(Collectors.toList())));
// 提案附议
List<NutMap> secondInfos = new ArrayList<>();
List<ProcessTaskVO> secondTaskVos = taskGroups.get("提案附议");
@@ -1475,29 +1535,13 @@ public class ProposalCommonServiceImpl extends BaseServiceImpl<ProposalInfo> imp
docData.put("secondedList", secondInfos);
docData.put("delegationAuditList", delegationAuditList);
String templateCode = getProposalTemplateCode(docData.getString("caseFilingResult"));
try {
XWPFTemplate.compile(sysOfficeTemplateUtil.getTemplate(templateCode), config).render(docData).writeAndClose(byteArrayOutputStream);
XWPFTemplate.compile(sysOfficeTemplateUtil.getTemplate(PROPOSAL_DEFAULT_TEMPLATE_CODE), config).render(docData).writeAndClose(byteArrayOutputStream);
} catch (IOException e) {
log.error("提案导出失败{},提案id:{}", e.getMessage(), id);
}
}
/**
* 根据立案结果选择数据库办公模板编码。
*
* @param caseFilingResult 立案结果字典编码:CONFIRM_FILING、SUGGESTION 或 NOT
* @return 对应的 Word 模板编码;历史空值或未知编码返回 proposal
*/
private String getProposalTemplateCode(String caseFilingResult) {
return switch (StrUtil.nullToEmpty(caseFilingResult)) {
case "CONFIRM_FILING" -> PROPOSAL_CONFIRM_FILING_TEMPLATE_CODE;
case "SUGGESTION" -> PROPOSAL_SUGGESTION_TEMPLATE_CODE;
case "NOT" -> PROPOSAL_NOT_TEMPLATE_CODE;
default -> PROPOSAL_DEFAULT_TEMPLATE_CODE;
};
}
/**
* 将现行工作流节点映射到历史提案表模板使用的区块名称,并按主协办单位拆分办理答复。
* 模板仍使用旧节点名称时,避免已完成的意见和答复因键名不一致而无法渲染。
@@ -101,7 +101,7 @@ public class TeacherCongressInstitutionUserServiceImpl extends BaseServiceImpl<T
if (identity.equals("主任")) {
sysRole = sysRoleService.getByCode(RoleConstant.TEACHER_CONGRESS_PREPARATION_LEADERSHIP_GROUP_DIRECTOR);
} else if (identity.equals("成员")) {
} else if (identity.equals("委员")) {
sysRole = sysRoleService.getByCode(RoleConstant.TEACHER_CONGRESS_PREPARATION_LEADERSHIP_GROUP_MEMBER);
}
if (sysRole == null) {
@@ -148,7 +148,7 @@ public class TeacherCongressInstitutionUserServiceImpl extends BaseServiceImpl<T
if (institutionUser.getIdentity().equals("主任")) {
sysRole = sysRoleService.getByCode(RoleConstant.TEACHER_CONGRESS_PREPARATION_LEADERSHIP_GROUP_DIRECTOR);
} else if (institutionUser.getIdentity().equals("成员")) {
} else if (institutionUser.getIdentity().equals("委员")) {
sysRole = sysRoleService.getByCode(RoleConstant.TEACHER_CONGRESS_PREPARATION_LEADERSHIP_GROUP_MEMBER);
}
if (sysRole == null) {
@@ -28,10 +28,10 @@ import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.staffbenefit.condolence.model.Condolence;
import com.budwk.app.zhgh.staffbenefit.condolence.model.CondolenceType;
import com.budwk.app.zhgh.staffbenefit.condolence.service.CondolenceService;
import com.deepoove.poi.XWPFTemplate;
import com.deepoove.poi.data.PictureRenderData;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Sqls;
@@ -47,8 +47,6 @@ import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.util.List;
@@ -59,6 +57,7 @@ import java.util.List;
* @Version 1.0
* @Description TODO
*/
@Slf4j
@IocBean
@At("/platform/condolence/mine")
@Api("职工慰问我的")
@@ -157,6 +156,7 @@ public class CondolenceMineController {
@ApiOperation("导出职工慰问申请表")
@SaCheckPermission("condolence.mine")
public void doExportApply(String id, HttpServletResponse response, Boolean print) throws Exception {
log.info("[职工慰问导出] 开始,applyId={}, userId={}, format={}", id, SecurityUtil.getUserId(), Boolean.TRUE.equals(print) ? "PDF" : "Word");
Condolence condolence = condolenceService.fetch(id);
if (condolence == null) throw new BaseException("该条记录不存在");
if (!SecurityUtil.getUserId().equals(condolence.getCreatedBy())) {
@@ -167,36 +167,32 @@ public class CondolenceMineController {
if (instance == null || !ProcessInstanceStateEnum.FINISHED.getCode().equals(instance.getState())) {
throw new BaseException("只能导出已完成的申请");
}
log.info("[职工慰问导出] 流程已完成,applyId={}, instanceId={}, state={}, applicantSignPresent={}", id, instance.getId(), instance.getState(), StrUtil.isNotBlank(condolence.getSignature()));
NutMap docData = Lang.obj2nutmap(condolence);
docData.put("payUserName", StrUtil.nullToEmpty(condolence.getBankUserName()));
CondolenceType type = condolenceService.dao().fetch(CondolenceType.class, condolence.getType());
docData.put("typeName", type == null ? "" : type.getName());
docData.put("signature", "");
docData.put("signature", null);
if (StrUtil.isNotBlank(condolence.getSignature())) {
PictureRenderData signature = sysOfficeTemplateUtil.createPictureRenderData(90, 40, condolence.getSignature());
if (signature == null) throw new BaseException("申请人签字图片读取失败");
docData.put("signature", signature);
}
fillApplyApprovals(docData, instance);
for (String field : List.of("signature", "firstApprovalSign", "secondApprovalSign", "thirdApprovalSign", "fourthApprovalSign")) {
Object value = docData.get(field);
log.info("[职工慰问导出] 渲染前图片字段,applyId={}, instanceId={}, field={}, valueType={}", id, instance.getId(), field, value == null ? "null" : value.getClass().getName());
}
String fileName = "重庆工贸职业技术学院职工慰问申请表_" + StrUtil.nullToEmpty(condolence.getHelpUserName());
try (InputStream input = getClass().getResourceAsStream("/templates/condolence/重庆工贸职业技术学院职工慰问申请表.doc");
ByteArrayOutputStream templateOutput = new ByteArrayOutputStream();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
try (InputStream input = getClass().getResourceAsStream("/templates/condolence/重庆工贸职业技术学院职工慰问申请表.doc")) {
if (input == null) throw new BaseException("职工慰问申请表模板不存在");
com.aspose.words.Document document = new com.aspose.words.Document(input);
document.save(templateOutput, com.aspose.words.SaveFormat.DOCX);
try (ByteArrayInputStream templateInput = new ByteArrayInputStream(templateOutput.toByteArray())) {
XWPFTemplate.compile(templateInput).render(docData).writeAndClose(output);
}
if (Boolean.TRUE.equals(print)) {
try (ByteArrayInputStream wordInput = new ByteArrayInputStream(output.toByteArray());
ByteArrayOutputStream pdfOutput = new ByteArrayOutputStream()) {
new com.aspose.words.Document(wordInput).save(pdfOutput, com.aspose.words.SaveFormat.PDF);
CommonDownloadUtil.download(fileName + ".pdf", pdfOutput.toByteArray(), response);
}
} else {
CommonDownloadUtil.download(fileName + ".docx", output.toByteArray(), response);
}
boolean pdf = Boolean.TRUE.equals(print);
byte[] bytes = sysOfficeTemplateUtil.renderTemplate(input, docData, null, pdf, "condolence:" + id);
log.info("[职工慰问导出] 渲染完成,applyId={}, format={}, outputBytes={}", id, pdf ? "PDF" : "Word", bytes.length);
CommonDownloadUtil.download(fileName + (pdf ? ".pdf" : ".docx"), bytes, response);
} catch (Exception e) {
log.error("[职工慰问导出] 模板渲染或文件输出失败,applyId={}, instanceId={}, format={}", id, instance.getId(), Boolean.TRUE.equals(print) ? "PDF" : "Word", e);
throw e;
}
}
@@ -206,29 +202,39 @@ public class CondolenceMineController {
String[] fields = {"firstApproval", "secondApproval", "thirdApproval", "fourthApproval"};
List<String> suffixes = List.of("Opinion", "Sign", "Year", "Month", "Day");
for (String field : fields) {
for (String suffix : suffixes) docData.put(field + suffix, "");
for (String suffix : suffixes) docData.put(field + suffix, "Sign".equals(suffix) ? null : "");
}
List<ProcessTask> tasks = condolenceService.dao().query(ProcessTask.class,
Cnd.where("processInstanceId", "=", instance.getId()).asc("id"));
log.info("[职工慰问导出] 查询审批记录,applyId={}, instanceId={}, taskCount={}, expectedTaskKeys={}", instance.getBusinessNo(), instance.getId(), tasks.size(), List.of(taskKeys));
for (ProcessTask task : tasks) {
ProcessTaskVO taskVO = flowEngine.processTaskService().findById(task.getId());
if ("startTask".equals(task.getTaskName()) || (taskVO.getExt() != null && Boolean.TRUE.equals(taskVO.getExt().getBool("isFirstTaskNode")))) {
log.info("[职工慰问导出] 审批记录,instanceId={}, taskId={}, taskKey={}, displayName={}, state={}, finishTime={}, isFirstTaskNode={}, matchedApproval={}", instance.getId(), task.getId(), task.getTaskName(), task.getDisplayName(), task.getTaskState(), task.getFinishTime(), taskVO.getExt() == null ? null : taskVO.getExt().get("isFirstTaskNode"), List.of(taskKeys).contains(task.getTaskName()));
if ("startTask".equals(task.getTaskName()) || (!List.of(taskKeys).contains(task.getTaskName())
&& taskVO.getExt() != null && Boolean.TRUE.equals(taskVO.getExt().getBool("isFirstTaskNode")))) {
log.info("[职工慰问导出] 申请节点重置本轮审批字段,instanceId={}, taskId={}, taskKey={}", instance.getId(), task.getId(), task.getTaskName());
// 重新申请后,仅导出本轮审批记录。
for (String field : fields) {
for (String suffix : suffixes) docData.put(field + suffix, "");
for (String suffix : suffixes) docData.put(field + suffix, "Sign".equals(suffix) ? null : "");
}
continue;
}
for (int i = 0; i < taskKeys.length; i++) {
if (!taskKeys[i].equals(task.getTaskName())) continue;
for (String suffix : suffixes) docData.put(fields[i] + suffix, "");
if (!ProcessTaskStateEnum.FINISHED.getCode().equals(task.getTaskState())) continue;
for (String suffix : suffixes) docData.put(fields[i] + suffix, "Sign".equals(suffix) ? null : "");
if (!ProcessTaskStateEnum.FINISHED.getCode().equals(task.getTaskState())) {
log.info("[职工慰问导出] 跳过未完成审批,instanceId={}, taskId={}, field={}, state={}", instance.getId(), task.getId(), fields[i], task.getTaskState());
continue;
}
Dict data = taskVO.getExt() == null ? Dict.create() : taskVO.getTaskFormData();
log.info("[职工慰问导出] 读取审批表单,instanceId={}, taskId={}, field={}, formKeys={}, signPresent={}, signReferenceLength={}", instance.getId(), task.getId(), fields[i], data.keySet(), StrUtil.isNotBlank(data.getStr("sign")), StrUtil.nullToEmpty(data.getStr("sign")).length());
if (StrUtil.isBlank(data.getStr("sign"))) log.warn("[职工慰问导出] 已完成审批缺少签名字段,instanceId={}, taskId={}, field={}", instance.getId(), task.getId(), fields[i]);
docData.put(fields[i] + "Opinion", StrUtil.nullToEmpty(data.getStr("opinion")));
if (StrUtil.isNotBlank(data.getStr("sign"))) {
PictureRenderData signature = sysOfficeTemplateUtil.createPictureRenderData(90, 40, data.getStr("sign"));
if (signature == null) throw new BaseException("审批签字图片读取失败");
docData.put(fields[i] + "Sign", signature);
log.info("[职工慰问导出] 审批签名填充完成,instanceId={}, taskId={}, field={}", instance.getId(), task.getId(), fields[i] + "Sign");
}
docData.put(fields[i] + "Year", task.getFinishTime() == null ? "" : DateUtil.format(task.getFinishTime(), "yyyy"));
docData.put(fields[i] + "Month", task.getFinishTime() == null ? "" : DateUtil.format(task.getFinishTime(), "M"));
@@ -106,7 +106,7 @@ public class CondolenceReadController {
cnd.and(seg);
}
if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) {
if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_CHAIRMAN.name())) {
cnd.and("info.applyUnionId", "=", SecurityUtil.getUnionId());
}
@@ -153,7 +153,7 @@ public class CondolenceReadController {
seg.or("info.helpLoginName", "like", "%" + pageForm.getSearchKeyword() + "%");
cnd.and(seg);
}
if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name())) {
if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_CHAIRMAN.name())) {
cnd.and("con.applyUnionId", "=", SecurityUtil.getUnionId());
}
sql.setCondition(cnd);
@@ -9,7 +9,6 @@ import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.alibaba.excel.EasyExcel;
@@ -18,7 +17,6 @@ import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.CommonDownloadUtil;
import com.budwk.app.base.utils.MoneyUtil;
import com.budwk.app.base.utils.OfficePlusUtil;
import com.budwk.app.base.utils.SysOfficeTemplateUtil;
import com.budwk.app.flow.engine.FlowEngine;
import com.budwk.app.flow.entity.ProcessInstance;
@@ -39,7 +37,6 @@ import com.budwk.app.zhgh.staffbenefit.difficulthelp.service.DifficultHelpCommon
import com.budwk.app.zhgh.staffbenefit.difficulthelp.vo.DifficultHelpPageParam;
import com.budwk.app.zhgh.staffbenefit.difficulthelp.vo.moneyImportVo;
import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.aidFund.param.AidFundPageForm;
import com.deepoove.poi.XWPFTemplate;
import com.deepoove.poi.config.Configure;
import com.deepoove.poi.data.PictureRenderData;
import com.deepoove.poi.plugin.table.LoopRowTableRenderPolicy;
@@ -74,8 +71,6 @@ import javax.servlet.http.HttpServletResponse;
import java.awt.image.BufferedImage;
import java.io.*;
import java.net.URLEncoder;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
@@ -319,9 +314,9 @@ public class DifficultHelpReadingController {
LoopRowTableRenderPolicy policy = new LoopRowTableRenderPolicy();
Configure config = Configure.builder().bind("familyList", policy).build();
XWPFTemplate.compile(sysOfficeTemplateUtil.getTemplate("difficulty_apply"), config)
.render(docData)
.write(zipOutputStream);
try (InputStream input = sysOfficeTemplateUtil.getTemplate("difficulty_apply")) {
zipOutputStream.write(sysOfficeTemplateUtil.renderTemplate(input, docData, config, false, "difficulthelp:batch:" + record.getString("id")));
}
zipOutputStream.closeEntry();
}
zipOutputStream.flush();
@@ -379,17 +374,18 @@ public class DifficultHelpReadingController {
String[] taskKeys = {"36ebafda-44c2-4bd3-bd7f-10839a575a0d", "715ece82-4ae3-433c-ad18-ed875f45d1e3", "e405f96c-5fc0-4f63-b323-e69dfe853253"};
String[] fields = {"firstApproval", "secondApproval", "thirdApproval"};
for (String field : fields) {
for (String suffix : List.of("", "Opinion", "User", "Date", "Year", "Month", "Day", "Sign")) docData.put(field + suffix, "");
for (String suffix : List.of("", "Opinion", "User", "Date", "Year", "Month", "Day", "Sign")) docData.put(field + suffix, "Sign".equals(suffix) ? null : "");
}
ProcessInstance instance = dao.fetch(ProcessInstance.class, Cnd.where("businessNo", "=", businessId).desc("id"));
if (instance == null) return;
List<ProcessTask> tasks = dao.query(ProcessTask.class, Cnd.where("processInstanceId", "=", instance.getId()).asc("id"));
for (ProcessTask task : tasks) {
ProcessTaskVO taskVO = flowEngine.processTaskService().findById(task.getId());
if ("startTask".equals(task.getTaskName()) || (taskVO.getExt() != null && Boolean.TRUE.equals(taskVO.getExt().getBool("isFirstTaskNode")))) {
if ("startTask".equals(task.getTaskName()) || (!List.of(taskKeys).contains(task.getTaskName())
&& taskVO.getExt() != null && Boolean.TRUE.equals(taskVO.getExt().getBool("isFirstTaskNode")))) {
// 重新申请后,仅导出本轮审批,避免混入上一次退回或撤销前的意见。
for (String field : fields) {
for (String suffix : List.of("", "Opinion", "User", "Date", "Year", "Month", "Day", "Sign")) docData.put(field + suffix, "");
for (String suffix : List.of("", "Opinion", "User", "Date", "Year", "Month", "Day", "Sign")) docData.put(field + suffix, "Sign".equals(suffix) ? null : "");
}
continue;
}
@@ -453,22 +449,9 @@ public class DifficultHelpReadingController {
String fileName = FileUtil.mainName(sysOfficeTemplateUtil.getTemplateFileName(templateName)) + "_" + difficultHelp.getUserName() + "_" +
DateUtil.format(difficultHelp.getApplyTime(), "yyyyMMdd") + ".pdf";
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
XWPFTemplate.compile(getApplyTemplate(templateName),config)
.render(docData)
.writeAndClose(byteArrayOutputStream);
byte[] bytes = byteArrayOutputStream.toByteArray();
File sourceFile = File.createTempFile("file_convert_origin", ".docx");
Files.write(sourceFile.toPath(), bytes, StandardOpenOption.WRITE);
File pdfFile = File.createTempFile("file_convert", ".pdf");
OfficePlusUtil.convert(sourceFile.getPath(), pdfFile.getPath());
CommonDownloadUtil.download(fileName, IoUtil.readBytes(FileUtil.getInputStream(pdfFile)), response);
// 删除临时文件
FileUtil.del(sourceFile);
FileUtil.del(pdfFile.toPath());
} catch (IOException e) {
log.error("困难慰问申请表导出PDF失败,id:{},错误信息:{}", difficultHelp.getId(), e.getMessage());
try (InputStream input = getApplyTemplate(templateName)) {
byte[] bytes = sysOfficeTemplateUtil.renderTemplate(input, docData, config, true, "difficulthelp:" + difficultHelp.getId());
CommonDownloadUtil.download(fileName, bytes, response);
}
}
@@ -478,13 +461,9 @@ public class DifficultHelpReadingController {
String fileName = FileUtil.mainName(sysOfficeTemplateUtil.getTemplateFileName(templateName)) + "_" + difficultHelp.getUserName() + "_" +
DateUtil.format(difficultHelp.getApplyTime(), "yyyyMMdd") + ".docx";
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
XWPFTemplate.compile(getApplyTemplate(templateName),config)
.render(docData)
.writeAndClose(byteArrayOutputStream);
CommonDownloadUtil.download(fileName, byteArrayOutputStream.toByteArray(), response);
} catch (IOException e) {
log.error("困难慰问申请表导出Word失败,id:{},错误信息:{}", difficultHelp.getId(), e.getMessage());
try (InputStream input = getApplyTemplate(templateName)) {
byte[] bytes = sysOfficeTemplateUtil.renderTemplate(input, docData, config, false, "difficulthelp:" + difficultHelp.getId());
CommonDownloadUtil.download(fileName, bytes, response);
}
}
@@ -562,15 +541,13 @@ public class DifficultHelpReadingController {
throw new RuntimeException("导出Excel汇总表失败:" + e.getMessage());
}
} else if ("docx".equals(flag)) {
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
try (InputStream input = sysOfficeTemplateUtil.getTemplate(resource)) {
Configure config = Configure.builder()
.bind("da", new LoopRowTableRenderPolicy())
.build();
XWPFTemplate.compile(sysOfficeTemplateUtil.getTemplate(resource), config)
.render(result)
.writeAndClose(out);
CommonDownloadUtil.download(fileName + ".docx", out.toByteArray(), response);
byte[] bytes = sysOfficeTemplateUtil.renderTemplate(input, result, config, false, "difficulthelp:summary:" + resource);
CommonDownloadUtil.download(fileName + ".docx", bytes, response);
}
}
}
@@ -1,6 +1,7 @@
package com.budwk.app.zhgh.staffbenefit.difficulthelp.service;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.flow.entity.ProcessTask;
import com.budwk.app.zhgh.staffbenefit.difficulthelp.models.DifficultHelpInfo;
import com.budwk.app.zhgh.staffbenefit.difficulthelp.vo.DifficultHelpPageParam;
import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.aidFund.param.AidFundPageForm;
@@ -21,6 +22,14 @@ public interface DifficultHelpCommonService extends BaseService<DifficultHelpInf
void validateApplyUser(DifficultHelpInfo difficultHelpInfo);
/**
* 困难帮扶进入校工会办公室主任审批时,通知本次任务的实际参与人。
* @param task WF 已创建且参与人已落库的任务;仅 KNBF 办公室审批待办触发,
* 接收人使用任务参与人的 actorAccount(系统 loginname)
* @return 无返回值;开关关闭或非目标节点不发送,发送失败记录日志而不阻断流程
*/
void notifyOfficeApproval(ProcessTask task);
/**
* 导入实际金额数据处理
* @param file 文件
@@ -6,6 +6,12 @@ import cn.hutool.json.JSONUtil;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.exception.BaseException;
import com.budwk.app.base.wxwork.WxWorkMessageService;
import com.budwk.app.flow.entity.ProcessDefine;
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.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.base.utils.ManyAddOrRenewUtil;
@@ -31,6 +37,8 @@ import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Lang;
import org.nutz.lang.random.R;
import org.nutz.lang.util.NutMap;
import org.nutz.log.Log;
import org.nutz.log.Logs;
import org.nutz.mvc.upload.TempFile;
import java.util.*;
@@ -46,6 +54,8 @@ import java.util.stream.IntStream;
*/
@IocBean(args = {"refer:dao"})
public class DifficultHelpCommonServiceImpl extends BaseServiceImpl<DifficultHelpInfo> implements DifficultHelpCommonService {
private static final Log log = Logs.get();
private static final String OFFICE_APPROVAL_TASK = "36ebafda-44c2-4bd3-bd7f-10839a575a0d";
public DifficultHelpCommonServiceImpl(Dao dao) {
super(dao);
@@ -53,6 +63,55 @@ public class DifficultHelpCommonServiceImpl extends BaseServiceImpl<DifficultHel
@Inject
private ManyAddOrRenewUtil manyAddOrRenewUtil;
@Inject
private WxWorkMessageService wxWorkMessageService;
/**
* 只通知 KNBF 办公室待办的实际审核人;重新提交生成的新任务沿用同一入口。
* task 为任务开始事件对应的 WF 任务,actorAccount 为学校企业微信 userid。
* 无返回值;服务返回 skipped 表示开关关闭,success=false 表示接口失败或部分接收人无效。
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public void notifyOfficeApproval(ProcessTask task) {
if (task == null || task.getId() == null || task.getProcessInstanceId() == null
|| !OFFICE_APPROVAL_TASK.equals(task.getTaskName())
|| !ProcessTaskStateEnum.DOING.getCode().equals(task.getTaskState())) {
return;
}
try {
ProcessInstance instance = dao().fetch(ProcessInstance.class, task.getProcessInstanceId());
if (instance == null || instance.getProcessDefineId() == null) return;
ProcessDefine define = dao().fetch(ProcessDefine.class, instance.getProcessDefineId());
// 使用实例所属定义核验流程 KEY,避免其他流程复用同一个节点 ID 时误发。
if (define == null || !"KNBF".equals(define.getName())) return;
DifficultHelpInfo info = StrUtil.isBlank(instance.getBusinessNo()) ? null : fetch(instance.getBusinessNo());
if (info == null) {
log.warnf("困难帮扶企业微信通知跳过:申请不存在,taskId=%s", task.getId());
return;
}
List<ProcessTaskActor> actors = dao().query(ProcessTaskActor.class,
Cnd.where(ProcessTaskActor::getProcessTaskId, "=", task.getId()));
List<String> loginNames = actors.stream().map(ProcessTaskActor::getActorAccount)
.filter(StrUtil::isNotBlank).map(String::trim).distinct().toList();
// 不按角色扩大接收范围,未绑定工号时不以内部用户 ID 代替企业微信 userid。
if (loginNames.isEmpty()) {
log.warnf("困难帮扶企业微信通知跳过:审核人 loginname 为空,taskId=%s", task.getId());
return;
}
String content = "您有一条困难帮扶申请待审核,受助人:"
+ StrUtil.blankToDefault(info.getUserName(), "未填写") + ",请进入校工会办公室审批页面办理。";
// 流程定义中仍保留旧的 unionLeaderApproval 链接,本节点实际办理入口为 branchUnionApproval。
NutMap result = wxWorkMessageService.massSend(loginNames, "困难帮扶待办提醒", content,
"/platform/difficultHelp/branchUnionApproval/h5");
if (!result.getBoolean("skipped", false) && !result.getBoolean("success", false)) {
log.warnf("困难帮扶企业微信通知发送失败或部分失败,taskId=%s,result=%s", task.getId(), result);
}
} catch (Exception e) {
// 外部消息不改变审批结果;日志保留任务 ID,便于定位接口、账号或配置问题。
log.error("困难帮扶企业微信通知异常,taskId=" + task.getId(), e);
}
}
@Override
public void validateApplyUser(DifficultHelpInfo difficultHelpInfo) {
+3 -3
View File
@@ -5,7 +5,7 @@ nutz:
ignore: ^(.+[.])(jsp|png|gif|jpg|js|css|jspx|jpeg|html|mp3|mp4|ico|svg|vue)$
exclusions: /favicon/*,/assets/*,/druid/*,/upload/*,/components/*
server:
port: 8081
port: 80
host: 0.0.0.0
jetty:
@@ -21,7 +21,7 @@ jetty:
#结合ftp使用,或用nginx代理ftp路径
#staticPath: /Users/wizzer/temp/files
#开发模式静态资源
staticPathLocal: D:/myProject/zhgh_cqgmy/src/main/resources/static
staticPathLocal: E:/projects/zhgh_cqgmy/src/main/resources/static
security:
tokenName: saToken
@@ -87,7 +87,7 @@ xsssql:
beetl:
RESOURCE:
#本地路径,开发时设置本地路径,便于调试
rootLocal: D:/myProject/zhgh_cqgmy/src/main/resources/views/
rootLocal: E:/projects/zhgh_cqgmy/src/main/resources/views/
root: views/
DELIMITER_STATEMENT_START: "<!--#"
DELIMITER_STATEMENT_END: "#-->"
+89 -9
View File
@@ -1,17 +1,85 @@
<?xml version="1.0" encoding="UTF-8" ?>
<configuration scan="false" scanPeriod="60000" debug="false">
<!-- 定义日志文件的存储路径 -->
<!-- 日志存储路径 -->
<property name="LOG_HOME" value="./logs"/>
<!-- 控制台输出配置 -->
<!-- 控制台输出 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- INFO级别日志文件配置 -->
<!--
#################################################################################
# #
# 全文输出日志(一个文件存储)start #
# #
#################################################################################
-->
<!-- 所有级别日志写入同一个文件 -->
<appender name="ALL_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/app.log</file>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/app-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>300</maxHistory>
</rollingPolicy>
</appender>
<!-- 框架日志级别(按需调整) -->
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="org.quartz" level="INFO"/>
<logger name="org.nutz" level="DEBUG"/>
<!-- root logger:DEBUG 级别,输出到控制台和全量文件 -->
<root level="DEBUG">
<appender-ref ref="STDOUT"/>
<appender-ref ref="ALL_FILE"/>
</root>
<!--
#################################################################################
# #
# 全文输出日志(一个文件存储)end #
# #
#################################################################################
-->
<!--
#################################################################################
# #
# debug、info、error分文件输出 start #
# #
#################################################################################
-->
<!-- DEBUG级别日志文件配置 -->
<!--<appender name="DEBUG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/debug.log</file>
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>DEBUG</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/debug-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>300</maxHistory>
</rollingPolicy>
</appender>
&lt;!&ndash; INFO级别日志文件配置 &ndash;&gt;
<appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/info.log</file>
<encoder>
@@ -24,11 +92,11 @@
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/info-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
<maxHistory>300</maxHistory>
</rollingPolicy>
</appender>
<!-- ERROR级别日志文件配置 -->
&lt;!&ndash; ERROR级别日志文件配置 &ndash;&gt;
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/error.log</file>
<encoder>
@@ -41,19 +109,31 @@
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/error-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
<maxHistory>300</maxHistory>
</rollingPolicy>
</appender>
<logger name="java" additivity="false" />
&lt;!&ndash; 框架日志级别 &ndash;&gt;
&lt;!&ndash; <logger name="java" additivity="false" />&ndash;&gt;
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="org.quartz" level="INFO"/>
<logger name="org.nutz" level="DEBUG"/>
<!-- 日志级别和appender的关联 -->
&lt;!&ndash; 日志级别和appender的关联 &ndash;&gt;
<root level="DEBUG">
<appender-ref ref="STDOUT"/>
<appender-ref ref="DEBUG_FILE"/>
<appender-ref ref="INFO_FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
</root>-->
<!--
#################################################################################
# #
# debug、info、error分文件输出 end #
# #
#################################################################################
-->
</configuration>
@@ -0,0 +1 @@
7pKr23u2tZHcvXud
@@ -10,7 +10,7 @@
:finished="tableFinished"
finished-text="没有更多了"
@load="onLoad">
<div class="table-list-container">
<div class="table-list-container" @click="showOverflowText">
<div v-for="(row, index) in tableData" :key="index" class="table-list-item"
:data-row-id="row && row.id ? row.id : ''">
<slot name="header" :index="index" :row="row">
@@ -36,6 +36,12 @@
</div>
</div>
</van-list>
<van-popover ref="overflowPopover" :value="overflowVisible" :style="overflowAnchorStyle"
:placement="overflowPlacement" get-container="body" @input="onOverflowInput"
@opened="positionOverflow">
<div class="table-list-overflow-content">{{ overflowText }}</div>
<template #reference><span></span></template>
</van-popover>
</van-pull-refresh>
</template>
<script>
@@ -90,10 +96,138 @@ module.exports = {
tableLoading: false,
tableFinished: false,
tableRefreshing: false,
localPageForm: {...this.page_form}
localPageForm: {...this.page_form},
overflowVisible: false,
overflowText: "",
overflowTarget: null,
overflowPlacement: "top",
overflowAnchorStyle: {}
}
},
methods: {
// 只响应真正被截断的纯文字,保留卡片内按钮和业务插槽原有的点击行为。
showOverflowText(event) {
const target = event.target
if (!target || !target.closest) return
const card = target.closest(".table-list-item")
if (!card || target.closest(".item-actions, a, button, input, textarea, select, [role=button], [tabindex], [onclick]")) return
if (target.children.length || !target.textContent.trim() || this.hasOverflowClickHandler(target, card)) return
const style = window.getComputedStyle(target)
if (style.textOverflow !== "ellipsis" || (target.scrollWidth <= target.clientWidth && target.scrollHeight <= target.clientHeight)) return
const rect = target.getBoundingClientRect()
this.overflowTarget = target
this.overflowText = target.textContent.trim()
this.overflowPlacement = rect.top < 180 ? "bottom" : "top"
this.overflowAnchorStyle = {
position: "fixed",
display: "block",
pointerEvents: "none",
left: rect.left + "px",
top: rect.top + "px",
width: rect.width + "px",
height: rect.height + "px"
}
if (!this.overflowVisible) this.bindOverflowListeners()
this.overflowVisible = true
this.$nextTick(() => {
const instance = this.$refs.overflowPopover
if (instance) instance.updateLocation()
setTimeout(() => this.positionOverflow(), 50)
setTimeout(() => this.positionOverflow(), 350)
})
},
// 检查文字及其卡片内的祖先节点,避免干扰已有 Vue 点击事件。
hasOverflowClickHandler(target, card) {
const ancestors = []
let element = target
while (element && element !== card.parentElement) {
ancestors.push(element)
element = element.parentElement
}
const visit = (node) => {
if (!node) return false
const events = node.data ? [node.data.on, node.data.nativeOn] : []
if (ancestors.includes(node.elm) && events.some((handlers) => handlers &&
Object.keys(handlers).some((key) => /^[~!&]*(click|touchstart|pointerdown|mousedown)$/.test(key)))) return true
if (node.componentInstance && visit(node.componentInstance._vnode)) return true
return Array.isArray(node.children) && node.children.some((child) => visit(child))
}
return visit(this._vnode)
},
// Vant 的弹层挂在 body;校正窗口边缘的位置,避免完整文字被屏幕裁掉。
positionOverflow() {
const instance = this.$refs.overflowPopover
const popup = instance && instance.$refs.popover && instance.$refs.popover.$el
if (!this.overflowVisible || !this.overflowTarget || !popup) return
const rect = popup.getBoundingClientRect()
const anchor = this.overflowTarget.getBoundingClientRect()
if (!rect.width || !rect.height) return
const margin = 8
const left = Math.max(margin, Math.min(rect.left, window.innerWidth - rect.width - margin))
let top = rect.top
if (top < margin) top = anchor.bottom + 8
if (top + rect.height > window.innerHeight - margin) top = anchor.top - rect.height - 8
top = Math.max(margin, Math.min(top, window.innerHeight - rect.height - margin))
popup.style.left = (parseFloat(popup.style.left) || 0) + left - rect.left + "px"
popup.style.top = (parseFloat(popup.style.top) || 0) + top - rect.top + "px"
const arrow = popup.querySelector(".van-popover__arrow")
if (arrow) {
arrow.style.left = Math.max(12, Math.min(anchor.left + anchor.width / 2 - left, rect.width - 12)) + "px"
}
},
onOverflowInput(visible) {
this.overflowVisible = visible
if (!visible) {
this.overflowTarget = null
this.unbindOverflowListeners()
}
},
// 监听只在气泡打开时存在,PJAX 替换页面前也会关闭气泡并注销。
bindOverflowListeners() {
document.addEventListener("click", this.closeOverflowOnClick)
window.addEventListener("scroll", this.closeOverflowOnMove, true)
window.addEventListener("resize", this.closeOverflowOnMove)
window.addEventListener("transitionend", this.onOverflowAnimationEnd)
window.addEventListener("animationend", this.onOverflowAnimationEnd)
$(document).on("pjax:send", this.closeOverflowForNavigation)
},
unbindOverflowListeners() {
document.removeEventListener("click", this.closeOverflowOnClick)
window.removeEventListener("scroll", this.closeOverflowOnMove, true)
window.removeEventListener("resize", this.closeOverflowOnMove)
window.removeEventListener("transitionend", this.onOverflowAnimationEnd)
window.removeEventListener("animationend", this.onOverflowAnimationEnd)
$(document).off("pjax:send", this.closeOverflowForNavigation)
},
closeOverflowForNavigation() {
this.onOverflowInput(false)
},
// Vant 会在动画结束时重新计算定位,随后再次限制气泡的屏幕边界。
onOverflowAnimationEnd() {
if (this.overflowVisible) setTimeout(() => this.positionOverflow(), 0)
},
// 滚动或改变屏幕尺寸时关闭气泡,避免气泡脱离原文字位置。
closeOverflowOnMove(event) {
if (event && event.target && event.target.closest && event.target.closest(".table-list-overflow-content")) return
this.onOverflowInput(false)
},
closeOverflowOnClick(event) {
if (!this.overflowVisible) return
if (this.overflowTarget && this.overflowTarget.contains(event.target)) return
if (event.target.closest && event.target.closest(".table-list-overflow-content")) return
this.onOverflowInput(false)
},
/**
* 获取当前列表快照。
* 返回值包含已加载记录、分页参数和加载完成状态,供业务页面在进入详情前暂存列表现场。
@@ -186,10 +320,28 @@ module.exports = {
},
mounted() {
this.$emit("ready")
},
beforeDestroy() {
this.unbindOverflowListeners()
}
}
</script>
<style>
/* Vant 将气泡挂到 body,控制宽高并允许长文本在气泡内滚动。 */
.table-list-overflow-content {
max-width: min(240px, calc(100vw - 48px));
max-height: 160px;
padding: 10px 12px;
overflow-y: auto;
color: #253247;
font-size: 13px;
line-height: 1.55;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
</style>
<style scoped>
.table-list-container {
padding: 0;
@@ -35,22 +35,58 @@ module.exports = {
},
data() {
return {
//前端唯一标识 当然后端还有用户id作为唯一标识
// 二维码携带本次组件实例的标识,与服务端当前登录用户共同确定签字记录。
id: new Date().getTime() + Math.floor(Math.random() * 10000) + 1,
signatureAddress: null,
interval: null,
connectionWaitResolve: null,
unsubscribeSignature: null,
signatureContent: null,
loading: true,
}
},
methods: {
async init() {
const {code, data} = await this.$axios.post("/platform/signature/get")
if (code === 0) {
this.signatureContent = data && data.signature
this.$emit("input", this.signatureContent)
}
/**
* 无请求参数,返回 Promise;响应 code 为0表示成功,data.signature 为已有签字图片地址。
* 请求结束后由 mounted 的 finally 统一关闭 loading。
*/
init() {
this.signatureAddress = origin + "/platform/signature/scanPcCode?id=" + this.id
return this.$axios.post("/platform/signature/get")
.then((res) => {
if (this._isBeingDestroyed || this._isDestroyed) {
return
}
if (res.code !== 0) {
throw new Error("签字信息加载失败")
}
this.signatureContent = res.data && res.data.signature
this.$emit("input", this.signatureContent)
})
},
// 返回等待完成的 Promise;连接成功、等待30秒或组件销毁时结束等待。
waitForConnection() {
return new Promise((resolve) => {
const startedAt = Date.now()
this.connectionWaitResolve = resolve
const checkReady = () => {
this.interval = null
if (this._isBeingDestroyed || this._isDestroyed ||
(webSocketPubSub.ws && webSocketPubSub.ws.readyState === WebSocket.OPEN)) {
this.connectionWaitResolve = null
resolve()
return
}
if (Date.now() - startedAt >= 30000) {
this.connectionWaitResolve = null
this.$message.warning("签字消息连接超时,请刷新页面重试")
resolve()
return
}
this.interval = setTimeout(checkReady, 100)
}
checkReady()
})
},
signatureAgain() {
const val = sessionStorage.getItem("h5-scan-code-signature-" + this.id)
@@ -58,25 +94,42 @@ module.exports = {
this.$emit("input", val)
}
},
async mounted() {
await this.init()
webSocketPubSub.subscribe("h5-scan-code-signature", (data) => {
console.info("ws:收到签字成功消息:", data)
mounted() {
this.loading = true
// 先登记消息回调,连接成功后由公共连接加入房间,避免请求期间遗漏订阅。
this.unsubscribeSignature = webSocketPubSub.subscribe("h5-scan-code-signature", (data) => {
this.signatureContent = data.value
this.$emit("input", data.value)
})
// 可选:轮询或 watch ready 状态
const checkReady = () => {
if (webSocketPubSub.ws && webSocketPubSub.ws.readyState === WebSocket.OPEN) {
console.info("ws.readyState is open");
this.loading = false;
console.log(this.signatureAddress)
} else {
setTimeout(checkReady, 100);
this.init()
.then(() => {
if (!this._isBeingDestroyed && !this._isDestroyed) {
return this.waitForConnection()
}
})
.catch(() => {
if (!this._isBeingDestroyed && !this._isDestroyed) {
this.$message.error("签字信息加载失败,请刷新页面重试")
}
})
.finally(() => {
if (!this._isBeingDestroyed && !this._isDestroyed) {
this.loading = false
}
})
},
beforeDestroy() {
// pjax 切换页面时取消轮询和订阅,旧组件不得继续接收签字消息。
clearTimeout(this.interval)
this.interval = null
if (this.connectionWaitResolve) {
this.connectionWaitResolve()
this.connectionWaitResolve = null
}
if (this.unsubscribeSignature) {
this.unsubscribeSignature()
this.unsubscribeSignature = null
}
};
checkReady();
}
}
</script>
@@ -9,6 +9,7 @@
progress
:accept="accept"
:max-count="upload_number"
:upload-text="upload_text"
title=""
description=""
tip=""
@@ -50,6 +51,11 @@ module.exports = {
default: 1,
required: false
},
upload_text: {
type: String,
default: "",
required: false
},
// 上传返回id或url
upload_result_type: {
type: String,
@@ -101,6 +107,7 @@ module.exports = {
this.fileList = val.map((v) => {
return {
...v,
file: v.file || { name: v.name },
status: null,
isImage: ["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(v.name.split(".").pop().toLowerCase())
}
@@ -118,6 +125,7 @@ module.exports = {
this.fileList = val.map((v) => {
return {
...v,
file: v.file || { name: v.name },
url: v.url ? v.url : v.response?.data,
isImage: ["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(v.name.split(".").pop().toLowerCase()),
status: null
@@ -171,8 +179,8 @@ module.exports = {
f.url = resp.data
f.response = resp
f.percentage = 100
f.isImage = true
delete f.file
f.isImage = ["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(f.name.split(".").pop().toLowerCase())
f.file = { name: f.name }
delete f.content
} else {
f.status = "fail"
+115 -59
View File
@@ -443,50 +443,76 @@
constructor() {
this.ws = null
this.subscribers = new Map()
this.isSubscribed = false // 添加订阅状态标记
this.isSubscribed = false
this.connectTimer = null
this.reconnectTimer = null
this.pingTimer = null
this.isDisposed = false
this.init()
}
// 页面只创建一个心跳任务;每次连接自行绑定事件及握手超时。
init() {
try {
this.connect()
this.setupEventListeners()
this.setupPing()
this.setupWindowEvents()
} catch (ex) {
console.info(ex)
}
this.setupPing()
this.connect()
}
// 正在连接或关闭时等待对应事件,避免同时创建多个连接。
connect() {
if (this.isDisposed || this.reconnectTimer !== null ||
(this.ws && this.ws.readyState !== WebSocket.CLOSED)) {
return
}
try {
const WS_URL = window.location.host + "${base}/websocket"
const protocol = window.location.protocol === "http:" ? "ws://" : "wss://"
this.ws = new WebSocket(protocol + WS_URL)
this.isSubscribed = false
this.setupEventListeners()
const socket = this.ws
// 握手最多等待30秒,心跳不得提前关闭仍处于 CONNECTING 的连接。
this.connectTimer = setTimeout(() => {
this.connectTimer = null
if (this.ws === socket && socket.readyState === WebSocket.CONNECTING) {
console.warn("WebSocket 握手超时,关闭当前连接后重试", socket.url)
socket.close()
}
}, 30000)
} catch (error) {
console.error("WebSocket 创建连接失败", error)
this.reconnect()
}
}
//订阅
/**
* type 为消息 action 名称,callback 接收服务端消息对象;返回取消本次订阅的方法。
* 连接尚未建立时先保留回调,在 onopen 中统一加入当前用户的消息房间。
*/
subscribe(type, callback) {
if (!this.subscribers.has(type)) {
this.subscribers.set(type, new Set())
}
this.subscribers.get(type).add(callback)
setTimeout(() => {
// 所有订阅完成后,发送 join 消息
if (!this.isSubscribed && this.ws.readyState === WebSocket.OPEN) {
this.isSubscribed = true
const callbacks = this.subscribers.get(type)
callbacks.add(callback)
this.sendJoinMessage()
return () => {
callbacks.delete(callback)
if (callbacks.size === 0 && this.subscribers.get(type) === callbacks) {
this.subscribers.delete(type)
}
}
}, 2000)
}
// 事件处理
// 事件绑定到本次连接,旧连接的延迟事件不得影响新连接。
setupEventListeners() {
// 消息处理
this.ws.onmessage = (event) => {
const socket = this.ws
socket.onmessage = (event) => {
if (this.isDisposed || this.ws !== socket) {
return
}
try {
const data = JSON.parse(event.data)
// 触发订阅者的回调
if (this.subscribers.has(data.action)) {
this.subscribers.get(data.action).forEach((callback) => {
try {
@@ -500,82 +526,112 @@
console.error("消息解析错误:", error)
}
}
// 连接建立
this.ws.onopen = () => {
// this.ws.send(
// JSON.stringify({
// room: store.state.room,
// action: "join"
// })
// )
// 连接建立时不立即发送 join,等待订阅完成
socket.onopen = () => {
if (this.isDisposed || this.ws !== socket) {
return
}
clearTimeout(this.connectTimer)
this.connectTimer = null
this.sendJoinMessage()
this.ping()
console.info("WebSocket 连接已建立", socket.url)
}
socket.onerror = () => {
if (!this.isDisposed && this.ws === socket) {
console.warn("WebSocket 连接异常,等待关闭后重连", socket.url)
}
}
socket.onclose = (event) => {
if (this.isDisposed || this.ws !== socket) {
return
}
clearTimeout(this.connectTimer)
this.connectTimer = null
this.isSubscribed = false
console.warn("WebSocket 已关闭", event.code, event.reason)
this.reconnect()
}
}
// 新增发送 join 消息的方法
// 每个已建立的连接只加入一次房间,首次连接及断线恢复都补发已有订阅。
sendJoinMessage() {
if (this.isDisposed || this.isSubscribed || this.subscribers.size === 0 ||
!window.store || !store.state.room || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
return
}
this.send({
room: store.state.room,
action: "join"
})
this.isSubscribed = true
}
// 心跳检测
// 沿用原心跳间隔,仅对已建立的连接发送心跳。
setupPing() {
setInterval(() => this.ping(), (11624317 / 1000) * 2)
this.pingTimer = setInterval(() => this.ping(), (11624317 / 1000) * 2)
}
// CONNECTING/CLOSING 状态分别由握手超时和关闭事件处理。
ping() {
if (this.isDisposed) {
return
}
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
this.ws.send("{}")
} catch (e) {
this.reconnect()
} catch (error) {
console.warn("WebSocket 心跳发送失败,等待关闭后重连", error)
this.ws.close()
}
} else {
} else if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
this.reconnect()
}
}
// 关闭事件和心跳共用一个重连任务,避免重复定时器创建并行连接。
reconnect() {
if (this.ws) {
this.ws.close() // 确保旧连接已关闭
if (this.isDisposed || this.reconnectTimer !== null ||
(this.ws && this.ws.readyState !== WebSocket.CLOSED)) {
return
}
setTimeout(() => {
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.connect()
this.setupEventListeners()
// 如果之前已订阅,重新发送join消息
if (this.isSubscribed) {
setTimeout(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.sendJoinMessage()
}
}, 1000)
}
}, 3000) // 延迟3秒重连
}, 3000)
}
// 窗口事件处理
// 真正离开页面时释放连接和定时器;进入往返缓存时保留页面状态。
setupWindowEvents() {
let _beforeUnload_time = 0
window.onbeforeunload = () => {
_beforeUnload_time = new Date().getTime()
window.addEventListener("pagehide", (event) => {
if (event.persisted) {
return
}
window.onunload = () => {
const _gap_time = new Date().getTime() - _beforeUnload_time
if (_gap_time <= 5) {
this.isDisposed = true
clearTimeout(this.connectTimer)
clearTimeout(this.reconnectTimer)
clearInterval(this.pingTimer)
this.connectTimer = null
this.reconnectTimer = null
this.pingTimer = null
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
this.send({
room: "${@auth.getPrincipalProperty('loginname')}:${@auth.getSessionId()}",
room: store.state.room,
action: "left"
})
} catch (error) {
console.warn("WebSocket 离开房间消息发送失败", error)
}
}
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
this.ws.close()
}
})
}
// message 为消息对象或 JSON 字符串;连接未建立时不发送,返回值为 undefined。
send(message) {
if (this.ws) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
const messageStr = typeof message === "string" ? message : JSON.stringify(message)
this.ws.send(messageStr)
}
@@ -650,7 +706,7 @@
Vue.component("year-van-dropdown-item", httpVueLoader("/components/plugins/vantMore/yearVanDropDownItem.vue?v=" + h5ComponentVersion))
Vue.component("van-text-dialog", httpVueLoader("/components/plugins/vantMore/vantTextDialog.vue?v=" + h5ComponentVersion))
Vue.component("enum-tag", httpVueLoader("/components/plugins/sysEnum/EnumTag.vue?v=" + h5ComponentVersion))
Vue.component("table-list", httpVueLoader("/components/plugins/h5/TableList.vue?v=" + h5ComponentVersion))
Vue.component("table-list", httpVueLoader("/components/plugins/h5/TableList.vue?v=" + h5ComponentVersion + "_overflow1"))
Vue.component("table-column", httpVueLoader("/components/plugins/h5/TableColumn.vue?v=" + h5ComponentVersion))
Vue.component("file-preview", httpVueLoader("/components/plugins/sysFilePreview/H5Index.vue?v=" + h5ComponentVersion))
</script>
@@ -68,9 +68,10 @@
<link rel="stylesheet" href="${base!}/assets/platform/plugins/vxe/vxe-pc-ui.css" />
<link rel="stylesheet" href="${base!}/assets/platform/plugins/vxe/vxe-table.css" />
<script src="https://vxeui.com/umd/xe-utils@3.5.30/dist/xe-utils.umd.min.js"></script>
<script src="https://vxeui.com/umd/vxe-pc-ui@3.1.25/lib/index.umd.min.js"></script>
<script src="https://vxeui.com/umd/vxe-table@3.9.0/lib/index.umd.min.js"></script>
<!-- 使用随应用打包的本地资源,按工具库、UI、表格的依赖顺序加载,避免外链失效影响页面。 -->
<script src="${base!}/assets/platform/plugins/vxe/xe-utils.js"></script>
<script src="${base!}/assets/platform/plugins/vxe/vxe-pc-ui.js"></script>
<script src="${base!}/assets/platform/plugins/vxe/vue-table.js"></script>
<!-- 引入 form-create 和 designer -->
<script src="${base!}/assets/platform/plugins/form-create/form-create.min.js"></script>
@@ -153,51 +154,76 @@
constructor() {
this.ws = null
this.subscribers = new Map()
this.isSubscribed = false // 添加订阅状态标记
this.isSubscribed = false
this.connectTimer = null
this.reconnectTimer = null
this.pingTimer = null
this.isDisposed = false
this.init()
}
// 页面只创建一个心跳任务;每次连接自行绑定事件及握手超时。
init() {
try {
this.connect()
this.setupEventListeners()
this.setupPing()
this.setupWindowEvents()
} catch (ex) {
console.info(ex)
}
this.setupPing()
this.connect()
}
// 正在连接或关闭时等待对应事件,避免同时创建多个连接。
connect() {
if (this.isDisposed || this.reconnectTimer !== null ||
(this.ws && this.ws.readyState !== WebSocket.CLOSED)) {
return
}
try {
const WS_URL = window.location.host + "${base}/websocket"
// const WS_URL = 'zhgh.jshvc.edu.cn' + "${base}/websocket"
const protocol = window.location.protocol === "http:" ? "ws://" : "wss://"
this.ws = new WebSocket(protocol + WS_URL)
this.isSubscribed = false
this.setupEventListeners()
const socket = this.ws
// 握手最多等待30秒,心跳不得提前关闭仍处于 CONNECTING 的连接。
this.connectTimer = setTimeout(() => {
this.connectTimer = null
if (this.ws === socket && socket.readyState === WebSocket.CONNECTING) {
console.warn("WebSocket 握手超时,关闭当前连接后重试", socket.url)
socket.close()
}
}, 30000)
} catch (error) {
console.error("WebSocket 创建连接失败", error)
this.reconnect()
}
}
//订阅
/**
* type 为消息 action 名称,callback 接收服务端消息对象;返回取消本次订阅的方法。
* 连接尚未建立时先保留回调,在 onopen 中统一加入当前用户的消息房间。
*/
subscribe(type, callback) {
if (!this.subscribers.has(type)) {
this.subscribers.set(type, new Set())
}
this.subscribers.get(type).add(callback)
setTimeout(() => {
// 所有订阅完成后,发送 join 消息
if (!this.isSubscribed && this.ws.readyState === WebSocket.OPEN) {
this.isSubscribed = true
const callbacks = this.subscribers.get(type)
callbacks.add(callback)
this.sendJoinMessage()
return () => {
callbacks.delete(callback)
if (callbacks.size === 0 && this.subscribers.get(type) === callbacks) {
this.subscribers.delete(type)
}
}
}, 1000)
}
// 事件处理
// 事件绑定到本次连接,旧连接的延迟事件不得影响新连接。
setupEventListeners() {
// 消息处理
this.ws.onmessage = (event) => {
const socket = this.ws
socket.onmessage = (event) => {
if (this.isDisposed || this.ws !== socket) {
return
}
try {
const data = JSON.parse(event.data)
// 触发订阅者的回调
if (this.subscribers.has(data.action)) {
this.subscribers.get(data.action).forEach((callback) => {
try {
@@ -211,83 +237,112 @@
console.error("消息解析错误:", error)
}
}
// 连接建立
this.ws.onopen = () => {
// this.ws.send(
// JSON.stringify({
// room: store.state.room,
// action: "join"
// })
// )
// 连接建立时不立即发送 join,等待订阅完成
socket.onopen = () => {
if (this.isDisposed || this.ws !== socket) {
return
}
clearTimeout(this.connectTimer)
this.connectTimer = null
this.sendJoinMessage()
this.ping()
console.info('websocket open success')
console.info("WebSocket 连接已建立", socket.url)
}
socket.onerror = () => {
if (!this.isDisposed && this.ws === socket) {
console.warn("WebSocket 连接异常,等待关闭后重连", socket.url)
}
}
socket.onclose = (event) => {
if (this.isDisposed || this.ws !== socket) {
return
}
clearTimeout(this.connectTimer)
this.connectTimer = null
this.isSubscribed = false
console.warn("WebSocket 已关闭", event.code, event.reason)
this.reconnect()
}
}
// 新增发送 join 消息的方法
// 每个已建立的连接只加入一次房间,首次连接及断线恢复都补发已有订阅。
sendJoinMessage() {
if (this.isDisposed || this.isSubscribed || this.subscribers.size === 0 ||
!window.store || !store.state.room || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
return
}
this.send({
room: store.state.room,
action: "join"
})
this.isSubscribed = true
}
// 心跳检测
// 沿用原心跳间隔,仅对已建立的连接发送心跳。
setupPing() {
setInterval(() => this.ping(), (11624317 / 1000) * 2)
this.pingTimer = setInterval(() => this.ping(), (11624317 / 1000) * 2)
}
// CONNECTING/CLOSING 状态分别由握手超时和关闭事件处理。
ping() {
if (this.isDisposed) {
return
}
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
this.ws.send("{}")
} catch (e) {
this.reconnect()
} catch (error) {
console.warn("WebSocket 心跳发送失败,等待关闭后重连", error)
this.ws.close()
}
} else {
} else if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
this.reconnect()
}
}
// 关闭事件和心跳共用一个重连任务,避免重复定时器创建并行连接。
reconnect() {
if (this.ws) {
this.ws.close() // 确保旧连接已关闭
if (this.isDisposed || this.reconnectTimer !== null ||
(this.ws && this.ws.readyState !== WebSocket.CLOSED)) {
return
}
setTimeout(() => {
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.connect()
this.setupEventListeners()
// 如果之前已订阅,重新发送join消息
if (this.isSubscribed) {
setTimeout(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.sendJoinMessage()
}
}, 500)
}
}, 1000) // 延迟2秒重连
}, 1000)
}
// 窗口事件处理
// 真正离开页面时释放连接和定时器;进入往返缓存时保留页面状态。
setupWindowEvents() {
let _beforeUnload_time = 0
window.onbeforeunload = () => {
_beforeUnload_time = new Date().getTime()
window.addEventListener("pagehide", (event) => {
if (event.persisted) {
return
}
window.onunload = () => {
const _gap_time = new Date().getTime() - _beforeUnload_time
if (_gap_time <= 5) {
this.isDisposed = true
clearTimeout(this.connectTimer)
clearTimeout(this.reconnectTimer)
clearInterval(this.pingTimer)
this.connectTimer = null
this.reconnectTimer = null
this.pingTimer = null
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
this.send({
room: "${@auth.getPrincipalProperty('loginname')}:${@auth.getSessionId()}",
room: store.state.room,
action: "left"
})
} catch (error) {
console.warn("WebSocket 离开房间消息发送失败", error)
}
}
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
this.ws.close()
}
})
}
// message 为消息对象或 JSON 字符串;连接未建立时不发送,返回值为 undefined。
send(message) {
if (this.ws) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
const messageStr = typeof message === "string" ? message : JSON.stringify(message)
this.ws.send(messageStr)
}
@@ -481,7 +536,8 @@
align-items: center;
gap: 24px;
height: 100%;
max-width: 310px;
/* 用户区按完整姓名、账号及操作按钮的实际宽度布局,避免退出按钮溢出后被顶栏裁切。 */
width: max-content;
flex-shrink: 0;
}
@@ -617,6 +673,26 @@
height: 100%;
}
/* 常见笔记本宽度下压缩导航留白,为完整用户信息与退出按钮腾出空间。 */
@media (min-width: 1025px) and (max-width: 1440px) {
.v4-nav {
gap: 12px;
}
.v4-nav-item {
padding: 0 8px;
column-gap: 6px;
}
.v4-user-section {
gap: 12px;
}
.v4-user-info {
padding: 0 6px;
}
}
@media (max-width: 1024px) {
.v4-nav {
gap: 24px;
@@ -100,7 +100,7 @@ layout("/layouts/platform.html"){
size="mini" type="danger">删除
</el-button>
<el-button @click="doExportDeclare(row)" size="mini" type="primary">申请表导出</el-button>
<el-button v-if="row.instanceState === 20" @click="doExportDeclare(row)" size="mini" type="primary">申请表导出</el-button>
<el-button v-if="row.instanceState === 20" @click="doExportReimbursement(row)" size="mini"
type="primary">报销凭证导出
</el-button>
@@ -21,14 +21,29 @@ const PROPOSAL_INFO = {
</el-descriptions-item>
<el-descriptions-item label="提案类别">
{{viewData.typeName}}
</el-descriptions-item><!--
</el-descriptions-item>
<!-- 提案方式和联系电话来自提案详情,未填写时显示占位符。 -->
<el-descriptions-item label="提案方式">
<dict-tag v-if="viewData.source" :options="dict.type.PROPOSAL_SOURCE"
:value="viewData.source"></dict-tag>
<span v-else>--</span>
</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ viewData.mobile || '--' }}</el-descriptions-item><!--
<el-descriptions-item label="建议承办单位">
{{viewData.suggestUnits}}
</el-descriptions-item>-->
<el-descriptions-item label="立案结果" :span="2">
<dict-tag :options="dict.type.PROPOSAL_CASE_FILING_RESULT"
<!-- 立案结果和类型尚未填写时仍显示字段,并以双横线表示空值。 -->
<el-descriptions-item label="立案结果">
<dict-tag v-if="viewData.caseFilingResult" :options="dict.type.PROPOSAL_CASE_FILING_RESULT"
:value="viewData.caseFilingResult"></dict-tag>
<span v-else>--</span>
</el-descriptions-item>
<el-descriptions-item label="立案类型">
<dict-tag v-if="viewData.caseFilingType" :options="dict.type.PROPOSAL_CASE_FILING_TYPE"
:value="viewData.caseFilingType"></dict-tag>
<span v-else>--</span>
</el-descriptions-item>
<el-descriptions-item></el-descriptions-item>
<!--<el-descriptions-item label="调研情况" :span="3">
<div class="text-left">{{viewData.researchFindings}}</div>
</el-descriptions-item>-->
@@ -82,18 +97,22 @@ const PROPOSAL_INFO = {
{{row.commissionerName}}({{row.commissionerLoginName}})
</template>
</el-table-column>
<el-table-column label="立案结果" prop="caseFilingType">
<!-- 委员意见分别展示立案结果和类型,未填写时显示双横线。 -->
<el-table-column label="立案结果" prop="caseFilingResult">
<template slot-scope="{row}">
<div style="display: flex;justify-content: center">
<dict-tag :options="dict.type.PROPOSAL_CASE_FILING_RESULT"
<dict-tag v-if="row.caseFilingResult" :options="dict.type.PROPOSAL_CASE_FILING_RESULT"
:value="row.caseFilingResult"></dict-tag>
<!-- 普通提案/重点提案暂不展示,历史数据仍保留。 -->
<template v-if="false">
(
<dict-tag :options="dict.type.PROPOSAL_CASE_FILING_TYPE"
:value="row.caseFilingType"></dict-tag>
)
<span v-else>--</span>
</div>
</template>
</el-table-column>
<el-table-column label="立案类型" prop="caseFilingType">
<template slot-scope="{row}">
<div style="display: flex;justify-content: center">
<dict-tag v-if="row.caseFilingType" :options="dict.type.PROPOSAL_CASE_FILING_TYPE"
:value="row.caseFilingType"></dict-tag>
<span v-else>--</span>
</div>
</template>
</el-table-column>
@@ -162,7 +181,8 @@ const PROPOSAL_INFO = {
<el-descriptions-item label="办理用户">{{ task.taskFormData.userName
}}({{task.taskFormData.loginName}})
</el-descriptions-item>
<el-descriptions-item label="办理时间" :span="2">{{ task.finishTime }}</el-descriptions-item>
<el-descriptions-item label="办理时间" :span="2">{{ task.finishTime }}
</el-descriptions-item>
<el-descriptions-item label="附议人" :span="3" v-if="task.taskName === 'invite'">
<el-table v-loading="tableLoading" :data="task?.taskFormData?.seconder">
<el-table-column label="姓名" prop="userName"></el-table-column>
@@ -183,22 +203,27 @@ const PROPOSAL_INFO = {
}}({{task.taskFormData.loginName}})
</el-descriptions-item>
<el-descriptions-item label="办理时间">{{ task.finishTime }}</el-descriptions-item>
<!-- 立案节点分别展示流程记录中的结果与类型,空值显示双横线。 -->
<el-descriptions-item label="立案结果">
<div style="display: flex;justify-content: center">
<dict-tag :options="dict.type.PROPOSAL_CASE_FILING_RESULT"
<dict-tag v-if="task.ext.tf_caseFilingResult"
:options="dict.type.PROPOSAL_CASE_FILING_RESULT"
:value="task.ext.tf_caseFilingResult"></dict-tag>
<!-- 普通提案/重点提案暂不展示,历史流程变量仍保留。 -->
<template v-if="false">
(
<dict-tag :options="dict.type.PROPOSAL_CASE_FILING_TYPE"
<span v-else>--</span>
</div>
</el-descriptions-item>
<el-descriptions-item label="立案类型">
<div style="display: flex;justify-content: center">
<dict-tag v-if="task.ext.tf_caseFilingType"
:options="dict.type.PROPOSAL_CASE_FILING_TYPE"
:value="task.ext.tf_caseFilingType"></dict-tag>
)
</template>
<span v-else>--</span>
</div>
</el-descriptions-item>
<template v-if="['CONFIRM_FILING','SUGGESTION'].includes(task.ext.tf_caseFilingResult)">
<el-descriptions-item v-if="task.ext.tf_caseFilingResult === 'SUGGESTION'" label="承办单位" :span="3">{{task?.ext?.tf_masterUnitNameStr || task?.ext?.tf_slaveUnitNameStr}}
<el-descriptions-item v-if="task.ext.tf_caseFilingResult === 'SUGGESTION'"
label="承办单位" :span="3">{{task?.ext?.tf_masterUnitNameStr ||
task?.ext?.tf_slaveUnitNameStr}}
</el-descriptions-item>
<template v-else>
<el-descriptions-item label="主办单位" :span="3">{{task?.ext?.tf_masterUnitNameStr}}
@@ -214,7 +239,8 @@ const PROPOSAL_INFO = {
<el-descriptions border class="flow-task-form" :column="3" :key="task.id" v-else>
<el-descriptions-item label="承办单位"
v-if="['opinion_unit_reply','unit_reply','two_unit_reply','opinion_master_reply','master_reply','slave_reply'].includes(task.taskName)" :span="3">
v-if="['opinion_unit_reply','unit_reply','two_unit_reply','opinion_master_reply','master_reply','slave_reply'].includes(task.taskName)"
:span="3">
{{task.ext.underTakeName}}
</el-descriptions-item>
@@ -223,7 +249,8 @@ const PROPOSAL_INFO = {
</el-descriptions-item>
<el-descriptions-item label="办理时间">{{ task.finishTime }}</el-descriptions-item>
<el-descriptions-item label="办理评价" v-if="['feedback'].includes(task.taskName)" :span="3">
<el-descriptions-item label="办理评价" v-if="['feedback'].includes(task.taskName)"
:span="3">
<dict-tag :options="dict.type.PROPOSAL_FEEDBACK"
:value="task.ext.tf_feedback"></dict-tag>
</el-descriptions-item>
@@ -243,7 +270,7 @@ const PROPOSAL_INFO = {
<snaker-chart ref="snakerChartRef"></snaker-chart>
</div>
`,
dicts: ["PROPOSAL_TYPE", "PROPOSAL_CASE_FILING_RESULT", "PROPOSAL_CASE_FILING_TYPE", "PROCESS_TASK_SUBMIT_TYPE", "PROPOSAL_FEEDBACK"],
dicts: ["PROPOSAL_TYPE", "PROPOSAL_SOURCE", "PROPOSAL_CASE_FILING_RESULT", "PROPOSAL_CASE_FILING_TYPE", "PROCESS_TASK_SUBMIT_TYPE", "PROPOSAL_FEEDBACK"],
props: {
hide_slave: false
},
@@ -60,7 +60,10 @@ const USER_COMPONENT = {
},
filterUnit(query, item) {
return item.username.indexOf(query) > -1
// 按姓名或工号筛选人员,字段为空时按空字符串处理。
const username = item.username || ""
const loginname = item.loginname || ""
return username.indexOf(query) > -1 || loginname.indexOf(query) > -1
}
}
}
@@ -23,9 +23,9 @@ layout("/layouts/platform.html"){
</el-card>
<el-card shadow="never">
<table-tool :columns.sync="tableColumns">
<el-button class="ml5" icon="el-icon-upload2" type="primary" size="small"
<!-- <el-button class="ml5" icon="el-icon-upload2" type="primary" size="small"
@click="openImport">导入提案
</el-button>
</el-button>-->
</table-tool>
<el-table v-loading="tableLoading" :data="tableData" @sort-change="pageOrder" header-align="center" style="width: 100%" row-key="id">
<el-table-column label="序号" width="50" type="index" :index="indexMethod" fixed="left"></el-table-column>
@@ -76,8 +76,7 @@ layout("/layouts/platform.html"){
<el-button v-if="row.canRevoke" size="mini" type="danger" @click="openRevoke(row)">
撤回
</el-button>
<!-- v-if="row.taskKey === 'startTask' || !row.instanceId"-->
<el-button size="mini" type="danger" @click="del(row.id)">
<el-button v-if="row.taskKey === 'startTask' || !row.instanceId" size="mini" type="danger" @click="del(row.id)">
删除
</el-button>
</template>
@@ -8,7 +8,7 @@ layout("/layouts/platform.html"){
<custom-card>
<snaker-start slot="header" label="撰写提案" define_key="JDHTA_NC">
<template slot="header-right-label">
<el-link type="primary" @click="openImport" v-if="!formData.id" style="margin-right: 15px">导入提案</el-link>
<!-- <el-link type="primary" @click="openImport" v-if="!formData.id" style="margin-right: 15px">导入提案</el-link>-->
</template>
</snaker-start>
<el-form :model="formData" :rules="formRules" label-width="120px" ref="addForm">
@@ -55,7 +55,7 @@ layout("/layouts/platform.html"){
</el-select>
</el-form-item>
</el-descriptions-item>-->
<el-descriptions-item label="证明人">
<!-- <el-descriptions-item label="证明人">
<el-form-item prop="certifierUserId" label="证明人">
<el-select
style="width: 100%"
@@ -75,7 +75,7 @@ layout("/layouts/platform.html"){
</el-option>
</el-select>
</el-form-item>
</el-descriptions-item>
</el-descriptions-item> -->
<el-descriptions-item label="慰问类型">
<el-form-item prop="type" label="慰问类型">
<el-select v-model="formData.type" @change="typeChange" style="width: 100%"
@@ -112,14 +112,14 @@ layout("/layouts/platform.html"){
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="收款账户">
<el-descriptions-item label="收款账户" v-if="!['A', 'D'].includes(formData.typeCode)">
<el-form-item prop="bankCardNumber" label="收款账户">
<el-input maxlength="20" v-model="formData.bankCardNumber" placeholder="请填写收款账户"
<el-input maxlength="30" v-model="formData.bankCardNumber" placeholder="请填写银行卡号或对公账户号"
></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="户名">
<el-descriptions-item label="户名" v-if="!['A', 'D'].includes(formData.typeCode)">
<el-form-item prop="bankUserName" label="户名">
<el-input maxlength="20" v-model="formData.bankUserName"
placeholder="请填写户名"
@@ -127,7 +127,7 @@ layout("/layouts/platform.html"){
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="开户行" >
<el-descriptions-item label="开户行" v-if="!['A', 'D'].includes(formData.typeCode)">
<el-form-item prop="bankOfDeposit" label="开户行">
<el-input maxlength="20" v-model="formData.bankOfDeposit"
placeholder="请填写开户行"
@@ -173,10 +173,12 @@ layout("/layouts/platform.html"){
</template>
</el-descriptions>
<el-descriptions :column="3" border>
<el-descriptions-item label="申请事由" :span="3">
<el-form-item prop="remark" label="申请事由">
<el-input maxlength="500" v-model="formData.remark" placeholder="请填写申请事由"
type="textarea" :autosize="{ minRows: 4, maxRows: 8}"></el-input>
type="textarea" :rows="3" resize="none"></el-input>
</el-form-item>
</el-descriptions-item>
@@ -238,7 +240,10 @@ layout("/layouts/platform.html"){
payUserId: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
type: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
way: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
money: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
money: [
{ required: true, message: "请填写慰问金额", trigger: ["change", "blur"] },
{ validator: (rule, value, callback) => callback(this.validateMoney(value) ? undefined : new Error("慰问金额须大于0且不超过10000元,最多两位小数")), trigger: ["change", "blur"] }
],
occurTime: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
child: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
hospital: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
@@ -247,14 +252,20 @@ layout("/layouts/platform.html"){
hospitalBy: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
deadImmediateFamily: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
remark: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
files: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
files: [{ required: true, type: "array", min: 1, max: 5, message: "请上传1至5个附件", trigger: ["change", "blur"] }],
signature: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
handlerUserId: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
certifierUserId: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
bankCardNumber: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
// certifierUserId: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
bankCardNumber: [
{ required: true, message: "请填写银行卡号或对公账户号", trigger: ["change", "blur"] },
{ pattern: /^[0-9]{6,30}$/, message: "收款账户须为6至30位数字(银行卡号或对公账户号)", trigger: ["change", "blur"] }
],
bankUserName: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
bankOfDeposit: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
thisYearHospitalizationNum: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
thisYearHospitalizationNum: [
{ required: true, message: "必填", trigger: ["change", "blur"] },
{ type: "integer", min: 0, max: 10000, message: "住院次数须为0至10000的整数", trigger: ["change", "blur"] }
],
leaveHospitalTime: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
hospitalizationTime: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
},
@@ -268,6 +279,9 @@ layout("/layouts/platform.html"){
}
},
methods: {
validateMoney(value) {
return /^(0|[1-9][0-9]*)(\.[0-9]{1,2})?$/.test(String(value)) && Number(value) > 0 && Number(value) <= 10000
},
createRemoteMethod(options) {
return (keyword) => {
this.selectQueryUser(keyword, options)
@@ -37,12 +37,12 @@ const basicForm = {
<el-radio-button :label="false">不上传</el-radio-button>
</el-radio-group>
</el-form-item>-->
<!-- <el-form-item label="是否上传附件" prop="isUploadFile">-->
<!-- <el-radio-group v-model="formData.isUploadFile">-->
<!-- <el-radio-button :label="true">是</el-radio-button>-->
<!-- <el-radio-button :label="false">否</el-radio-button>-->
<!-- </el-radio-group>-->
<!-- </el-form-item>-->
<el-form-item label="是否上传附件" prop="isUploadFile">
<el-radio-group v-model="formData.isUploadFile">
<el-radio-button :label="true">是</el-radio-button>
<el-radio-button :label="false">否</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="上传附件说明" prop="uploadFileDesc" v-if="formData.isUploadFile">
<el-input v-model="formData.uploadFileDesc" maxlength="50" clearable placeholder="请填写上传附件说明"></el-input>
</el-form-item>
@@ -26,7 +26,7 @@ layout("/layouts/platform.html"){
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="受补助人">
<el-form-item prop="userName">
<el-form-item prop="userId">
<template v-if="formData.mode == 2">
<el-select :remote-method="queryRecipients"
@change="userChange" clearable filterable
@@ -51,7 +51,7 @@ layout("/layouts/platform.html"){
</el-descriptions-item>
<el-descriptions-item label="身份证号" :span="2">
<el-form-item prop="idCard">
<el-input maxlength="20" clearable placeholder="请填写身份证号"
<el-input clearable placeholder="请填写身份证号"
v-model="formData.idCard" maxlength="18"></el-input>
</el-form-item>
</el-descriptions-item>
@@ -84,7 +84,7 @@ layout("/layouts/platform.html"){
<el-descriptions-item label="手机号码">
<el-form-item prop="mobile">
<el-input maxlength="11" clearable placeholder="请填写手机号码"
v-model="formData.mobile" maxlength="11"></el-input>
v-model="formData.mobile"></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="家庭年总收入(万元)">
@@ -103,6 +103,7 @@ layout("/layouts/platform.html"){
<el-input-number
v-model="formData.homeNumber"
:min="1"
:precision="0"
:step="1"
style="width: 100%"
placeholder="请填写家庭人数">
@@ -113,7 +114,7 @@ layout("/layouts/platform.html"){
<el-form-item prop="homeAddress">
<el-input clearable
style="width: 100%"
v-model="formData.homeAddress"
v-model="formData.homeAddress" maxlength="200"
placeholder="请填写家庭住址">
</el-input>
</el-form-item>
@@ -132,7 +133,7 @@ layout("/layouts/platform.html"){
<!-- </el-descriptions-item>-->
<el-descriptions-item label="银行卡号" >
<el-form-item prop="bankCardNum">
<el-input maxlength="20" clearable placeholder="请填写银行卡号"
<el-input maxlength="19" clearable placeholder="请填写银行卡号"
v-model="formData.bankCardNum"></el-input>
</el-form-item>
</el-descriptions-item>
@@ -152,7 +153,7 @@ layout("/layouts/platform.html"){
</el-descriptions-item>
<el-descriptions-item label="申请金额(元)">
<el-form-item prop="applyMoney" :rules="[{required:true,type:'number',min:0.01,message:'请填写大于零的申请金额',trigger:['change','blur']}]">
<el-form-item prop="applyMoney">
<el-input-number v-model="formData.applyMoney" :precision="2" :min="0.01" placeholder="请输入申请金额"></el-input-number>
</el-form-item>
</el-descriptions-item>
@@ -198,8 +199,8 @@ layout("/layouts/platform.html"){
<template slot-scope="{row,$index}">
<el-form-item label-width="0"
:prop="'familyList.'+$index+'.relation'"
:rules="[{required:true,message:'必填',trigger:['change','blur']}]">
<el-input v-model="row.relation" placeholder="请填写与本人关系" clearable></el-input>
:rules="textRules('与本人关系', 50)">
<el-input v-model="row.relation" maxlength="50" placeholder="请填写与本人关系" clearable></el-input>
</el-form-item>
</template>
</el-table-column>
@@ -207,8 +208,8 @@ layout("/layouts/platform.html"){
<template slot-scope="{row,$index}">
<el-form-item label-width="0"
:prop="'familyList.'+$index+'.name'"
:rules="[{required:true,message:'必填',trigger:['change','blur']}]">
<el-input v-model="row.name" placeholder="请填写姓名" clearable></el-input>
:rules="textRules('姓名', 100)">
<el-input v-model="row.name" maxlength="100" placeholder="请填写姓名" clearable></el-input>
</el-form-item>
</template>
</el-table-column>
@@ -216,9 +217,9 @@ layout("/layouts/platform.html"){
<template slot-scope="{row,$index}">
<el-form-item label-width="0"
:prop="'familyList.'+$index+'.age'"
:rules="[{required:true,message:'必填',trigger:['change','blur']}]">
:rules="[{required:true,type:'integer',min:1,max:100,message:'年龄须为1至100的整数',trigger:['change','blur']}]">
<el-input-number v-model="row.age" placeholder="年龄" :min="1"
:max="100" :step="1" style="width: 100%" clearable></el-input-number>
:max="100" :precision="0" :step="1" style="width: 100%" clearable></el-input-number>
</el-form-item>
</template>
</el-table-column>
@@ -226,8 +227,8 @@ layout("/layouts/platform.html"){
<template slot-scope="{row,$index}">
<el-form-item label-width="0"
:prop="'familyList.'+$index+'.homeUnit'"
:rules="[{required:true,message:'必填',trigger:['change','blur']}]">
<el-input v-model="row.homeUnit" placeholder="请填写工作单位" clearable></el-input>
:rules="textRules('工作单位', 200)">
<el-input v-model="row.homeUnit" maxlength="200" placeholder="请填写工作单位" clearable></el-input>
</el-form-item>
</template>
</el-table-column>
@@ -235,9 +236,9 @@ layout("/layouts/platform.html"){
<template slot-scope="{row,$index}">
<el-form-item label-width="0"
:prop="'familyList.'+$index+'.yearIncome'"
:rules="[{required:true,message:'必填',trigger:['change','blur']}]">
:rules="[{validator:validateIncome,required:true,trigger:['change','blur']}]">
<el-input-number v-model="row.yearIncome" placeholder="年收入"
:min="0" :step="1" style="width: 100%" clearable></el-input-number>
:min="0" :precision="2" :step="1" style="width: 100%" clearable></el-input-number>
</el-form-item>
</template>
</el-table-column>
@@ -245,8 +246,8 @@ layout("/layouts/platform.html"){
<template slot-scope="{row,$index}">
<el-form-item label-width="0"
:prop="'familyList.'+$index+'.remarks'"
:rules="[{required:true,message:'必填',trigger:['change','blur']}]">
<el-input v-model="row.remarks" placeholder="请填写备注" clearable></el-input>
:rules="textRules('备注', 500)">
<el-input v-model="row.remarks" maxlength="500" placeholder="请填写备注" clearable></el-input>
</el-form-item>
</template>
</el-table-column>
@@ -326,23 +327,37 @@ layout("/layouts/platform.html"){
applyCount: '1',
},
formRules: {
proxyUserName: [{required: true, message: '必填', trigger: ['blur', 'change']}],
proxyLoginName: [{required: true, message: '必填', trigger: ['blur', 'change']}],
sex: [{required: true, message: '请填写性别', trigger: ['blur', 'change']}],
userName: [{required: true, message: '请填写受补助人', trigger: ['blur', 'change']}],
unitName: [{required: true, message: '请填写所属单位', trigger: ['blur', 'change']}],
proxyUserName: this.textRules('填写人姓名', 100),
proxyLoginName: this.textRules('填写人工号', 100),
mode: [{required: true, type: 'enum', enum: ['1', '2'], message: '请选择申请模式', trigger: ['blur', 'change']}],
sex: [{required: true, type: 'enum', enum: ['男', '女'], message: '请选择性别', trigger: ['blur', 'change']}],
userId: [{required: true, message: '请选择受补助人', trigger: ['blur', 'change']}],
unitName: this.textRules('所属单位', 500),
unionName: [{required: true, message: '请填写所属工会', trigger: ['blur', 'change']}],
homeAddress: [{required: true, message: '请填写家庭住址', trigger: ['blur', 'change']}],
mobile: [{required: true, message: '请填写手机号码', trigger: ['blur', 'change']}],
idCard: [{required: true, message: '请填写身份证号', trigger: ['blur', 'change']}],
bankCardNum: [{required: true, message: '请填写收款账户卡号', trigger: ['blur', 'change']}],
homeAddress: this.textRules('家庭住址', 200),
mobile: [
{required: true, message: '请填写手机号码', trigger: ['blur', 'change']},
{pattern: /^1[3-9]\d{9}$/, message: '请输入正确的11位手机号码', trigger: ['blur', 'change']}
],
idCard: [{validator: (rule, value, callback) => {
const result = this.isValidIdCard(value || '')
callback(result.status === 0 ? new Error(result.msg) : undefined)
}, required: true, trigger: ['blur', 'change']}],
bankCardNum: [{validator: (rule, value, callback) => {
const result = this.validateCard(value || '')
callback(result.status === 0 ? new Error(result.msg) : undefined)
}, required: true, trigger: ['blur', 'change']}],
bankUserName: [{required: true, message: '请填写户名', trigger: ['blur', 'change']}],
bankOfDeposit: [{required: true, message: '请填写开户行', trigger: ['blur', 'change']}],
position: [{required: true, message: '请填写职务', trigger: ['blur', 'change']}],
officialCapacity: [{required: true, message: '请填写职称', trigger: ['blur', 'change']}],
subsidyStandards: [{required: true, message: '请选择困难类型', trigger: ['blur', 'change']}],
reason: [{required: true, message: '请填写申请原因', trigger: ['blur', 'change']}],
sign: [{required: true, message: '请签字', trigger: ['blur', 'change']}]
bankOfDeposit: this.textRules('开户行', 30),
position: this.textRules('职务', 20),
officialCapacity: this.textRules('职称', 20),
homeIncome: [{validator: this.validateIncome, required: true, trigger: ['blur', 'change']}],
homeNumber: [{required: true, type: 'integer', min: 1, message: '家庭人数须为大于零的整数', trigger: ['blur', 'change']}],
subsidy: this.textRules('补助类型', 500),
subsidyStandards: this.textRules('困难类型', 500),
applyMoney: [{validator: this.validateMoney, required: true, trigger: ['blur', 'change']}],
reason: this.textRules('生活致困原因', 500),
sign: [{required: true, whitespace: true, message: '请完成签字', trigger: ['blur', 'change']}],
},
// 替他人申请相关
@@ -350,8 +365,21 @@ layout("/layouts/platform.html"){
}
},
methods: {
textRules(label, max) {
return [{required: true, whitespace: true, max, message: '请填写' + label + ',且不超过' + max + '个字符', trigger: ['blur', 'change']}]
},
validateIncome(rule, value, callback) {
const valid = typeof value === 'number' && Number.isFinite(value) && value >= 0 && /^\d+(\.\d{1,2})?$/.test(String(value))
callback(valid ? undefined : new Error('收入须为不小于零的数字,最多保留两位小数'))
},
validateMoney(rule, value, callback) {
const valid = typeof value === 'number' && Number.isFinite(value) && value > 0 && /^\d+(\.\d{1,2})?$/.test(String(value))
callback(valid ? undefined : new Error('申请金额须大于零,最多保留两位小数'))
},
// 保存
onSave() {
async onSave() {
const valid = await this.$refs.formRef.validate().catch(() => false)
if (!valid) return
this.$confirm("您确定保存吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
@@ -368,24 +396,13 @@ layout("/layouts/platform.html"){
// 提交
async onSubmit() {
this.$refs.formRef.validate().then((valid) => {
this.$refs.formRef.validate().catch(() => false).then((valid) => {
if (valid) {
this.$confirm("您确定要提交吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(() => {
const result = this.isValidIdCard(this.formData.idCard)
if (result.status === 0) {
this.$message.warning(result.msg)
return
}
const bankRes = this.validateCard(this.formData.bankCardNum)
if (bankRes.status === 0) {
this.$message.warning(bankRes.msg)
return;
}
this.$axios.post('/platform/difficultHelp/apply/submit', {
data: JSON.stringify(this.formData)
}).then(res => {
@@ -400,7 +417,7 @@ layout("/layouts/platform.html"){
},
async onFinishTask() {
this.$refs.formRef.validate().then((valid) => {
this.$refs.formRef.validate().catch(() => false).then((valid) => {
if (valid) {
this.$confirm("您确定要提交吗?", "提示", {
confirmButtonText: "确定",
@@ -408,17 +425,6 @@ layout("/layouts/platform.html"){
type: "warning"
}).then(() => {
const result = this.isValidIdCard(this.formData.idCard)
if (result.status === 0) {
this.$message.warning(result.msg)
return
}
const bankRes = this.validateCard(this.formData.bankCardNum)
if (bankRes.status === 0) {
this.$message.warning(bankRes.msg)
return;
}
this.$axios.post('/platform/difficultHelp/apply/submitAgain', {
data: JSON.stringify(this.formData),
taskId: GetQueryString("taskId")
@@ -88,7 +88,8 @@ layout("/layouts/platform.html"){
</template>
<template #edit>
<el-form :model="formData" :rules="welfareFormRules" label-width="150px" ref="form" v-loading="formLoading">
<el-form :model="formData" :rules="welfareFormRules" :validate-on-rule-change="false"
@submit.native.prevent label-width="150px" ref="form" v-loading="formLoading">
<el-form-item label="沿用往期福利" v-if="!formData.id">
<el-select clearable filterable placeholder="请选择往期福利" style="width: 100%"
v-model="reuseProjectId" @change="reuseHistoryProject">
@@ -294,10 +295,12 @@ layout("/layouts/platform.html"){
welfareFormRules() {
// 慰问金只要求项目名称和单个发放时间,其他发放形式沿用原有校验。
const requiredForOtherMode = this.formData.provideMode !== 99
// 慰问金在提交时统一校验,输入项目名称或切换表单项时不提前提示。
const requiredTrigger = requiredForOtherMode ? ["blur", "change"] : "submit"
return {
name: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
name: [{ required: true, message: "必填", trigger: requiredTrigger }],
festival: [{ required: requiredForOtherMode, message: "必选", trigger: ["blur", "change"] }],
provideTime: [{ required: true, message: "必选", trigger: ["blur", "change"] }],
provideTime: [{ required: true, message: "必选", trigger: requiredTrigger }],
choiceTime: [{ required: requiredForOtherMode, message: "必选", trigger: ["blur", "change"] }],
provideAddress: [{ required: requiredForOtherMode, message: "必选", trigger: ["blur", "change"] }],
provideMode: [{ required: true, message: "必选", trigger: ["blur", "change"] }],
@@ -410,6 +413,9 @@ layout("/layouts/platform.html"){
this.$set(this, "reuseProjectId", null)
if (this.$refs.form) this.$refs.form.resetFields()
this.$set(this, "formData", {
name: "",
choiceTime: [],
provideTime: null,
provideMode: 99,
isPushHome: false,
signMode: 4,
@@ -9,26 +9,30 @@ const PROPOSAL_INFO = {
<van-cell-group :border="false">
<van-cell title="提案编号">{{ viewData.code || '--' }}</van-cell>
<van-cell title="提案名称">{{ viewData.name || '--' }}</van-cell>
<van-cell title="立案结果" v-if="viewData.caseFilingResult">
<dict-tag :options="dict.type.PROPOSAL_CASE_FILING_RESULT"
:value="viewData.caseFilingResult"></dict-tag>
</van-cell>
<van-cell title="立案类型" v-if="viewData.caseFilingType">
<dict-tag :options="dict.type.PROPOSAL_CASE_FILING_TYPE"
:value="viewData.caseFilingType"></dict-tag>
</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="代表姓名">{{ viewData.createUserName || '--' }}</van-cell>
<van-cell title="所属教代会">{{ viewData.sessionName || viewData.fullName || '--' }}</van-cell>
<van-cell :title="viewData.committeeName ? '所属委员会' : '所属代表团'">
<van-cell title="提案人">{{ viewData.createUserName || '--' }}</van-cell>
<van-cell title="教代会届次">{{ viewData.sessionName || viewData.fullName || '--' }}</van-cell>
<van-cell :title="viewData.committeeName ? '委员会名称' : '代表团名称'">
{{ viewData.committeeName || viewData.delegationName || '--' }}
</van-cell>
<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.createTime || '--' }}</van-cell>
<van-cell title="案由" class="proposal-view-long-cell">
<!-- 立案结果和类型尚未填写时仍显示字段,并以双横线表示空值。 -->
<van-cell title="立案结果">
<dict-tag v-if="viewData.caseFilingResult" :options="dict.type.PROPOSAL_CASE_FILING_RESULT"
:value="viewData.caseFilingResult"></dict-tag>
<span v-else>--</span>
</van-cell>
<van-cell title="立案类型">
<dict-tag v-if="viewData.caseFilingType" :options="dict.type.PROPOSAL_CASE_FILING_TYPE"
:value="viewData.caseFilingType"></dict-tag>
<span v-else>--</span>
</van-cell>
<van-cell title="提案案由" class="proposal-view-long-cell">
<div class="proposal-view-rich-value" v-html="viewData.brief || '--'"></div>
</van-cell>
<van-cell title="建议措施" class="proposal-view-long-cell">
@@ -48,7 +52,7 @@ const PROPOSAL_INFO = {
:key="merge.id"
class="proposal-view-merge-cell"
:title="(index + 1) + '. ' + merge.name"
:label="'提案编号:' + merge.code + '|提案人:' + merge.createUserName + '|类别:' + merge.typeName + '|代表团:' + merge.delegationName"
:label="'提案编号:' + merge.code + '|提案人:' + merge.createUserName + '|提案类别:' + merge.typeName + '|代表团:' + merge.delegationName"
is-link
@click="openView(merge)">
</van-cell>
@@ -66,13 +70,16 @@ const PROPOSAL_INFO = {
<template v-if="opinion.commissionerLoginName">({{ opinion.commissionerLoginName }})
</template>
</van-cell>
<!-- 委员意见分别展示立案结果和类型,未填写时显示双横线。 -->
<van-cell title="立案结果">
<dict-tag :options="dict.type.PROPOSAL_CASE_FILING_RESULT"
<dict-tag v-if="opinion.caseFilingResult" :options="dict.type.PROPOSAL_CASE_FILING_RESULT"
:value="opinion.caseFilingResult"></dict-tag>
<span v-else>--</span>
</van-cell>
<van-cell title="立案类型" v-if="opinion.caseFilingType">
<dict-tag :options="dict.type.PROPOSAL_CASE_FILING_TYPE"
<van-cell title="立案类型">
<dict-tag v-if="opinion.caseFilingType" :options="dict.type.PROPOSAL_CASE_FILING_TYPE"
:value="opinion.caseFilingType"></dict-tag>
<span v-else>--</span>
</van-cell>
<van-cell title="意见" v-if="opinion.opinionText" class="proposal-view-long-cell">
<div class="proposal-view-rich-value">{{ opinion.opinionText }}</div>
@@ -158,13 +165,18 @@ const PROPOSAL_INFO = {
|| '--' }})
</van-cell>
<van-cell title="办理时间">{{ selectedTask.finishTime || '--' }}</van-cell>
<!-- 立案节点分别展示流程记录中的结果与类型,空值显示双横线。 -->
<van-cell title="立案结果">
<dict-tag :options="dict.type.PROPOSAL_CASE_FILING_RESULT"
<dict-tag v-if="selectedTask.ext.tf_caseFilingResult"
:options="dict.type.PROPOSAL_CASE_FILING_RESULT"
:value="selectedTask.ext.tf_caseFilingResult"></dict-tag>
<span v-else>--</span>
</van-cell>
<van-cell title="立案类型" v-if="selectedTask.ext.tf_caseFilingType">
<dict-tag :options="dict.type.PROPOSAL_CASE_FILING_TYPE"
<van-cell title="立案类型">
<dict-tag v-if="selectedTask.ext.tf_caseFilingType"
:options="dict.type.PROPOSAL_CASE_FILING_TYPE"
:value="selectedTask.ext.tf_caseFilingType"></dict-tag>
<span v-else>--</span>
</van-cell>
<template
v-if="['CONFIRM_FILING', 'SUGGESTION'].includes(selectedTask.ext.tf_caseFilingResult)">
@@ -187,7 +199,7 @@ const PROPOSAL_INFO = {
</template>
<template v-else>
<van-cell title="办理单位"
<van-cell title="承办单位"
v-if="['opinion_unit_reply', 'unit_reply', 'two_unit_reply', 'opinion_master_reply', 'master_reply', 'slave_reply'].includes(selectedTask.taskName)">
{{ selectedTask.ext.underTakeName || '--' }}
</van-cell>
@@ -255,7 +255,7 @@ layout("/layouts/platform_h5.html"){
<van-empty description="暂无数据" v-else></van-empty>
</van-action-sheet>-->
<van-field
<!-- <van-field
v-model="formData.certifierUserName"
name="certifierUserName"
label="证明人"
@@ -288,7 +288,7 @@ layout("/layouts/platform_h5.html"){
</template>
</div>
<van-empty description="暂无数据" v-else></van-empty>
</van-action-sheet>
</van-action-sheet> -->
<van-field
v-model="formData.typeName"
@@ -322,7 +322,7 @@ layout("/layouts/platform_h5.html"){
name="money"
label="慰问金额"
required
:rules="[{ required: true }]"
:rules="[{ required: true, message: '请填写慰问金额' }, { validator: validateMoney, message: '慰问金额须大于0且不超过10000元,最多两位小数' }]"
placeholder="选择慰问类型自动匹配金额"
clickable
type="number"
@@ -353,17 +353,19 @@ layout("/layouts/platform_h5.html"){
</van-popup>
<van-field
v-if="!['A', 'D'].includes(formData.typeCode)"
v-model="formData.bankCardNumber"
label="收款账户"
maxlength="20"
maxlength="30"
clearable
name="bankCardNumber"
placeholder="请填写收款账户"
:rules="[{ required: true }]"
placeholder="请填写银行卡号或对公账户号"
:rules="[{ required: true, message: '请填写银行卡号或对公账户号' }, { pattern: /^[0-9]{6,30}$/, message: '收款账户须为6至30位数字(银行卡号或对公账户号)' }]"
required
></van-field>
<van-field
v-if="!['A', 'D'].includes(formData.typeCode)"
v-model="formData.bankUserName"
label="户名"
placeholder="请填写户名"
@@ -373,9 +375,10 @@ layout("/layouts/platform_h5.html"){
name="bankUserName"
></van-field>
<van-field
v-if="!['A', 'D'].includes(formData.typeCode)"
v-model="formData.bankOfDeposit"
label="开户行"
maxlength="30"
maxlength="20"
type="textarea"
rows="4"
autosize
@@ -433,8 +436,8 @@ layout("/layouts/platform_h5.html"){
></van-datetime-picker>
</van-popup>
<van-field type="digit" label="当年第几次住院"
:rules="[{ required: true }]"
<van-field type="digit" label="当年第几次住院" name="thisYearHospitalizationNum"
:rules="[{ required: true }, { validator: value => /^[0-9]+$/.test(String(value)) && Number(value) <= 10000, message: '住院次数须为0至10000的整数' }]"
required
placeholder="请输入当年第几次住院"
v-model="formData.thisYearHospitalizationNum"></van-field>
@@ -443,6 +446,7 @@ layout("/layouts/platform_h5.html"){
<van-field
v-model="formData.remark"
name="remark"
maxlength="500"
label="申请事由"
type="textarea"
rows="4"
@@ -460,15 +464,18 @@ layout("/layouts/platform_h5.html"){
{{ '附件说明:' + chooseType.uploadFileDesc }}
</div>
<van-cell-group :border="false">
<van-field class="direction-column-field" name="avatar" label="">
<van-field class="direction-column-field" name="files" label="附件" required
:rules="[{ validator: () => Array.isArray(formData.files) && formData.files.length > 0 && formData.files.length <= 5, message: '请上传1至5个附件' }]">
<template #input>
<h5-file-upload
slot="input"
:value.sync="formData.files"
:upload_number="5"
upload_text="上传附件"
upload_result_category="array"
complete_result
></h5-file-upload>
<div class="condolence-card__desc">最多上传 5 个文件</div>
</template>
</van-field>
</van-cell-group>
@@ -477,7 +484,8 @@ layout("/layouts/platform_h5.html"){
<section class="form-section">
<h2 class="condolence-card__title">签字确认</h2>
<van-cell-group :border="false">
<van-field class="direction-column-field" name="signature" label="">
<van-field class="direction-column-field" name="signature" label="" :value="formData.signature"
:rules="[{ required: true, message: '请填写签字' }]">
<template #input>
<h5-signature v-model="formData.signature" slot="input"></h5-signature>
</template>
@@ -532,6 +540,9 @@ layout("/layouts/platform_h5.html"){
}
},
methods: {
validateMoney(value) {
return /^(0|[1-9][0-9]*)(\.[0-9]{1,2})?$/.test(String(value)) && Number(value) > 0 && Number(value) <= 10000
},
// 选择出院时间
showLeaveHospitalTimePickerClick() {
if (this.formData.leaveHospitalTime) {
@@ -151,14 +151,14 @@ layout("/layouts/platform_h5.html"){
<h2 class="condolence-info-card__title">
审批签字<span class="condolence-audit-card__required">*</span>
</h2>
<van-field name="tf_sign" :value="formData.tf_sign" required :rules="[{required:true,message:'请完成审批签字'}]">
<van-field name="tf_sign" :value="formData.tf_sign" :rules="[{required:true,message:'请完成审批签字'}]">
<template #input><h5-signature v-model="formData.tf_sign" :key="formData.processTaskId"></h5-signature></template>
</van-field>
</van-form>
</section>
<div class="condolence-popup-actions">
<van-button class="condolence-action-danger" type="danger" block :loading="formLoading" @click="handleTaskAction(6)">退回</van-button>
<van-button class="condolence-action-danger" type="danger" block :loading="formLoading" @click="handleTaskAction(20)">不同意</van-button>
<van-button class="condolence-action-danger" type="danger" block :loading="formLoading" @click="handleTaskAction(2)">不同意</van-button>
<van-button class="condolence-action-primary" type="primary" block :loading="formLoading" @click="handleTaskAction(1)">同意</van-button>
</div>
</div>
@@ -158,19 +158,44 @@
}
.condolence-info-long-cell {
display: flex;
align-items: flex-start;
}
.condolence-info-card .condolence-attachments .empty-container {
height: auto;
justify-content: flex-start;
color: inherit;
}
.condolence-info-card .condolence-attachments .empty-text {
font-size: inherit;
line-height: inherit;
}
.condolence-info-opinion > :first-child {
margin-top: 0;
}
.condolence-info-opinion > :last-child {
margin-bottom: 0;
}
.condolence-signature-image,
.condolence-approval-signature-image {
display: block;
}
.condolence-info-long-cell .van-cell__title {
width: auto;
width: 100%;
max-width: 100%;
height: 80px;
}
.condolence-info-long-cell .van-cell__value {
margin-top: 7px;
.condolence-signature-image .van-image__img,
.condolence-approval-signature-image {
object-fit: contain;
object-position: left center;
}
.condolence-signature-image {
display: block;
.condolence-info-opinion img {
max-width: 100%;
height: auto;
}
@@ -25,13 +25,13 @@ const condolenceInfo = {
<van-cell title="申请人单位">{{ viewData.applyUnitName || '--' }}</van-cell>
<van-cell title="申请人工会">{{ viewData.applyUnionName || '--' }}</van-cell>
<van-cell title="慰问对象">{{ viewData.helpUserName || '--' }}</van-cell>
<van-cell title="证明人">{{ viewData.certifierUserName || '--' }}</van-cell>
<!-- <van-cell title="证明人">{{ viewData.certifierUserName || '--' }}</van-cell> -->
<van-cell title="慰问类型">{{ viewData.typeName || '--' }}</van-cell>
<van-cell title="慰问金额">{{ viewData.money || '--' }}</van-cell>
<van-cell title="慰问时间">{{ viewData.occurTime || '--' }}</van-cell>
<van-cell title="收款账户">{{ viewData.bankCardNumber || '--' }}</van-cell>
<van-cell title="户名">{{ viewData.bankUserName || '--' }}</van-cell>
<van-cell class="condolence-info-long-cell" title="开户行">
<van-cell title="开户行">
{{ viewData.bankOfDeposit || '暂无' }}
</van-cell>
<template v-if="['2'].includes(viewData.typeCode)">
@@ -39,17 +39,18 @@ const condolenceInfo = {
<van-cell title="出院时间">{{ viewData.leaveHospitalTime || '--' }}</van-cell>
<van-cell title="当年第几次住院(次)">{{ viewData.thisYearHospitalizationNum || '--' }}</van-cell>
</template>
<van-cell class="condolence-info-long-cell" title="申请事由">
<van-cell title="申请事由">
{{ viewData.remark || '暂无' }}
</van-cell>
<van-cell class="condolence-info-long-cell" title="附件">
<file-preview :files="viewData.files" complete_result></file-preview>
<file-preview class="condolence-attachments" :files="viewData.files" complete_result></file-preview>
</van-cell>
<van-cell class="condolence-info-long-cell" title="签字">
<van-image
v-if="viewData.signature"
:src="viewData.signature"
class="condolence-signature-image"
fit="contain"
></van-image>
<span v-else>暂无签字</span>
</van-cell>
@@ -86,10 +87,10 @@ const condolenceInfo = {
></dict-tag>
</van-cell>
<van-cell v-if="task.taskFormData && task.taskFormData.sign" title="审批签字">
<img :src="task.taskFormData.sign" style="max-width: 100%; height: 80px; object-fit: contain">
<img :src="task.taskFormData.sign" class="condolence-approval-signature-image">
</van-cell>
<van-cell class="condolence-info-long-cell" title="办理意见">
<div v-html="task.taskFormData && task.taskFormData.tf_opinion || '暂无'"></div>
<van-cell title="办理意见">
<div class="condolence-info-opinion" v-html="task.taskFormData && task.taskFormData.tf_opinion || '暂无'"></div>
</van-cell>
</van-cell-group>
</section>
@@ -151,14 +151,14 @@ layout("/layouts/platform_h5.html"){
<h2 class="condolence-info-card__title">
审批签字<span class="condolence-audit-card__required">*</span>
</h2>
<van-field name="tf_sign" :value="formData.tf_sign" required :rules="[{required:true,message:'请完成审批签字'}]">
<van-field name="tf_sign" :value="formData.tf_sign" :rules="[{required:true,message:'请完成审批签字'}]">
<template #input><h5-signature v-model="formData.tf_sign" :key="formData.processTaskId"></h5-signature></template>
</van-field>
</van-form>
</section>
<div class="condolence-popup-actions">
<van-button class="condolence-action-danger" type="danger" block :loading="formLoading" @click="handleTaskAction(6)">退回</van-button>
<van-button class="condolence-action-danger" type="danger" block :loading="formLoading" @click="handleTaskAction(20)">不同意</van-button>
<van-button class="condolence-action-danger" type="danger" block :loading="formLoading" @click="handleTaskAction(2)">不同意</van-button>
<van-button class="condolence-action-primary" type="primary" block :loading="formLoading" @click="handleTaskAction(1)">同意</van-button>
</div>
</div>
@@ -223,6 +223,23 @@ layout("/layouts/platform_h5.html"){
},
onReady() {
this.queryCondolenceType()
this.openTodoTask()
},
openTodoTask() {
const taskId = GetQueryString("taskId")
const bizId = GetQueryString("bizId")
if (!taskId || !bizId) return
this.$axios.post("/flow/common/taskInfo", {taskId}).then((res) => {
if (res.code !== 0 || !res.data) return
const task = res.data
if (task.taskName !== "cbd41337-3c87-4959-9027-809df5c7ef5e") return
const row = {id: bizId, taskId: task.id, curTaskName: task.displayName}
if (task.taskState === 10 && task.executable && GetQueryString("tab") === "todo") {
this.onApproval(row)
} else {
this.onView(row)
}
})
},
onView(row) {
this.$set(this, "showApprovalForm", false)
@@ -151,14 +151,14 @@ layout("/layouts/platform_h5.html"){
<h2 class="condolence-info-card__title">
审批签字<span class="condolence-audit-card__required">*</span>
</h2>
<van-field name="tf_sign" :value="formData.tf_sign" required :rules="[{required:true,message:'请完成审批签字'}]">
<van-field name="tf_sign" :value="formData.tf_sign" :rules="[{required:true,message:'请完成审批签字'}]">
<template #input><h5-signature v-model="formData.tf_sign" :key="formData.processTaskId"></h5-signature></template>
</van-field>
</van-form>
</section>
<div class="condolence-popup-actions">
<van-button class="condolence-action-danger" type="danger" block :loading="formLoading" @click="handleTaskAction(6)">退回</van-button>
<van-button class="condolence-action-danger" type="danger" block :loading="formLoading" @click="handleTaskAction(20)">不同意</van-button>
<van-button class="condolence-action-danger" type="danger" block :loading="formLoading" @click="handleTaskAction(2)">不同意</van-button>
<van-button class="condolence-action-primary" type="primary" block :loading="formLoading" @click="handleTaskAction(1)">同意</van-button>
</div>
</div>
@@ -55,7 +55,7 @@ layout("/layouts/platform_h5.html"){
<div id="app">
<div class="condolence-list-page">
<van-nav-bar title="分工会主席审批" left-text="返回" left-arrow @click-left="historyBack" fixed placeholder></van-nav-bar>
<van-nav-bar title="校工会主席审批" left-text="返回" left-arrow @click-left="historyBack" fixed placeholder></van-nav-bar>
<van-sticky offset-top="46px">
<div class="condolence-list-sticky">
@@ -151,14 +151,14 @@ layout("/layouts/platform_h5.html"){
<h2 class="condolence-info-card__title">
审批签字<span class="condolence-audit-card__required">*</span>
</h2>
<van-field name="tf_sign" :value="formData.tf_sign" required :rules="[{required:true,message:'请完成审批签字'}]">
<van-field name="tf_sign" :value="formData.tf_sign" :rules="[{required:true,message:'请完成审批签字'}]">
<template #input><h5-signature v-model="formData.tf_sign" :key="formData.processTaskId"></h5-signature></template>
</van-field>
</van-form>
</section>
<div class="condolence-popup-actions">
<van-button class="condolence-action-danger" type="danger" block :loading="formLoading" @click="handleTaskAction(6)">退回</van-button>
<van-button class="condolence-action-danger" type="danger" block :loading="formLoading" @click="handleTaskAction(20)">不同意</van-button>
<van-button class="condolence-action-danger" type="danger" block :loading="formLoading" @click="handleTaskAction(2)">不同意</van-button>
<van-button class="condolence-action-primary" type="primary" block :loading="formLoading" @click="handleTaskAction(1)">同意</van-button>
</div>
</div>
@@ -9,7 +9,7 @@ layout("/layouts/platform_h5.html"){
</style>
<div id="app">
<div class="difficult-help-approval-page">
<van-nav-bar title="帮扶分工会审核" left-text="返回" left-arrow @click-left="historyBack" placeholder fixed></van-nav-bar>
<van-nav-bar title="校工会办公室主任审批" left-text="返回" left-arrow @click-left="historyBack" placeholder fixed></van-nav-bar>
<van-sticky offset-top="46px">
<div class="difficult-help-approval-sticky">
<van-search
@@ -29,7 +29,7 @@ layout("/layouts/platform_h5.html"){
:close-on-click-overlay="false"
>
<van-dropdown-item
v-if="$auth.hasRoleOr('SYSADMIN,SCHOOL_UNION_ADMIN,BRANCH_UNION_CHAIRMAN')"
v-if="filterUserReady && $auth.hasRoleOr('SYSADMIN,SCHOOL_UNION_ADMIN,BRANCH_UNION_CHAIRMAN')"
:options="unionList"
@change="unionChange"
@open="onFilterOpen"
@@ -37,7 +37,7 @@ layout("/layouts/platform_h5.html"){
v-model="pageForm.unionId">
</van-dropdown-item>
<van-dropdown-item
v-if="$auth.hasRoleOr('SYSADMIN,SCHOOL_UNION_ADMIN,BRANCH_UNION_CHAIRMAN')"
v-if="filterUserReady && $auth.hasRoleOr('SYSADMIN,SCHOOL_UNION_ADMIN,BRANCH_UNION_CHAIRMAN')"
:options="unitList"
@change="doSearch"
@open="onFilterOpen"
@@ -228,6 +228,14 @@ layout("/layouts/platform_h5.html"){
components: {
"info": INFO
},
computed: {
// 首次进入时用户接口可能尚未返回;非管理员还需所属工会,避免空对象报错或请求全部工会。
filterUserReady() {
const user = this.$store.state.user
if (!user || !Array.isArray(user.roles)) return false
return this.$auth.hasRoleOr(["SYSADMIN", "SCHOOL_UNION_ADMIN"]) || !!(user.union && user.union.id)
}
},
methods: {
historyBack,
@@ -419,13 +427,22 @@ layout("/layouts/platform_h5.html"){
},
},
created() {
this.initLevelDict()
this.initUnion().then(() => this.flushUnits())
if (!window.h5HistoryLayerManager) return
// 先订阅弹框状态,工会加载异常也不能阻断等级选择器与确认框的打开、返回。
if (window.h5HistoryLayerManager) {
this.$set(this, "historyLayerUnsubscribe", window.h5HistoryLayerManager.subscribe((stack) => {
this.$set(this, "confirmVisible", stack.includes("difficult-help-action-confirm"))
this.$set(this, "showLevelPicker", stack.includes("difficult-help-level-picker"))
}))
}
this.initLevelDict()
// 缓存已就绪时立即加载,否则等待用户接口回填;只初始化一次,避免重置已选筛选条件。
// $watch 由当前 Vue 实例管理,页面销毁时自动注销。
let filtersInitialized = false
this.$watch("filterUserReady", (ready) => {
if (!ready || filtersInitialized) return
filtersInitialized = true
this.initUnion().then(() => this.flushUnits())
}, {immediate: true})
},
beforeDestroy() {
if (typeof this.historyLayerUnsubscribe === "function") this.historyLayerUnsubscribe()
@@ -9,7 +9,7 @@ layout("/layouts/platform_h5.html"){
</style>
<div id="app">
<div class="difficult-help-approval-page">
<van-nav-bar title="帮扶校工会审核" left-text="返回" left-arrow @click-left="historyBack" placeholder fixed></van-nav-bar>
<van-nav-bar title="分管校领导审批" left-text="返回" left-arrow @click-left="historyBack" placeholder fixed></van-nav-bar>
<van-sticky offset-top="46px">
<div class="difficult-help-approval-sticky">
<van-search
@@ -9,7 +9,7 @@ layout("/layouts/platform_h5.html"){
</style>
<div id="app">
<div class="difficult-help-approval-page">
<van-nav-bar title="帮扶二级党组织审核" left-text="返回" left-arrow @click-left="historyBack" placeholder fixed></van-nav-bar>
<van-nav-bar title="校工会主席审批" left-text="返回" left-arrow @click-left="historyBack" placeholder fixed></van-nav-bar>
<van-sticky offset-top="46px">
<div class="difficult-help-approval-sticky">
<van-search
@@ -1,4 +1,8 @@
const apps = {
// 从“我的工具”进入时指定分类名称;空值表示恢复应用中心原有浏览状态。
props: {
initialCategoryName: {type: String, default: ""}
},
template: /*language=HTML*/ `
<div class="apps-container">
<van-nav-bar title="应用中心" placeholder fixed></van-nav-bar>
@@ -201,7 +205,8 @@ const apps = {
},
mounted() {
// 动态分类加载完成后再恢复分类,避免把缓存的动态分类误判为“全部应用”
this.restoreAppsState()
// 指定入口不恢复旧搜索词、滚动位置及子菜单弹框,避免目标分类被旧状态遮挡。
if (!this.initialCategoryName) this.restoreAppsState()
this.loadCategories().finally(() => {
this.restoreActiveCategory()
this.loadApps()
@@ -249,6 +254,13 @@ const apps = {
},
restoreActiveCategory() {
// 动态分类返回后按名称定位;未配置或无可用分类时明确提示,并展示全部应用。
if (this.initialCategoryName) {
const targetIndex = this.allCategories.findIndex(item => item.name === this.initialCategoryName)
this.$set(this, "activeTab", targetIndex >= 0 ? targetIndex : 0)
if (targetIndex < 0) this.$toast("未找到“" + this.initialCategoryName + "”分类")
return
}
const index = this.allCategories.findIndex(item => String(item.id) === String(this.cachedCategoryId))
this.activeTab = index >= 0 ? index : 0
},
@@ -73,7 +73,7 @@ layout("/layouts/platform_h5.html"){
<todo v-if="homeTabbarActive === 'todo'" @todo-count-change="updateTodoCount"></todo>
<work v-if="homeTabbarActive === 'work'"></work>
<mine v-if="homeTabbarActive === 'mine'" @switch-tab="switchHomeTab"></mine>
<apps v-if="homeTabbarActive === 'apps'"></apps>
<apps v-if="homeTabbarActive === 'apps'" :initial-category-name="appsCategoryName"></apps>
<msg v-if="homeTabbarActive === 'msg'" @unread-count-change="updateUnreadMessageCount"></msg>
</div>
@@ -111,19 +111,25 @@ layout("/layouts/platform_h5.html"){
data() {
return {
homeTabbarActive: "home",
appsCategoryName: "",
todoCount: 0,
unreadMessageCount: 0
}
},
methods: {
homeTabbarChange(val) {
// 普通页签切换沿用应用页缓存,不保留工具入口指定的分类。
this.$set(this, "appsCategoryName", "")
this.$store.commit("setActiveTarBar", val)
},
switchHomeTab(val) {
this.homeTabbarActive = val
// categoryName 来自“我的工具”,先传分类再挂载应用页,避免读取上一次的分类缓存。
switchHomeTab(val, categoryName = "") {
this.$set(this, "appsCategoryName", val === "apps" ? categoryName : "")
this.$set(this, "homeTabbarActive", val)
this.$store.commit("setActiveTarBar", val)
},
moduleClick(val) {
this.$set(this, "appsCategoryName", "")
this.homeTabbarActive = "apps"
this.$store.commit("setActiveTarBar", "apps")
},
@@ -74,22 +74,23 @@ const mine = {
<van-icon name="edit"></van-icon>
<span>我的签名</span>
</button>
<button type="button" class="mine-grid-item" @click="switchTab('apps')">
<button type="button" class="mine-grid-item" @click="switchTab('apps', '我的收藏')">
<van-icon name="star-o"></van-icon>
<span>我的收藏</span>
</button>
<button type="button" class="mine-grid-item" @click="switchTab('apps')">
<button type="button" class="mine-grid-item" @click="switchTab('apps', '职工福利')">
<van-icon name="gift-o"></van-icon>
<span>福利中心</span>
</button>
<button type="button" class="mine-grid-item" @click="switchTab('apps')">
<button type="button" class="mine-grid-item" @click="switchTab('apps', '活动管理')">
<van-icon name="calendar-o"></van-icon>
<span>活动报名</span>
</button>
<button type="button" class="mine-grid-item" @click="switchTab('apps')">
<button type="button" class="mine-grid-item" @click="$pjaxReplace('/platform/h5/qsv')">
<van-icon name="completed-o"></van-icon>
<span>问卷调查</span>
</button>
<!-- 暂不开放设置中心和帮助中心,保留入口模板便于恢复。
<button type="button" class="mine-grid-item" @click="switchTab('apps')">
<van-icon name="setting-o"></van-icon>
<span>设置中心</span>
@@ -98,6 +99,7 @@ const mine = {
<van-icon name="question-o"></van-icon>
<span>帮助中心</span>
</button>
-->
</div>
</section>
@@ -150,9 +152,10 @@ const mine = {
}
},
methods: {
switchTab(tab) {
// tab 为底部页签,categoryName 为可选应用分类名称;通过事件交给首页切换并定位分类。
switchTab(tab, categoryName = "") {
this.$store.commit("setActiveTarBar", tab)
this.$emit("switch-tab", tab)
this.$emit("switch-tab", tab, categoryName)
},
logout() {
this.$dialog
@@ -281,11 +281,13 @@ const todo = {
// 处理任务
onView(task) {
const {taskId, taskKey, instanceId, businessNo, formKey, h5FormKey} = task;
if (!h5FormKey) {
const mobileFormKey = h5FormKey || (taskKey === "cbd41337-3c87-4959-9027-809df5c7ef5e"
? "/platform/condolence/fourthApproval/h5" : "");
if (!mobileFormKey) {
this.$toast("当前流程暂不支持手机端审核,请前往PC端页面审核!");
return;
}
this.$pjaxReplace(h5FormKey + "?taskId=" + taskId + "&bizId=" + businessNo + "&taskKey=" + taskKey + "&tab=" + this.activeTab)
this.$pjaxReplace(mobileFormKey + "?taskId=" + taskId + "&bizId=" + businessNo + "&taskKey=" + taskKey + "&tab=" + this.activeTab)
},
// 根据当前列表类型统一展示流程状态,发起列表保留流程实例的实际状态。