86 changed files with 3547 additions and 674 deletions
@@ -1,10 +1,16 @@
package com.budwk.app.base.utils; package com.budwk.app.base.utils;
import cn.hutool.core.io.FileUtil; import cn.hutool.core.io.FileUtil;
import com.aspose.cells.FontConfigs;
import com.aspose.cells.PdfSaveOptions; import com.aspose.cells.PdfSaveOptions;
import com.aspose.cells.Workbook; import com.aspose.cells.Workbook;
import com.aspose.slides.FontsLoader;
import com.aspose.slides.LoadOptions;
import com.aspose.slides.Presentation; import com.aspose.slides.Presentation;
import com.aspose.words.Document; import com.aspose.words.Document;
import com.aspose.words.FontSettings;
import com.aspose.words.FontSourceBase;
import com.aspose.words.MemoryFontSource;
import com.aspose.words.SaveFormat; import com.aspose.words.SaveFormat;
import com.budwk.app.base.exception.BaseException; import com.budwk.app.base.exception.BaseException;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -12,6 +18,8 @@ import lombok.extern.slf4j.Slf4j;
import java.io.File; import java.io.File;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
/** /**
* 文档转换 * 文档转换
@@ -19,6 +27,81 @@ import java.io.IOException;
@Slf4j @Slf4j
public class OfficePlusUtil { public class OfficePlusUtil {
private static final String DEFAULT_FONT_NAME = "Noto Sans SC";
private static final String DEFAULT_FONT_RESOURCE = "/fonts/NotoSansSC-VF.ttf";
private static final Object FONT_CONFIG_LOCK = new Object();
private static volatile boolean fontConfigured;
/**
* 注册系统内置字体,并将其作为 Office 文档缺失字体或缺失字形时的默认回退字体。
* 原文档字体在服务器可用时仍优先使用,只有找不到原字体时才使用内置字体。
*/
private static void configureDefaultFont() {
if (fontConfigured) {
return;
}
synchronized (FONT_CONFIG_LOCK) {
if (fontConfigured) {
return;
}
byte[] fontData = loadDefaultFontData();
configureWordFont(fontData);
configureExcelFont(fontData);
FontsLoader.loadExternalFont(fontData);
fontConfigured = true;
}
}
/**
* 从 classpath 读取随应用打包的字体,避免依赖线上服务器安装字体。
*
* @return 默认字体的二进制数据
*/
private static byte[] loadDefaultFontData() {
try (InputStream inputStream = OfficePlusUtil.class.getResourceAsStream(DEFAULT_FONT_RESOURCE)) {
if (inputStream == null) {
throw new BaseException("系统内置默认字体不存在");
}
return inputStream.readAllBytes();
} catch (IOException e) {
log.error("读取系统内置默认字体失败", e);
throw new BaseException("读取系统内置默认字体失败");
}
}
/**
* 为 Word 转换保留已有字体源并追加内置字体,同时配置缺失字体和缺失字形回退。
*
* @param fontData 默认字体的二进制数据
*/
private static void configureWordFont(byte[] fontData) {
FontSettings fontSettings = FontSettings.getDefaultInstance();
FontSourceBase[] currentSources = fontSettings.getFontsSources();
FontSourceBase[] fontSources = Arrays.copyOf(currentSources, currentSources.length + 1);
fontSources[currentSources.length] = new MemoryFontSource(fontData);
fontSettings.setFontsSources(fontSources);
fontSettings.getSubstitutionSettings().getDefaultFontSubstitution().setDefaultFontName(DEFAULT_FONT_NAME);
try {
fontSettings.getFallbackSettings().loadNotoFallbackSettings();
} catch (Exception e) {
log.error("配置 Word 默认字体回退规则失败", e);
throw new BaseException("配置 Word 默认字体失败");
}
}
/**
* 为 Excel 转换追加内置字体,并将其设置为找不到原字体时的默认字体。
*
* @param fontData 默认字体的二进制数据
*/
private static void configureExcelFont(byte[] fontData) {
com.aspose.cells.FontSourceBase[] currentSources = FontConfigs.getFontSources();
com.aspose.cells.FontSourceBase[] fontSources = Arrays.copyOf(currentSources, currentSources.length + 1);
fontSources[currentSources.length] = new com.aspose.cells.MemoryFontSource(fontData);
FontConfigs.setFontSources(fontSources);
FontConfigs.setDefaultFontName(DEFAULT_FONT_NAME);
}
/** /**
* 转换 * 转换
* *
@@ -47,7 +130,11 @@ public class OfficePlusUtil {
public static void presentationConvertPdf(String sourcePath, String targetPath) { public static void presentationConvertPdf(String sourcePath, String targetPath) {
Presentation presentation = null; Presentation presentation = null;
try { try {
presentation = new Presentation(sourcePath); configureDefaultFont();
LoadOptions loadOptions = new LoadOptions();
loadOptions.setDefaultRegularFont(DEFAULT_FONT_NAME);
loadOptions.setDefaultAsianFont(DEFAULT_FONT_NAME);
presentation = new Presentation(sourcePath, loadOptions);
presentation.save(targetPath, com.aspose.slides.SaveFormat.Pdf); presentation.save(targetPath, com.aspose.slides.SaveFormat.Pdf);
} catch (Exception e) { } catch (Exception e) {
log.info("转换pdf报错", e); log.info("转换pdf报错", e);
@@ -68,10 +155,12 @@ public class OfficePlusUtil {
public static void excelConvertPdf(String sourcePath, String targetPath) { public static void excelConvertPdf(String sourcePath, String targetPath) {
FileOutputStream fileOS = null; FileOutputStream fileOS = null;
try { try {
configureDefaultFont();
Workbook wb = new Workbook(sourcePath); Workbook wb = new Workbook(sourcePath);
fileOS = new FileOutputStream(targetPath); fileOS = new FileOutputStream(targetPath);
PdfSaveOptions pdfSaveOptions = new PdfSaveOptions(); PdfSaveOptions pdfSaveOptions = new PdfSaveOptions();
pdfSaveOptions.setOnePagePerSheet(true); pdfSaveOptions.setOnePagePerSheet(true);
pdfSaveOptions.setDefaultFont(DEFAULT_FONT_NAME);
// for (int i = 0; i < 3; i++) { // for (int i = 0; i < 3; i++) {
// wb.getWorksheets().get(i).getHorizontalPageBreaks().clear(); // wb.getWorksheets().get(i).getHorizontalPageBreaks().clear();
// wb.getWorksheets().get(i).getVerticalPageBreaks().clear(); // wb.getWorksheets().get(i).getVerticalPageBreaks().clear();
@@ -105,6 +194,7 @@ public class OfficePlusUtil {
public static void wordConvertPdf(String sourcePath, String targetPath) { public static void wordConvertPdf(String sourcePath, String targetPath) {
FileOutputStream os = null; FileOutputStream os = null;
try { try {
configureDefaultFont();
File file = new File(targetPath); File file = new File(targetPath);
os = new FileOutputStream(file); os = new FileOutputStream(file);
Document doc = new Document(sourcePath); Document doc = new Document(sourcePath);
@@ -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_file;
import com.budwk.app.sys.models.Sys_office_template; import com.budwk.app.sys.models.Sys_office_template;
import com.budwk.app.sys.utils.SysFileMinIoUtil; 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.ByteArrayPictureRenderData;
import com.deepoove.poi.data.PictureRenderData; import com.deepoove.poi.data.PictureRenderData;
import com.deepoove.poi.data.PictureType; import com.deepoove.poi.data.PictureType;
@@ -18,6 +20,12 @@ import org.nutz.ioc.loader.annotation.IocBean;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.InputStream; 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.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@@ -66,6 +74,88 @@ public class SysOfficeTemplateUtil {
return new ByteArrayInputStream(fileBytes); 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的图片对象 * 签字url转换为poi的图片对象
* *
@@ -77,13 +167,7 @@ public class SysOfficeTemplateUtil {
public PictureRenderData createPictureRenderData(int width, int height, String url) { public PictureRenderData createPictureRenderData(int width, int height, String url) {
try { try {
byte[] bytes = getFileBytesByUrl(url); byte[] bytes = getFileBytesByUrl(url);
// if (StrUtil.isBlank(url)) { log.info("[Office图片读取] 创建图片渲染数据,referenceLength={}, byteCount={}, width={}, height={}", StrUtil.nullToEmpty(url).length(), bytes == null ? null : bytes.length, width, height);
// 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());
ByteArrayPictureRenderData pictureRenderData = new ByteArrayPictureRenderData(bytes, PictureType.PNG); ByteArrayPictureRenderData pictureRenderData = new ByteArrayPictureRenderData(bytes, PictureType.PNG);
PictureStyle pictureStyle = new PictureStyle(); PictureStyle pictureStyle = new PictureStyle();
pictureStyle.setWidth(width); pictureStyle.setWidth(width);
@@ -91,7 +175,7 @@ public class SysOfficeTemplateUtil {
pictureRenderData.setPictureStyle(pictureStyle); pictureRenderData.setPictureStyle(pictureStyle);
return pictureRenderData; return pictureRenderData;
} catch (Exception e) { } catch (Exception e) {
log.error("文件转换PictureRenderData错误,文件路径:{},{}", url, e.getMessage()); log.error("[Office图片读取] 创建图片渲染数据失败,referenceLength={}", StrUtil.nullToEmpty(url).length(), e);
return null; return null;
} }
} }
@@ -140,13 +224,20 @@ public class SysOfficeTemplateUtil {
public byte[] getFileBytesByUrl(String url) { public byte[] getFileBytesByUrl(String url) {
try { try {
if (StrUtil.isBlank(url)) { if (StrUtil.isBlank(url)) {
log.warn("[Office图片读取] 图片引用为空");
return null; return null;
} }
int startIndex = url.indexOf("id=") + "id=".length(); int startIndex = url.indexOf("id=") + "id=".length();
String id = url.substring(startIndex); 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); 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) { } catch (Exception e) {
log.error("[Office图片读取] 读取图片失败,referenceLength={}", StrUtil.nullToEmpty(url).length(), e);
return null; 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) .and(Sys_user_role::getUnitId, "=", unitId)
); );
if (Lang.isEmpty(roles)) { if (Lang.isEmpty(roles)) {
throw new BaseException("受助人所在单位未设置单位分管校领导,请联系校工会进行设置。"); throw new BaseException("受助人/申请人所在单位未设置单位分管校领导,请联系校工会进行设置。");
} }
return roles.stream().map(Sys_user_role::getUserId).distinct().toList(); return roles.stream().map(Sys_user_role::getUserId).distinct().toList();
} }
@@ -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.engine.event.ProcessEventListener;
import com.budwk.app.flow.entity.ProcessTask; import com.budwk.app.flow.entity.ProcessTask;
import com.budwk.app.flow.enums.ProcessEventTypeEnum; import com.budwk.app.flow.enums.ProcessEventTypeEnum;
import com.budwk.app.flow.service.SchoolOaTodoService;
import org.nutz.dao.Dao; import org.nutz.dao.Dao;
import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
@@ -17,11 +18,16 @@ public class ProcessTaskEndEventListener implements ProcessEventListener {
@Inject @Inject
private Dao dao; private Dao dao;
@Inject
private SchoolOaTodoService schoolOaTodoService;
@Override @Override
public void onEvent(ProcessEvent event) { public void onEvent(ProcessEvent event) {
if (event.getEventType() == ProcessEventTypeEnum.PROCESS_TASK_END) { if (event.getEventType() == ProcessEventTypeEnum.PROCESS_TASK_END) {
Long sourceId = event.getSourceId(); Long sourceId = event.getSourceId();
ProcessTask task = dao.fetch(ProcessTask.class, sourceId); ProcessTask task = dao.fetch(ProcessTask.class, sourceId);
// service 区分正常完成与废弃,只有正常完成才发送通达 status=2。
schoolOaTodoService.completeTodo(task);
System.out.println("流程任务完成事件:" + task.getDisplayName()); 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.ProcessInstance;
import com.budwk.app.flow.entity.ProcessTask; import com.budwk.app.flow.entity.ProcessTask;
import com.budwk.app.flow.enums.ProcessEventTypeEnum; import com.budwk.app.flow.enums.ProcessEventTypeEnum;
import com.budwk.app.flow.service.SchoolOaTodoService;
import org.nutz.dao.Dao; import org.nutz.dao.Dao;
import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
@@ -22,11 +23,16 @@ public class ProcessTaskRevokeEventListener implements ProcessEventListener {
@Inject @Inject
private Dao dao; private Dao dao;
@Inject
private SchoolOaTodoService schoolOaTodoService;
@Override @Override
public void onEvent(ProcessEvent event) { public void onEvent(ProcessEvent event) {
if(event.getEventType()== ProcessEventTypeEnum.PROCESS_TASK_REVOKE){ if(event.getEventType()== ProcessEventTypeEnum.PROCESS_TASK_REVOKE){
Long taskId = event.getSourceId(); Long taskId = event.getSourceId();
ProcessTask task = dao.fetch(ProcessTask.class, taskId); ProcessTask task = dao.fetch(ProcessTask.class, taskId);
// 公开通达协议未定义取消,集中记录未同步事项,不把撤回冒充正常已办。
schoolOaTodoService.cancelTodo(task);
String taskDisplayName = task.getDisplayName(); 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.entity.ProcessTaskActor;
import com.budwk.app.flow.enums.ProcessEventTypeEnum; import com.budwk.app.flow.enums.ProcessEventTypeEnum;
import com.budwk.app.zhgh.dayofficework.message.service.GlobalMessageSendService; import com.budwk.app.zhgh.dayofficework.message.service.GlobalMessageSendService;
import com.budwk.app.zhgh.staffbenefit.difficulthelp.service.DifficultHelpCommonService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.nutz.dao.Cnd; import org.nutz.dao.Cnd;
import org.nutz.dao.Dao; import org.nutz.dao.Dao;
@@ -30,13 +31,22 @@ public class ProcessTaskStartEventListener implements ProcessEventListener {
private Dao dao; private Dao dao;
@Inject @Inject
private GlobalMessageSendService globalMessageSendService; private GlobalMessageSendService globalMessageSendService;
@Inject
private DifficultHelpCommonService difficultHelpCommonService;
@Override @Override
public void onEvent(ProcessEvent event) { public void onEvent(ProcessEvent event) {
if (event.getEventType() == ProcessEventTypeEnum.PROCESS_TASK_START) { if (event.getEventType() == ProcessEventTypeEnum.PROCESS_TASK_START) {
Long sourceId = event.getSourceId(); Long sourceId = event.getSourceId();
ProcessTask task = dao.fetch(ProcessTask.class, sourceId); ProcessTask task = dao.fetch(ProcessTask.class, sourceId);
if (task == null) return;
sendMessage(task); 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.ProcessDefineService;
import com.budwk.app.flow.service.ProcessInstanceService; import com.budwk.app.flow.service.ProcessInstanceService;
import com.budwk.app.flow.service.ProcessTaskService; import com.budwk.app.flow.service.ProcessTaskService;
import com.budwk.app.flow.service.SchoolOaTodoService;
import com.budwk.app.flow.vo.ProcessTaskVO; import com.budwk.app.flow.vo.ProcessTaskVO;
import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.aop.interceptor.ioc.TransAop;
@@ -56,6 +57,9 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
@Inject @Inject
private ProcessDefineService processDefineService; private ProcessDefineService processDefineService;
@Inject
private SchoolOaTodoService schoolOaTodoService;
@Override @Override
public boolean save(ProcessTask param) { public boolean save(ProcessTask param) {
param.setId(null); param.setId(null);
@@ -213,6 +217,8 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
System.out.println("创建任务:" + processTask.getTaskName() + "," + processTask.getDisplayName()); System.out.println("创建任务:" + processTask.getTaskName() + "," + processTask.getDisplayName());
processTaskList.add(processTask); processTaskList.add(processTask);
addTaskActor(processTask.getId(), getTaskActors(taskModel, execution)); addTaskActor(processTask.getId(), getTaskActors(taskModel, execution));
// 参与人落库后统一推送通达待办,开始监听器不再重复推送。
schoolOaTodoService.createTodo(processTask);
return processTaskList; return processTaskList;
} }
@@ -284,6 +290,8 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
clone.setUpdatedBy(null); clone.setUpdatedBy(null);
dao().insert(clone); dao().insert(clone);
addTaskActor(clone.getId(), CollectionUtil.newArrayList(actor)); addTaskActor(clone.getId(), CollectionUtil.newArrayList(actor));
// 并行加签绕过普通节点创建入口,需要为新增的个人任务同步待办。
schoolOaTodoService.createTodo(clone);
}); });
} }
@@ -318,6 +326,7 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public ProcessTask rejectTask(ProcessModel model, ProcessTask currentTask) { public ProcessTask rejectTask(ProcessModel model, ProcessTask currentTask) {
Long taskParentId = currentTask.getTaskParentId(); Long taskParentId = currentTask.getTaskParentId();
if (ObjectUtil.isEmpty(taskParentId) || taskParentId.equals(0L)) { if (ObjectUtil.isEmpty(taskParentId) || taskParentId.equals(0L)) {
@@ -351,6 +360,8 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
} }
saveProcessTask(task); saveProcessTask(task);
addTaskActor(task.getId(), CollectionUtil.newArrayList(task.getOperator())); addTaskActor(task.getId(), CollectionUtil.newArrayList(task.getOperator()));
// 驳回创建了新的任务 ID,按新任务推送,避免复用历史待办。
schoolOaTodoService.createTodo(task);
return task; return task;
} }
@@ -424,6 +435,8 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
System.out.println("创建会签任务:" + processTask.getTaskName() + "," + processTask.getDisplayName()); System.out.println("创建会签任务:" + processTask.getTaskName() + "," + processTask.getDisplayName());
processTaskList.add(processTask); processTaskList.add(processTask);
addTaskActor(processTask.getId(), CollectionUtil.newArrayList(taskActor)); addTaskActor(processTask.getId(), CollectionUtil.newArrayList(taskActor));
// 串行和并行会签均按实际生成的个人任务发送待办。
schoolOaTodoService.createTodo(processTask);
}); });
ProcessInstanceService processInstanceService = ServiceContext.find(ProcessInstanceService.class); ProcessInstanceService processInstanceService = ServiceContext.find(ProcessInstanceService.class);
// 更新会签变量到流程实例参数中 // 更新会签变量到流程实例参数中
@@ -496,9 +509,12 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
// 创建转办任务参与者 // 创建转办任务参与者
addTaskActor(newTask.getId(), List.of(transferUserId)); 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 -> { processTaskList.forEach(processTask -> {
ProcessPublisher.notify(ProcessEvent.builder().eventType(ProcessEventTypeEnum.PROCESS_TASK_START).sourceId(processTask.getId()).build()); 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.constant.RedisConstant;
import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.base.sms.SmsService; 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.sms.model.MsgPlatformWechatRequest;
import com.budwk.app.base.utils.ManyAddOrRenewUtil; import com.budwk.app.base.utils.ManyAddOrRenewUtil;
import com.budwk.app.sys.models.Sys_msg; import com.budwk.app.sys.models.Sys_msg;
@@ -62,6 +63,8 @@ public class SysMsgServiceImpl extends BaseServiceImpl<Sys_msg> implements SysMs
private ManyAddOrRenewUtil manyAddOrRenewUtil; private ManyAddOrRenewUtil manyAddOrRenewUtil;
@Inject @Inject
private SmsService smsService; private SmsService smsService;
@Inject
private WxWorkMessageService wxWorkMessageService;
private static NutMap typeMap = NutMap.NEW().addv("system", "系统消息").addv("user", "用户消息"); 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(() -> { ThreadUtil.execute(() -> {
for (String loginName : loginNames) { for (String loginName : loginNames) {
try {
smsService.sendSmsByAccount(loginName, sysMsg.getNote()); smsService.sendSmsByAccount(loginName, sysMsg.getNote());
smsService.sendWechatTextByAccountOrMobile(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; return sysMsg;
} }
@@ -292,11 +310,13 @@ public class SysMsgServiceImpl extends BaseServiceImpl<Sys_msg> implements SysMs
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void sendMsg(String loginname, String title, String body, String sender) { public void sendMsg(String loginname, String title, String body, String sender) {
sendMsg(List.of(loginname), title, body, SecurityUtil.getUserId()); sendMsg(List.of(loginname), title, body, SecurityUtil.getUserId());
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void sendMsg(List<String> loginNames, String title, String body, String sender) { public void sendMsg(List<String> loginNames, String title, String body, String sender) {
Sys_msg sysMsg = new Sys_msg(); Sys_msg sysMsg = new Sys_msg();
sysMsg.setTitle(title); sysMsg.setTitle(title);
@@ -309,6 +329,7 @@ public class SysMsgServiceImpl extends BaseServiceImpl<Sys_msg> implements SysMs
} }
@Override @Override
@Aop(TransAop.READ_COMMITTED)
public void sendMsgInSys(List<String> loginNames, String title, String body, String sender) { public void sendMsgInSys(List<String> loginNames, String title, String body, String sender) {
Sys_msg sysMsg = new Sys_msg(); Sys_msg sysMsg = new Sys_msg();
sysMsg.setTitle(title); sysMsg.setTitle(title);
@@ -189,25 +189,16 @@ public class WkFailProcessor extends ViewProcessor {
if (Globals.sso) { if (Globals.sso) {
//1.判断是否为企业微信浏览器并且对接了企业微信 //1.判断是否为企业微信浏览器并且对接了企业微信
if (browserPlatform == BrowserPlatform.QI_YE_WEI_XIN && propertiesProxy.getBoolean("wxwork.enable", false)) { if (browserPlatform == BrowserPlatform.QI_YE_WEI_XIN && propertiesProxy.getBoolean("wxwork.enable", false)) {
//进行企业微信授权 // 专用企业微信认证配置缺失时整组回退到已接通的 CAS,保留卡片原始页面。
// String token = getWxWorkToken(); try {
// if (StrUtil.isNotBlank(token)) { String authUrl = buildEnterpriseCasUrl(redirect);
// //跳转到企业微信授权地址
// 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); log.info("auth:" + authUrl);
new ServerRedirectView(authUrl).render(request, response, null); 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)) { } 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) 地址");
}
}
/** /**
* 判断浏览器平台 * 判断浏览器平台
* *
@@ -4,12 +4,14 @@ package com.budwk.app.zhgh.activity.sports.service.impl;
import cn.afterturn.easypoi.excel.ExcelExportUtil; import cn.afterturn.easypoi.excel.ExcelExportUtil;
import cn.afterturn.easypoi.excel.entity.ExportParams; import cn.afterturn.easypoi.excel.entity.ExportParams;
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType; import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
import com.budwk.app.base.exception.BaseException;
import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.base.utils.CommonDownloadUtil; import com.budwk.app.base.utils.CommonDownloadUtil;
import com.budwk.app.base.utils.DateUtil; import com.budwk.app.base.utils.DateUtil;
import com.budwk.app.sys.models.Sys_home_activity; import com.budwk.app.sys.models.Sys_home_activity;
import com.budwk.app.sys.models.Sys_home_template; import com.budwk.app.sys.models.Sys_home_template;
import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.activity.basic.models.ActivityEvent;
import com.budwk.app.zhgh.activity.basic.models.ActivityBasicUnit; import com.budwk.app.zhgh.activity.basic.models.ActivityBasicUnit;
import com.budwk.app.zhgh.activity.basic.models.ActivityBasicUnion; import com.budwk.app.zhgh.activity.basic.models.ActivityBasicUnion;
import com.budwk.app.zhgh.activity.culture.models.ActivityTissue; import com.budwk.app.zhgh.activity.culture.models.ActivityTissue;
@@ -28,6 +30,7 @@ import org.nutz.dao.entity.Record;
import org.nutz.dao.sql.Sql; import org.nutz.dao.sql.Sql;
import org.nutz.ioc.aop.Aop; import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json;
import org.nutz.lang.Lang; import org.nutz.lang.Lang;
import org.nutz.lang.Strings; import org.nutz.lang.Strings;
@@ -55,6 +58,7 @@ public class ActivitySportsServiceImpl extends BaseServiceImpl<ActivitySchool> i
@Override @Override
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
public String doAdd(ActivitySchool activitySchool, ActivitySchoolEvent[] events) { public String doAdd(ActivitySchool activitySchool, ActivitySchoolEvent[] events) {
events = validateAndApplySingleEventLimits(events);
ActivityBasicUnit activityBasicUnit = dao().fetch(ActivityBasicUnit.class, Cnd.where("id", "=", SecurityUtil.getUserId())); ActivityBasicUnit activityBasicUnit = dao().fetch(ActivityBasicUnit.class, Cnd.where("id", "=", SecurityUtil.getUserId()));
if (activitySchool.getActivityLevel() == 40002) { if (activitySchool.getActivityLevel() == 40002) {
@@ -101,6 +105,7 @@ public class ActivitySportsServiceImpl extends BaseServiceImpl<ActivitySchool> i
@Override @Override
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
public void doEdit(ActivitySchool activitySchool, ActivitySchoolEvent[] events) { public void doEdit(ActivitySchool activitySchool, ActivitySchoolEvent[] events) {
events = validateAndApplySingleEventLimits(events);
ActivitySchool school = dao().fetch(ActivitySchool.class, activitySchool.getId()); ActivitySchool school = dao().fetch(ActivitySchool.class, activitySchool.getId());
if (school.getActivityLevel() == 40002) { if (school.getActivityLevel() == 40002) {
school.setBelongUnionId(SecurityUtil.getUnionId()); school.setBelongUnionId(SecurityUtil.getUnionId());
@@ -161,6 +166,56 @@ public class ActivitySportsServiceImpl extends BaseServiceImpl<ActivitySchool> i
dao().insertOrUpdate(sysHomeActivity); dao().insertOrUpdate(sysHomeActivity);
} }
/**
* 校验单项人数限制,并在具体单项未设置限制时应用“所有单项”的默认值。
*
* @param events 页面提交的项目配置,允许包含 eventId 为 allItems 的默认配置
* @return 已移除 allItems 临时配置、可直接持久化的项目配置数组
*/
private ActivitySchoolEvent[] validateAndApplySingleEventLimits(ActivitySchoolEvent[] events) {
if (events == null || events.length == 0) {
return new ActivitySchoolEvent[0];
}
ActivitySchoolEvent allItems = Arrays.stream(events)
.filter(event -> event != null && "allItems".equals(event.getEventId()))
.findFirst()
.orElse(null);
List<ActivitySchoolEvent> result = new ArrayList<>();
for (ActivitySchoolEvent event : events) {
if (event == null || "allItems".equals(event.getEventId())) {
continue;
}
ActivityEvent activityEvent = dao().fetch(ActivityEvent.class, event.getEventId());
if (activityEvent != null && "1".equals(activityEvent.getProjectType())) {
applySingleEventDefaultLimit(event, allItems);
}
result.add(event);
}
return result.toArray(new ActivitySchoolEvent[0]);
}
/**
* 单项的两个限制必须同时设置;均未设置或均为0时继承“所有单项”的对应值。
*/
private void applySingleEventDefaultLimit(ActivitySchoolEvent event, ActivitySchoolEvent allItems) {
Integer athletesMaxNum = event.getAthletesMaxNum();
Integer restrictRegNumber = event.getRestrictRegNumber();
if ((athletesMaxNum == null) != (restrictRegNumber == null)) {
throw new BaseException("每队(项)运动员最多人数和每人限报项目必须同时设置");
}
boolean useDefault = athletesMaxNum == null || (athletesMaxNum == 0 && restrictRegNumber == 0);
if (!useDefault) {
return;
}
if (allItems == null || allItems.getAthletesMaxNum() == null || allItems.getRestrictRegNumber() == null) {
throw new BaseException("请先设置所有单项的运动员最多人数和每人限报项目");
}
event.setAthletesMaxNum(allItems.getAthletesMaxNum());
event.setRestrictRegNumber(allItems.getRestrictRegNumber());
}
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
private void addTeam(String schoolId, ActivitySchoolEvent[] events) { private void addTeam(String schoolId, ActivitySchoolEvent[] events) {
if (events.length > 0) { if (events.length > 0) {
@@ -271,33 +326,19 @@ public class ActivitySportsServiceImpl extends BaseServiceImpl<ActivitySchool> i
WHERE WHERE
app.activityId = @id app.activityId = @id
and app.status = 2 and app.status = 2
$cnd and (@filterUnion = 0 or app.activityUnionId = @unionId)
ORDER BY ORDER BY
ev.allName DESC, ev.allName DESC,
u.unionname DESC, u.unionname DESC,
app.`team` asc app.`team` asc
""").setParam("id", id) """).setParam("id", id);
.setVar("cnd", ""); // 参数必须声明在原始SQL中;setVar追加的字符串不会再次解析其中的@参数。
if (Strings.isNotBlank(unionId)) { // 未选择分工会时导出整个活动,选择时按报名记录中的活动分工会过滤。
sql.setVar("cnd", "and u.unionid = @unionId"); sql.setParam("filterUnion", Strings.isNotBlank(unionId) ? 1 : 0);
sql.setParam("unionId", unionId); sql.setParam("unionId", Strings.isNotBlank(unionId) ? unionId : "");
}
List<Record> list = list(sql); List<Record> list = list(sql);
String activityName;
if (Lang.isNotEmpty(list)) {
activityName = list.get(0).getString("activityName");
} else {
activityName = "暂无人员";
}
List<ActivitySchoolApply> excels = new ArrayList<>(); List<ActivitySchoolApply> excels = new ArrayList<>();
list.forEach(record -> excels.add(toActivitySchoolApplyExport(record)));
list.forEach(z -> {
ActivitySchoolApply schoolApply = z.toPojo(ActivitySchoolApply.class);
String s = schoolApply.getIdentity().stream().map(personType::getName).toList().toString();
schoolApply.setSf(s);
excels.add(schoolApply);
});
ExportParams exportParams = new ExportParams(); ExportParams exportParams = new ExportParams();
exportParams.setType(ExcelType.XSSF); exportParams.setType(ExcelType.XSSF);
@@ -305,10 +346,42 @@ public class ActivitySportsServiceImpl extends BaseServiceImpl<ActivitySchool> i
CommonDownloadUtil.download(getUnionApplyExportFileName(unionId), workbook, response); CommonDownloadUtil.download(getUnionApplyExportFileName(unionId), workbook, response);
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
throw new BaseException("导出报名信息失败:" + e.getMessage());
} }
} }
/**
* 将原生SQL查询结果转换为报名信息导出对象。
* identity 是数据库JSON文本,需要显式转换为身份编码集合,避免 Record.toPojo 自动转换失败导致整表无数据。
*
* @param record 导出SQL返回的一行报名数据
* @return EasyPoi 使用的 ActivitySchoolApply 导出对象
*/
private ActivitySchoolApply toActivitySchoolApplyExport(Record record) {
ActivitySchoolApply schoolApply = new ActivitySchoolApply();
schoolApply.setUnionname(record.getString("unionname"));
schoolApply.setTeam(record.getString("team"));
schoolApply.setAllName(record.getString("allName"));
schoolApply.setLoginname(record.getString("loginname"));
schoolApply.setUsername(record.getString("username"));
schoolApply.setMobile(record.getString("mobile"));
schoolApply.setIdCard(record.getString("idCard"));
schoolApply.setProfessionalLevel(record.getString("professionalLevel"));
schoolApply.setSex(record.getString("sex"));
String identityJson = record.getString("identity");
List<String> identities = Strings.isBlank(identityJson)
? new ArrayList<>()
: Json.fromJsonAsList(String.class, identityJson);
String identityName = identities.stream()
.map(personType::getName)
.filter(Strings::isNotBlank)
.collect(Collectors.joining("、"));
schoolApply.setSf(identityName);
return schoolApply;
}
private String getUnionApplyExportFileName(String unionId) { private String getUnionApplyExportFileName(String unionId) {
String unionName = "分工会"; String unionName = "分工会";
if (Strings.isNotBlank(unionId)) { if (Strings.isNotBlank(unionId)) {
@@ -17,7 +17,6 @@ import com.budwk.app.flow.enums.ProcessInstanceStateEnum;
import com.budwk.app.flow.enums.ProcessSubmitTypeEnum; import com.budwk.app.flow.enums.ProcessSubmitTypeEnum;
import com.budwk.app.flow.service.FlowCommonService; import com.budwk.app.flow.service.FlowCommonService;
import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.club.model.ClubUser;
import com.budwk.app.zhgh.club.model.SysClub; import com.budwk.app.zhgh.club.model.SysClub;
import com.budwk.app.zhgh.club.model.SysClubExamineRegister; import com.budwk.app.zhgh.club.model.SysClubExamineRegister;
import com.budwk.app.zhgh.club.service.SysClubExamineService; import com.budwk.app.zhgh.club.service.SysClubExamineService;
@@ -119,10 +118,7 @@ public class ClubExamineApplyController {
if(StrUtil.isBlank(clubId)) { if(StrUtil.isBlank(clubId)) {
return Result.error("社团信息为空,请核查"); return Result.error("社团信息为空,请核查");
} }
int count = dao.count(ClubUser.class, Cnd.where("clubId", "=", clubId)); return Result.success(sysClubExamineService.getClubMemberMoney(clubId));
SysClub club = dao.fetch(SysClub.class, clubId);
int money = count * (club.getDue() != null ? Integer.parseInt(club.getDue()) : 0);
return Result.success(money);
} }
@At @At
@@ -175,6 +171,10 @@ public class ClubExamineApplyController {
public Result save(@Param("data") SysClubExamineRegister examineRegister, public Result save(@Param("data") SysClubExamineRegister examineRegister,
@Param("incomeDetailed") String incomeDetailed, @Param("incomeDetailed") String incomeDetailed,
@Param("incomeCensus") String incomeCensus) { @Param("incomeCensus") String incomeCensus) {
String validateMessage = sysClubExamineService.validateActivityRequired(examineRegister);
if (validateMessage != null) {
return Result.error(validateMessage);
}
if (StrUtil.isBlank(examineRegister.getId())) { if (StrUtil.isBlank(examineRegister.getId())) {
sysClubExamineService.doAdd(examineRegister, incomeDetailed, incomeCensus); sysClubExamineService.doAdd(examineRegister, incomeDetailed, incomeCensus);
} else { } else {
@@ -190,6 +190,10 @@ public class ClubExamineApplyController {
public Result submit(@Param("data") SysClubExamineRegister examineRegister, public Result submit(@Param("data") SysClubExamineRegister examineRegister,
@Param("incomeDetailed") String incomeDetailed, @Param("incomeDetailed") String incomeDetailed,
@Param("incomeCensus") String incomeCensus) { @Param("incomeCensus") String incomeCensus) {
String validateMessage = sysClubExamineService.validateActivityRequired(examineRegister);
if (validateMessage != null) {
return Result.error(validateMessage);
}
SysClubExamineRegister reg; SysClubExamineRegister reg;
if (StrUtil.isBlank(examineRegister.getId())) { if (StrUtil.isBlank(examineRegister.getId())) {
reg = sysClubExamineService.doAdd(examineRegister, incomeDetailed, incomeCensus); reg = sysClubExamineService.doAdd(examineRegister, incomeDetailed, incomeCensus);
@@ -221,6 +225,10 @@ public class ClubExamineApplyController {
@Param("incomeDetailed") String incomeDetailed, @Param("incomeDetailed") String incomeDetailed,
@Param("incomeCensus") String incomeCensus, @Param("incomeCensus") String incomeCensus,
@Param("taskId") Long taskId) { @Param("taskId") Long taskId) {
String validateMessage = sysClubExamineService.validateActivityRequired(examineRegister);
if (validateMessage != null) {
return Result.error(validateMessage);
}
if (StrUtil.isBlank(examineRegister.getId())) { if (StrUtil.isBlank(examineRegister.getId())) {
sysClubExamineService.doAdd(examineRegister, incomeDetailed, incomeCensus); sysClubExamineService.doAdd(examineRegister, incomeDetailed, incomeCensus);
} else { } else {
@@ -86,7 +86,7 @@ public class ClubExamineMineController {
//nutMap.setv("schoolName", Globals.MyConfig.getString("AppName")); //nutMap.setv("schoolName", Globals.MyConfig.getString("AppName"));
nutMap.setv("clubName", register.getClubName()); nutMap.setv("clubName", register.getClubName());
nutMap.setv("create_time", register.getFoundTime()); nutMap.setv("create_time", register.getFoundTime());
nutMap.setv("dues_standard", register.getDue()); nutMap.setv("dues_standard", register.getDue() == null ? "--" : register.getDue() + "元/年");
// 生成材料中的年度与列表展示的登记年度保持一致。 // 生成材料中的年度与列表展示的登记年度保持一致。
nutMap.setv("year", register.getYear()); nutMap.setv("year", register.getYear());
nutMap.setv("registerDate", register.getRegisterDate()); nutMap.setv("registerDate", register.getRegisterDate());
@@ -91,8 +91,8 @@ public class SysClub extends BaseModel {
@Column @Column
@Comment("会费标准") @Comment("会费标准")
@ColDefine(type = ColType.VARCHAR, width = 50) @ColDefine(type = ColType.DOUBLE, width = 10, precision = 2)
private String due; private Double due;
@Column @Column
@Comment("附件") @Comment("附件")
@@ -57,8 +57,8 @@ public class SysClubExamineRegister extends BaseModel {
@Column @Column
@Comment("会费标准") @Comment("会费标准")
@ColDefine(type = ColType.VARCHAR, width = 32) @ColDefine(type = ColType.DOUBLE, width = 10, precision = 2)
private String due; private Double due;
@Column @Column
@Comment("年度活动情况") @Comment("年度活动情况")
@@ -24,6 +24,21 @@ public interface SysClubExamineService extends BaseService<SysClubExamineRegiste
List<NutMap> getJgUser(@Valid String clubId); List<NutMap> getJgUser(@Valid String clubId);
/**
* 计算指定社团的年度会费收入。
*
* @param clubId 社团ID,用于查询当前会员数量和社团会费标准
* @return 会员数量乘以会费标准后的金额;社团或会费标准为空时返回0
*/
Double getClubMemberMoney(@Valid String clubId);
/**
* 校验考核登记的两组活动明细。
* @param examineRegister 页面data参数对应的登记实体,yearActivityList为本年度活动,plans为下一年度计划
* @return String类型;通过返回null,否则返回缺失的明细行号及字段提示
*/
String validateActivityRequired(SysClubExamineRegister examineRegister);
SysClubExamineRegister doAdd(SysClubExamineRegister examineRegister, String incomeDetailed, String incomeCensus); SysClubExamineRegister doAdd(SysClubExamineRegister examineRegister, String incomeDetailed, String incomeCensus);
SysClubExamineRegister doEdit(SysClubExamineRegister examineRegister, String incomeDetailed, String incomeCensus); SysClubExamineRegister doEdit(SysClubExamineRegister examineRegister, String incomeDetailed, String incomeCensus);
@@ -13,6 +13,7 @@ import com.budwk.app.flow.enums.ProcessInstanceStateEnum;
import com.budwk.app.flow.enums.ProcessTaskStateEnum; import com.budwk.app.flow.enums.ProcessTaskStateEnum;
import com.budwk.app.web.commons.auth.utils.AuthUtil; import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.club.model.ClubUser;
import com.budwk.app.zhgh.club.model.SysClub; import com.budwk.app.zhgh.club.model.SysClub;
import com.budwk.app.zhgh.club.model.SysClubExamineRegister; import com.budwk.app.zhgh.club.model.SysClubExamineRegister;
import com.budwk.app.zhgh.club.model.SysClubExamineRegisterDetailed; import com.budwk.app.zhgh.club.model.SysClubExamineRegisterDetailed;
@@ -50,6 +51,15 @@ public class SysClubExamineServiceImpl extends BaseServiceImpl<SysClubExamineReg
super(dao); super(dao);
} }
@Override
public Double getClubMemberMoney(String clubId) {
int memberCount = dao().count(ClubUser.class, Cnd.where(ClubUser::getClubId, "=", clubId));
SysClub club = dao().fetch(SysClub.class, clubId);
// 社团或会费标准为空时按0计算,保证考核登记初始化能够正常完成。
Double due = club == null || club.getDue() == null ? 0D : club.getDue();
return memberCount * due;
}
@Override @Override
public List<NutMap> getClubUserNum(String clubId, Integer year) { public List<NutMap> getClubUserNum(String clubId, Integer year) {
Date yearStart = DateUtil.parse(year + "-01-01 00:00:00"); Date yearStart = DateUtil.parse(year + "-01-01 00:00:00");
@@ -345,6 +355,40 @@ public class SysClubExamineServiceImpl extends BaseServiceImpl<SysClubExamineReg
return listMap; return listMap;
} }
/**
* 两组明细均至少填写一条;本年度主办单位和获奖情况保持选填。
* @param examineRegister data参数绑定的登记实体,包含yearActivityList和plans两组JSON明细
* @return 校验通过返回null,否则返回首个缺失字段的行号和名称
*/
@Override
public String validateActivityRequired(SysClubExamineRegister examineRegister) {
if (examineRegister == null) {
return "考核登记信息不能为空";
}
String message = validateActivityRows(examineRegister.getYearActivityList(), "本年度活动开展情况",
new String[]{"activityTime", "activityName", "activityAddress", "joinNum"},
new String[]{"活动时间", "活动主题/赛事名称", "活动地点", "参加人数"});
return message != null ? message : validateActivityRows(examineRegister.getPlans(), "下一年度活动计划",
new String[]{"activityName", "activityUnit", "activityAddress"},
new String[]{"活动主题/赛事名称", "活动/赛事主办单位", "活动地点"});
}
/** 检查空列表、空行及纯空白字段;数值0按已填写处理,不扩大为人数范围校验。 */
private String validateActivityRows(List<JSONObject> rows, String label, String[] fields, String[] names) {
if (rows == null || rows.isEmpty()) {
return label + "至少填写一条";
}
for (int i = 0; i < rows.size(); i++) {
JSONObject row = rows.get(i);
for (int j = 0; j < fields.length; j++) {
if (row == null || StrUtil.isBlank(row.getStr(fields[j]))) {
return label + "第" + (i + 1) + "条:" + names[j] + "不能为空";
}
}
}
return null;
}
@Override @Override
public SysClubExamineRegister doAdd(SysClubExamineRegister examineRegister, String incomeDetailed, String incomeCensus) { public SysClubExamineRegister doAdd(SysClubExamineRegister examineRegister, String incomeDetailed, String incomeCensus) {
// JSON转换为实体列表(空值判断避免转换异常) // JSON转换为实体列表(空值判断避免转换异常)
@@ -82,16 +82,7 @@ import java.util.stream.Collectors;
@IocBean(args = {"refer:dao"}) @IocBean(args = {"refer:dao"})
public class ProposalCommonServiceImpl extends BaseServiceImpl<ProposalInfo> implements ProposalCommonService { 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"; private static final String PROPOSAL_DEFAULT_TEMPLATE_CODE = "proposal";
/** PC端提案列表页面的排序字段白名单,页面代码由对应Controller固定传入。 */ /** PC端提案列表页面的排序字段白名单,页面代码由对应Controller固定传入。 */
@@ -1367,6 +1358,11 @@ public class ProposalCommonServiceImpl extends BaseServiceImpl<ProposalInfo> imp
execute(sql); execute(sql);
NutMap docData = (NutMap) sql.getResult(); NutMap docData = (NutMap) sql.getResult();
// 提案可能已被删除,避免在读取模板字段时产生空指针。
if (docData == null) {
throw new BaseException("提案信息不存在");
}
//处理下富文本 //处理下富文本
String brief = docData.getString("brief"); String brief = docData.getString("brief");
String measures = docData.getString("measures"); String measures = docData.getString("measures");
@@ -1381,11 +1377,22 @@ public class ProposalCommonServiceImpl extends BaseServiceImpl<ProposalInfo> imp
ProcessInstance processInstance = dao().fetch(ProcessInstance.class, Cnd.where(ProcessInstance::getBusinessNo, "=", id)); ProcessInstance processInstance = dao().fetch(ProcessInstance.class, Cnd.where(ProcessInstance::getBusinessNo, "=", id));
List<ProcessTaskVO> doneTaskVos = new ArrayList<>(); List<ProcessTaskVO> doneTaskVos = new ArrayList<>();
// 尚未启动或缺少 WF 实例的提案仍导出基础信息,附议和审批栏保持为空。
if (processInstance != null) {
List<ProcessTask> doneTaskList = flowEngine.processTaskService().getDoneTaskList(processInstance.getId(), null); List<ProcessTask> doneTaskList = flowEngine.processTaskService().getDoneTaskList(processInstance.getId(), null);
for (ProcessTask doneTask : doneTaskList) { for (ProcessTask doneTask : doneTaskList) {
ProcessTaskVO taskVO = flowEngine.processTaskService().findById(doneTask.getId()); ProcessTaskVO taskVO = flowEngine.processTaskService().findById(doneTask.getId());
if (taskVO != null && taskVO.getExt() != null) {
doneTaskVos.add(taskVO); 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() Map<String, List<ProcessTaskVO>> taskGroups = doneTaskVos.stream()
@@ -1394,6 +1401,17 @@ public class ProposalCommonServiceImpl extends BaseServiceImpl<ProposalInfo> imp
docData.putAll(taskGroups); docData.putAll(taskGroups);
putProposalExportTaskAliases(id, taskGroups, docData); 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<NutMap> secondInfos = new ArrayList<>();
List<ProcessTaskVO> secondTaskVos = taskGroups.get("提案附议"); List<ProcessTaskVO> secondTaskVos = taskGroups.get("提案附议");
@@ -1475,29 +1493,13 @@ public class ProposalCommonServiceImpl extends BaseServiceImpl<ProposalInfo> imp
docData.put("secondedList", secondInfos); docData.put("secondedList", secondInfos);
docData.put("delegationAuditList", delegationAuditList); docData.put("delegationAuditList", delegationAuditList);
String templateCode = getProposalTemplateCode(docData.getString("caseFilingResult"));
try { 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) { } catch (IOException e) {
log.error("提案导出失败{},提案id:{}", e.getMessage(), id); 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;
};
}
/** /**
* 将现行工作流节点映射到历史提案表模板使用的区块名称,并按主协办单位拆分办理答复。 * 将现行工作流节点映射到历史提案表模板使用的区块名称,并按主协办单位拆分办理答复。
* 模板仍使用旧节点名称时,避免已完成的意见和答复因键名不一致而无法渲染。 * 模板仍使用旧节点名称时,避免已完成的意见和答复因键名不一致而无法渲染。
@@ -0,0 +1,60 @@
package com.budwk.app.zhgh.staffbenefit.condolence.controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.hutool.core.lang.Dict;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.exception.BaseException;
import com.budwk.app.base.result.Result;
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.service.FlowCommonService;
import com.budwk.app.zhgh.staffbenefit.condolence.model.Condolence;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
@IocBean
@Ok("json")
@At("/platform/condolence/approval")
public class CondolenceApprovalController {
@Inject
private Dao dao;
@Inject
private FlowCommonService flowCommonService;
@At
@SaCheckLogin
@Aop(TransAop.READ_COMMITTED)
public Result executeTask(@Param("data") String data) {
Dict args = Json.fromJson(Dict.class, data);
if (args == null || args.getLong(FlowConst.PROCESS_TASK_ID_KEY) == null) {
throw new BaseException("审批任务不能为空");
}
ProcessTask task = dao.fetch(ProcessTask.class, args.getLong(FlowConst.PROCESS_TASK_ID_KEY));
if (task == null) throw new BaseException("审批任务不存在");
if (!"4dad0bef-3cf2-4a7d-bba3-35020b9b216d".equals(task.getTaskName())
&& !"34f7938e-47a3-4b3a-b4e0-92b7a3f47865".equals(task.getTaskName())
&& !"ecdaef3b-edf4-4143-9a96-887dd5921711".equals(task.getTaskName())
&& !"cbd41337-3c87-4959-9027-809df5c7ef5e".equals(task.getTaskName())) {
throw new BaseException("当前任务不是职工慰问审批任务");
}
ProcessInstance instance = dao.fetch(ProcessInstance.class, task.getProcessInstanceId());
Condolence info = instance == null ? null : dao.fetch(Condolence.class, instance.getBusinessNo());
if (info == null) throw new BaseException("职工慰问申请不存在");
if (StrUtil.isBlank(args.getStr("tf_sign"))) throw new BaseException("请完成审批签字");
if (StrUtil.isBlank(args.getStr("tf_opinion"))) throw new BaseException("请填写审批意见");
// 参与者权限由流程引擎校验,业务入口不接受客户端指定管理员代办。
args.remove(FlowConst.EXT_ENABLE_OPERATOR);
flowCommonService.executeTask(args);
return Result.success();
}
}
@@ -17,6 +17,7 @@ import org.nutz.dao.Cnd;
import org.nutz.dao.Sqls; import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql; import org.nutz.dao.sql.Sql;
import org.nutz.dao.util.cri.SqlExpressionGroup; 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.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap; import org.nutz.lang.util.NutMap;
@@ -27,7 +28,7 @@ import org.nutz.mvc.annotation.Param;
import java.util.List; import java.util.List;
/** /**
* @ClassName CondolenceBranchUnionApprovalController * @ClassName CondolenceFirstApprovalController
* @Author JyuHsin * @Author JyuHsin
* @Date 2025/7/28 11:33 * @Date 2025/7/28 11:33
* @Version 1.0 * @Version 1.0
@@ -35,26 +36,26 @@ import java.util.List;
*/ */
@IocBean @IocBean
@At("/platform/condolence/branchUnionApproval") @At("/platform/condolence/branchUnionApproval")
@Api("职工慰问分工会审核") @Api("职工慰问一级审核")
@Ok("json:full") @Ok("json:full")
public class CondolenceBranchUnionApprovalController { public class CondolenceFirstApprovalController {
@Inject @Inject
private CondolenceService condolenceService; private CondolenceService condolenceService;
@At("/") @At("/")
@Ok("beetl:/platform/zhgh/staffbenefit/condolence/branchUnionApproval/index.html") @Ok("beetl:/platform/zhgh/staffbenefit/condolence/branchUnionApproval/index.html")
@SaCheckPermission("condolence.branchUnionApproval") @SaCheckPermission("condolence.firstApproval")
public void index() {} public void index() {}
@At("/h5") @At("/h5")
@Ok("beetl:/platform/zhghh5/staffbenefit/condolence/branchUnionApproval/index.html") @Ok("beetl:/platform/zhghh5/staffbenefit/condolence/branchUnionApproval/index.html")
@SaCheckPermission("h5.condolence.branchUnionApproval") @SaCheckPermission("h5.condolence.firstApproval")
public void h5Index() {} public void h5Index() {}
@At @At
@ApiOperation("分页查询") @ApiOperation("分页查询")
@SaCheckPermission(value = {"condolence.branchUnionApproval", "h5.condolence.branchUnionApproval"}, mode = SaMode.OR) @SaCheckPermission(value = {"condolence.firstApproval", "h5.condolence.firstApproval"}, mode = SaMode.OR)
public Result pageData(PageForm pageForm, public Result pageData(PageForm pageForm,
@Param(value = "year") Integer year, @Param(value = "year") Integer year,
@Param(value = "type") String type, @Param(value = "type") String type,
@@ -90,6 +91,14 @@ public class CondolenceBranchUnionApprovalController {
$condition $condition
"""); """);
Cnd cnd = Cnd.NEW(); Cnd cnd = Cnd.NEW();
cnd.and(new Static("""
NOT EXISTS (
SELECT 1 FROM wf_process_task newer
WHERE newer.processInstanceId = t.processInstanceId
AND newer.taskName = t.taskName
AND newer.id > t.id
)
"""));
if (StrUtil.isNotBlank(pageForm.getSearchKeyword())) { if (StrUtil.isNotBlank(pageForm.getSearchKeyword())) {
SqlExpressionGroup seg = new SqlExpressionGroup(); SqlExpressionGroup seg = new SqlExpressionGroup();
seg.or(Condolence::getHelpUserName, "like", "%" + pageForm.getSearchKeyword() + "%"); seg.or(Condolence::getHelpUserName, "like", "%" + pageForm.getSearchKeyword() + "%");
@@ -100,7 +109,9 @@ public class CondolenceBranchUnionApprovalController {
cnd.andEX("YEAR(info.createTime)", "=", year); cnd.andEX("YEAR(info.createTime)", "=", year);
cnd.and("t.taskName", "=", "4dad0bef-3cf2-4a7d-bba3-35020b9b216d"); cnd.and("t.taskName", "=", "4dad0bef-3cf2-4a7d-bba3-35020b9b216d");
if (!"superadmin".equals(SecurityUtil.getUserLoginname())) {
cnd.and("ta.actorId", "in", List.of(SecurityUtil.getUserId())); cnd.and("ta.actorId", "in", List.of(SecurityUtil.getUserId()));
}
if (approval) { if (approval) {
cnd.and("t.taskState", "in", List.of(ProcessTaskStateEnum.FINISHED.getCode(), ProcessTaskStateEnum.INTERRUPT.getCode())); cnd.and("t.taskState", "in", List.of(ProcessTaskStateEnum.FINISHED.getCode(), ProcessTaskStateEnum.INTERRUPT.getCode()));
@@ -0,0 +1,160 @@
package com.budwk.app.zhgh.staffbenefit.condolence.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.PageUtil;
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.zhgh.staffbenefit.condolence.model.Condolence;
import com.budwk.app.zhgh.staffbenefit.condolence.service.CondolenceService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
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.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import java.util.List;
/**
* @ClassName CondolenceFourthApprovalController
* @Author JyuHsin
* @Date 2025/7/29 16:23
* @Version 1.0
* @Description TODO
*/
@IocBean
@At("/platform/condolence/fourthApproval")
@Api("职工慰问四级审核")
@Ok("json:full")
public class CondolenceFourthApprovalController {
@Inject
private Dao dao;
@Inject
private CondolenceService condolenceService;
@At("/")
@Ok("beetl:/platform/zhgh/staffbenefit/condolence/fourthApproval/index.html")
@SaCheckPermission("condolence.fourthApproval")
public void index() {}
@At("/h5")
@Ok("beetl:/platform/zhghh5/staffbenefit/condolence/fourthApproval/index.html")
@SaCheckPermission("h5.condolence.fourthApproval")
public void h5Index() {}
@At
@ApiOperation("分页查询")
@SaCheckPermission(value = {"condolence.fourthApproval", "h5.condolence.fourthApproval"}, mode = SaMode.OR)
public Result pageData(PageForm pageForm,
@Param(value = "year") Integer year,
@Param(value = "type") String type,
@Param(value = "unionId") String unionId,
@Param(value = "unitId") String unitId,
@Param(value = "approval") Boolean approval) {
Sql sql = Sqls.create("""
SELECT
info.*,
type.name as typeName,
ins.id AS instanceId,
ins.businessNo,
ins.state instanceState,
ins.variable instanceVariable,
ins.processDefineId instanceProcessDefineId,
t.id taskId,
t.taskName AS taskKey,
t.displayName taskName,
t.taskType,
t.performType taskPerformType,
t.taskState,
t.finishTime,
t.taskParentId,
t.variable taskVariable,
IFNULL(GROUP_CONCAT(DISTINCT nt.displayName),'结束') curTaskName,
IF(rt.id IS NOT NULL, 1, 0) AS canRevoke
FROM
wf_process_task t
LEFT JOIN wf_process_instance ins ON ins.id = t.processInstanceId
LEFT JOIN condolence info ON info.id = ins.businessNo
LEFT JOIN wf_process_task_actor ta ON ta.processTaskId = t.id
LEFT JOIN condolence_type type ON info.type = type.id
LEFT JOIN wf_process_task nt ON nt.processInstanceId = ins.id AND nt.taskState = 10
LEFT JOIN wf_process_task rt ON rt.processInstanceId = ins.id AND rt.taskParentId = t.id AND rt.taskState = 10
$condition
""");
Cnd cnd = Cnd.NEW();
cnd.and(new Static("""
NOT EXISTS (
SELECT 1 FROM wf_process_task newer
WHERE newer.processInstanceId = t.processInstanceId
AND newer.taskName = t.taskName
AND newer.id > t.id
)
"""));
if (StrUtil.isNotBlank(pageForm.getSearchKeyword())) {
SqlExpressionGroup seg = new SqlExpressionGroup();
seg.or(Condolence::getHelpUserName, "like", "%" + pageForm.getSearchKeyword() + "%");
seg.or(Condolence::getHelpLoginName, "like", "%" + pageForm.getSearchKeyword() + "%");
cnd.and(seg);
}
cnd.andEX("info.type", "=", type);
cnd.andEX("YEAR(info.createTime)", "=", year);
cnd.andEX("info.applyUnionId", "=", unionId);
cnd.andEX("info.applyUnitId", "=", unitId);
cnd.and("t.taskName", "=", "cbd41337-3c87-4959-9027-809df5c7ef5e");
if (!"superadmin".equals(SecurityUtil.getUserLoginname()) && !AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name())) {
Sql unitSql = Sqls.queryString("""
SELECT DISTINCT ur.unitId
FROM sys_user_role ur
INNER JOIN sys_role r ON r.id = ur.roleId
WHERE ur.userId = @userId
AND ur.enable = true
AND r.disabled = false
AND r.code = @roleCode
AND ur.unitId IS NOT NULL
AND ur.unitId <> ''
""");
unitSql.setParam("userId", SecurityUtil.getUserId());
unitSql.setParam("roleCode", RoleConstant.UNIT_SCHOOL_LEADER.name());
dao.execute(unitSql);
List<String> managedUnitIds = unitSql.getList(String.class);
if (managedUnitIds.isEmpty()) {
cnd.and(new Static("1 = 0"));
} else {
cnd.and("info.applyUnitId", "in", managedUnitIds);
}
}
if (approval) {
cnd.and("t.taskState", "in", List.of(ProcessTaskStateEnum.FINISHED.getCode(), ProcessTaskStateEnum.INTERRUPT.getCode()));
} else {
cnd.and("t.taskState", "=", ProcessTaskStateEnum.DOING.getCode());
}
if (StrUtil.isAllBlank(pageForm.getPageOrderName(), pageForm.getPageOrderBy())) {
cnd.desc("info.createTime");
} else {
cnd.orderBy(pageForm.getPageOrderName(), PageUtil.getOrder(pageForm.getPageOrderBy()));
}
cnd.groupBy("t.id");
sql.setCondition(cnd);
Pagination<NutMap> pageVO = condolenceService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql);
return Result.success(pageVO);
}
}
@@ -3,23 +3,35 @@ package com.budwk.app.zhgh.staffbenefit.condolence.controller;
import cn.dev33.satoken.annotation.SaCheckLogin; import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode; import cn.dev33.satoken.annotation.SaMode;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.lang.Dict;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.annotation.SLog; import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.constant.RoleConstant; import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.exception.BaseException;
import com.budwk.app.base.page.Pagination; import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm; import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result; import com.budwk.app.base.result.Result;
import com.budwk.app.base.sms.SmsService; import com.budwk.app.base.sms.SmsService;
import com.budwk.app.base.utils.PageUtil; import com.budwk.app.base.utils.PageUtil;
import com.budwk.app.base.utils.CommonDownloadUtil;
import com.budwk.app.base.utils.SysOfficeTemplateUtil;
import com.budwk.app.flow.engine.FlowEngine; import com.budwk.app.flow.engine.FlowEngine;
import com.budwk.app.flow.entity.ProcessInstance;
import com.budwk.app.flow.entity.ProcessTask;
import com.budwk.app.flow.enums.ProcessInstanceStateEnum;
import com.budwk.app.flow.enums.ProcessTaskStateEnum;
import com.budwk.app.flow.vo.ProcessTaskVO;
import com.budwk.app.sys.views.View_user; import com.budwk.app.sys.views.View_user;
import com.budwk.app.web.commons.auth.utils.AuthUtil; import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil; 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.Condolence;
import com.budwk.app.zhgh.staffbenefit.condolence.model.CondolenceType; import com.budwk.app.zhgh.staffbenefit.condolence.model.CondolenceType;
import com.budwk.app.zhgh.staffbenefit.condolence.service.CondolenceService; import com.budwk.app.zhgh.staffbenefit.condolence.service.CondolenceService;
import com.deepoove.poi.data.PictureRenderData;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd; import org.nutz.dao.Cnd;
import org.nutz.dao.Sqls; import org.nutz.dao.Sqls;
@@ -34,6 +46,10 @@ import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param; import org.nutz.mvc.annotation.Param;
import javax.servlet.http.HttpServletResponse;
import java.io.InputStream;
import java.util.List;
/** /**
* @ClassName CondolenceApplyController * @ClassName CondolenceApplyController
* @Author JyuHsin * @Author JyuHsin
@@ -41,6 +57,7 @@ import org.nutz.mvc.annotation.Param;
* @Version 1.0 * @Version 1.0
* @Description TODO * @Description TODO
*/ */
@Slf4j
@IocBean @IocBean
@At("/platform/condolence/mine") @At("/platform/condolence/mine")
@Api("职工慰问我的") @Api("职工慰问我的")
@@ -51,6 +68,8 @@ public class CondolenceMineController {
private CondolenceService condolenceService; private CondolenceService condolenceService;
@Inject @Inject
private FlowEngine flowEngine; private FlowEngine flowEngine;
@Inject
private SysOfficeTemplateUtil sysOfficeTemplateUtil;
@At("/") @At("/")
@Ok("beetl:/platform/zhgh/staffbenefit/condolence/mine/index.html") @Ok("beetl:/platform/zhgh/staffbenefit/condolence/mine/index.html")
@@ -132,6 +151,98 @@ public class CondolenceMineController {
return Result.success(pagination); return Result.success(pagination);
} }
@At
@Ok("void")
@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())) {
throw new BaseException("只能导出本人提交的申请");
}
ProcessInstance instance = condolenceService.dao().fetch(ProcessInstance.class,
Cnd.where("businessNo", "=", id).desc("id"));
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", 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")) {
if (input == null) throw new BaseException("职工慰问申请表模板不存在");
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;
}
}
private void fillApplyApprovals(NutMap docData, ProcessInstance instance) {
String[] taskKeys = {"4dad0bef-3cf2-4a7d-bba3-35020b9b216d", "34f7938e-47a3-4b3a-b4e0-92b7a3f47865",
"ecdaef3b-edf4-4143-9a96-887dd5921711", "cbd41337-3c87-4959-9027-809df5c7ef5e"};
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, "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());
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, "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, "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"));
docData.put(fields[i] + "Day", task.getFinishTime() == null ? "" : DateUtil.format(task.getFinishTime(), "d"));
}
}
}
@At @At
@ApiOperation("删除") @ApiOperation("删除")
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
@@ -106,7 +106,7 @@ public class CondolenceReadController {
cnd.and(seg); 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()); cnd.and("info.applyUnionId", "=", SecurityUtil.getUnionId());
} }
@@ -153,7 +153,7 @@ public class CondolenceReadController {
seg.or("info.helpLoginName", "like", "%" + pageForm.getSearchKeyword() + "%"); seg.or("info.helpLoginName", "like", "%" + pageForm.getSearchKeyword() + "%");
cnd.and(seg); 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()); cnd.and("con.applyUnionId", "=", SecurityUtil.getUnionId());
} }
sql.setCondition(cnd); sql.setCondition(cnd);
@@ -17,6 +17,7 @@ import org.nutz.dao.Cnd;
import org.nutz.dao.Sqls; import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql; import org.nutz.dao.sql.Sql;
import org.nutz.dao.util.cri.SqlExpressionGroup; 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.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap; import org.nutz.lang.util.NutMap;
@@ -33,26 +34,26 @@ import java.util.List;
*/ */
@IocBean @IocBean
@At("/platform/condolence/schoolPrincipalApproval") @At("/platform/condolence/schoolPrincipalApproval")
@Api("职工慰问校工会预审") @Api("职工慰问二级审核")
@Ok("json:full") @Ok("json:full")
public class CondolenceSchoolPrincipalApprovalController { public class CondolenceSecondApprovalController {
@Inject @Inject
private CondolenceService condolenceService; private CondolenceService condolenceService;
@At("/") @At("/")
@Ok("beetl:/platform/zhgh/staffbenefit/condolence/schoolPrincipalApproval/index.html") @Ok("beetl:/platform/zhgh/staffbenefit/condolence/schoolPrincipalApproval/index.html")
@SaCheckPermission("condolence.schoolPrincipalApproval") @SaCheckPermission("condolence.secondApproval")
public void index() {} public void index() {}
@At("/h5") @At("/h5")
@Ok("beetl:/platform/zhghh5/staffbenefit/condolence/schoolPrincipalApproval/index.html") @Ok("beetl:/platform/zhghh5/staffbenefit/condolence/schoolPrincipalApproval/index.html")
@SaCheckPermission("h5.condolence.schoolPrincipalApproval") @SaCheckPermission("h5.condolence.secondApproval")
public void h5Index() {} public void h5Index() {}
@At @At
@ApiOperation("分页查询") @ApiOperation("分页查询")
@SaCheckPermission(value = {"condolence.schoolPrincipalApproval", "h5.condolence.schoolPrincipalApproval"}, mode = SaMode.OR) @SaCheckPermission(value = {"condolence.secondApproval", "h5.condolence.secondApproval"}, mode = SaMode.OR)
public Result pageData(PageForm pageForm, public Result pageData(PageForm pageForm,
@Param(value = "year") Integer year, @Param(value = "year") Integer year,
@Param(value = "type") String type, @Param(value = "type") String type,
@@ -90,6 +91,14 @@ public class CondolenceSchoolPrincipalApprovalController {
$condition $condition
"""); """);
Cnd cnd = Cnd.NEW(); Cnd cnd = Cnd.NEW();
cnd.and(new Static("""
NOT EXISTS (
SELECT 1 FROM wf_process_task newer
WHERE newer.processInstanceId = t.processInstanceId
AND newer.taskName = t.taskName
AND newer.id > t.id
)
"""));
if (StrUtil.isNotBlank(pageForm.getSearchKeyword())) { if (StrUtil.isNotBlank(pageForm.getSearchKeyword())) {
SqlExpressionGroup seg = new SqlExpressionGroup(); SqlExpressionGroup seg = new SqlExpressionGroup();
seg.or(Condolence::getHelpUserName, "like", "%" + pageForm.getSearchKeyword() + "%"); seg.or(Condolence::getHelpUserName, "like", "%" + pageForm.getSearchKeyword() + "%");
@@ -17,6 +17,7 @@ import org.nutz.dao.Cnd;
import org.nutz.dao.Sqls; import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql; import org.nutz.dao.sql.Sql;
import org.nutz.dao.util.cri.SqlExpressionGroup; 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.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap; import org.nutz.lang.util.NutMap;
@@ -27,7 +28,7 @@ import org.nutz.mvc.annotation.Param;
import java.util.List; import java.util.List;
/** /**
* @ClassName CondolenceSchoolUnionApprovalController * @ClassName CondolenceThirdApprovalController
* @Author JyuHsin * @Author JyuHsin
* @Date 2025/7/29 16:23 * @Date 2025/7/29 16:23
* @Version 1.0 * @Version 1.0
@@ -35,26 +36,26 @@ import java.util.List;
*/ */
@IocBean @IocBean
@At("/platform/condolence/schoolUnionApproval") @At("/platform/condolence/schoolUnionApproval")
@Api("职工慰问校工会审核") @Api("职工慰问三级审核")
@Ok("json:full") @Ok("json:full")
public class CondolenceSchoolUnionApprovalController { public class CondolenceThirdApprovalController {
@Inject @Inject
private CondolenceService condolenceService; private CondolenceService condolenceService;
@At("/") @At("/")
@Ok("beetl:/platform/zhgh/staffbenefit/condolence/schoolUnionApproval/index.html") @Ok("beetl:/platform/zhgh/staffbenefit/condolence/schoolUnionApproval/index.html")
@SaCheckPermission("condolence.schoolUnionApproval") @SaCheckPermission("condolence.thirdApproval")
public void index() {} public void index() {}
@At("/h5") @At("/h5")
@Ok("beetl:/platform/zhghh5/staffbenefit/condolence/schoolUnionApproval/index.html") @Ok("beetl:/platform/zhghh5/staffbenefit/condolence/schoolUnionApproval/index.html")
@SaCheckPermission("h5.condolence.schoolUnionApproval") @SaCheckPermission("h5.condolence.thirdApproval")
public void h5Index() {} public void h5Index() {}
@At @At
@ApiOperation("分页查询") @ApiOperation("分页查询")
@SaCheckPermission(value = {"condolence.schoolUnionApproval", "h5.condolence.schoolUnionApproval"}, mode = SaMode.OR) @SaCheckPermission(value = {"condolence.thirdApproval", "h5.condolence.thirdApproval"}, mode = SaMode.OR)
public Result pageData(PageForm pageForm, public Result pageData(PageForm pageForm,
@Param(value = "year") Integer year, @Param(value = "year") Integer year,
@Param(value = "type") String type, @Param(value = "type") String type,
@@ -92,6 +93,14 @@ public class CondolenceSchoolUnionApprovalController {
$condition $condition
"""); """);
Cnd cnd = Cnd.NEW(); Cnd cnd = Cnd.NEW();
cnd.and(new Static("""
NOT EXISTS (
SELECT 1 FROM wf_process_task newer
WHERE newer.processInstanceId = t.processInstanceId
AND newer.taskName = t.taskName
AND newer.id > t.id
)
"""));
if (StrUtil.isNotBlank(pageForm.getSearchKeyword())) { if (StrUtil.isNotBlank(pageForm.getSearchKeyword())) {
SqlExpressionGroup seg = new SqlExpressionGroup(); SqlExpressionGroup seg = new SqlExpressionGroup();
seg.or(Condolence::getHelpUserName, "like", "%" + pageForm.getSearchKeyword() + "%"); seg.or(Condolence::getHelpUserName, "like", "%" + pageForm.getSearchKeyword() + "%");
@@ -104,7 +113,9 @@ public class CondolenceSchoolUnionApprovalController {
cnd.andEX("info.applyUnitId", "=", unitId); cnd.andEX("info.applyUnitId", "=", unitId);
cnd.and("t.taskName", "=", "ecdaef3b-edf4-4143-9a96-887dd5921711"); cnd.and("t.taskName", "=", "ecdaef3b-edf4-4143-9a96-887dd5921711");
if (!"superadmin".equals(SecurityUtil.getUserLoginname())) {
cnd.and("ta.actorId", "in", List.of(SecurityUtil.getUserId())); cnd.and("ta.actorId", "in", List.of(SecurityUtil.getUserId()));
}
if (approval) { if (approval) {
cnd.and("t.taskState", "in", List.of(ProcessTaskStateEnum.FINISHED.getCode(), ProcessTaskStateEnum.INTERRUPT.getCode())); cnd.and("t.taskState", "in", List.of(ProcessTaskStateEnum.FINISHED.getCode(), ProcessTaskStateEnum.INTERRUPT.getCode()));
@@ -9,7 +9,6 @@ import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.date.DateUtil; import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil; import cn.hutool.core.io.FileUtil;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil; import cn.hutool.json.JSONUtil;
import com.alibaba.excel.EasyExcel; 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.result.Result;
import com.budwk.app.base.utils.CommonDownloadUtil; import com.budwk.app.base.utils.CommonDownloadUtil;
import com.budwk.app.base.utils.MoneyUtil; 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.base.utils.SysOfficeTemplateUtil;
import com.budwk.app.flow.engine.FlowEngine; import com.budwk.app.flow.engine.FlowEngine;
import com.budwk.app.flow.entity.ProcessInstance; 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.DifficultHelpPageParam;
import com.budwk.app.zhgh.staffbenefit.difficulthelp.vo.moneyImportVo; import com.budwk.app.zhgh.staffbenefit.difficulthelp.vo.moneyImportVo;
import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.aidFund.param.AidFundPageForm; 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.config.Configure;
import com.deepoove.poi.data.PictureRenderData; import com.deepoove.poi.data.PictureRenderData;
import com.deepoove.poi.plugin.table.LoopRowTableRenderPolicy; import com.deepoove.poi.plugin.table.LoopRowTableRenderPolicy;
@@ -74,8 +71,6 @@ import javax.servlet.http.HttpServletResponse;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.*; import java.io.*;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Date; import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
@@ -319,9 +314,9 @@ public class DifficultHelpReadingController {
LoopRowTableRenderPolicy policy = new LoopRowTableRenderPolicy(); LoopRowTableRenderPolicy policy = new LoopRowTableRenderPolicy();
Configure config = Configure.builder().bind("familyList", policy).build(); Configure config = Configure.builder().bind("familyList", policy).build();
XWPFTemplate.compile(sysOfficeTemplateUtil.getTemplate("difficulty_apply"), config) try (InputStream input = sysOfficeTemplateUtil.getTemplate("difficulty_apply")) {
.render(docData) zipOutputStream.write(sysOfficeTemplateUtil.renderTemplate(input, docData, config, false, "difficulthelp:batch:" + record.getString("id")));
.write(zipOutputStream); }
zipOutputStream.closeEntry(); zipOutputStream.closeEntry();
} }
zipOutputStream.flush(); 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[] taskKeys = {"36ebafda-44c2-4bd3-bd7f-10839a575a0d", "715ece82-4ae3-433c-ad18-ed875f45d1e3", "e405f96c-5fc0-4f63-b323-e69dfe853253"};
String[] fields = {"firstApproval", "secondApproval", "thirdApproval"}; String[] fields = {"firstApproval", "secondApproval", "thirdApproval"};
for (String field : fields) { 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")); ProcessInstance instance = dao.fetch(ProcessInstance.class, Cnd.where("businessNo", "=", businessId).desc("id"));
if (instance == null) return; if (instance == null) return;
List<ProcessTask> tasks = dao.query(ProcessTask.class, Cnd.where("processInstanceId", "=", instance.getId()).asc("id")); List<ProcessTask> tasks = dao.query(ProcessTask.class, Cnd.where("processInstanceId", "=", instance.getId()).asc("id"));
for (ProcessTask task : tasks) { for (ProcessTask task : tasks) {
ProcessTaskVO taskVO = flowEngine.processTaskService().findById(task.getId()); 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 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; continue;
} }
@@ -453,22 +449,9 @@ public class DifficultHelpReadingController {
String fileName = FileUtil.mainName(sysOfficeTemplateUtil.getTemplateFileName(templateName)) + "_" + difficultHelp.getUserName() + "_" + String fileName = FileUtil.mainName(sysOfficeTemplateUtil.getTemplateFileName(templateName)) + "_" + difficultHelp.getUserName() + "_" +
DateUtil.format(difficultHelp.getApplyTime(), "yyyyMMdd") + ".pdf"; DateUtil.format(difficultHelp.getApplyTime(), "yyyyMMdd") + ".pdf";
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { try (InputStream input = getApplyTemplate(templateName)) {
XWPFTemplate.compile(getApplyTemplate(templateName),config) byte[] bytes = sysOfficeTemplateUtil.renderTemplate(input, docData, config, true, "difficulthelp:" + difficultHelp.getId());
.render(docData) CommonDownloadUtil.download(fileName, bytes, response);
.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());
} }
} }
@@ -478,13 +461,9 @@ public class DifficultHelpReadingController {
String fileName = FileUtil.mainName(sysOfficeTemplateUtil.getTemplateFileName(templateName)) + "_" + difficultHelp.getUserName() + "_" + String fileName = FileUtil.mainName(sysOfficeTemplateUtil.getTemplateFileName(templateName)) + "_" + difficultHelp.getUserName() + "_" +
DateUtil.format(difficultHelp.getApplyTime(), "yyyyMMdd") + ".docx"; DateUtil.format(difficultHelp.getApplyTime(), "yyyyMMdd") + ".docx";
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { try (InputStream input = getApplyTemplate(templateName)) {
XWPFTemplate.compile(getApplyTemplate(templateName),config) byte[] bytes = sysOfficeTemplateUtil.renderTemplate(input, docData, config, false, "difficulthelp:" + difficultHelp.getId());
.render(docData) CommonDownloadUtil.download(fileName, bytes, response);
.writeAndClose(byteArrayOutputStream);
CommonDownloadUtil.download(fileName, byteArrayOutputStream.toByteArray(), response);
} catch (IOException e) {
log.error("困难慰问申请表导出Word失败,id:{},错误信息:{}", difficultHelp.getId(), e.getMessage());
} }
} }
@@ -562,15 +541,13 @@ public class DifficultHelpReadingController {
throw new RuntimeException("导出Excel汇总表失败:" + e.getMessage()); throw new RuntimeException("导出Excel汇总表失败:" + e.getMessage());
} }
} else if ("docx".equals(flag)) { } else if ("docx".equals(flag)) {
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { try (InputStream input = sysOfficeTemplateUtil.getTemplate(resource)) {
Configure config = Configure.builder() Configure config = Configure.builder()
.bind("da", new LoopRowTableRenderPolicy()) .bind("da", new LoopRowTableRenderPolicy())
.build(); .build();
XWPFTemplate.compile(sysOfficeTemplateUtil.getTemplate(resource), config) byte[] bytes = sysOfficeTemplateUtil.renderTemplate(input, result, config, false, "difficulthelp:summary:" + resource);
.render(result) CommonDownloadUtil.download(fileName + ".docx", bytes, response);
.writeAndClose(out);
CommonDownloadUtil.download(fileName + ".docx", out.toByteArray(), response);
} }
} }
} }
@@ -1,6 +1,7 @@
package com.budwk.app.zhgh.staffbenefit.difficulthelp.service; package com.budwk.app.zhgh.staffbenefit.difficulthelp.service;
import com.budwk.app.base.service.BaseService; 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.models.DifficultHelpInfo;
import com.budwk.app.zhgh.staffbenefit.difficulthelp.vo.DifficultHelpPageParam; import com.budwk.app.zhgh.staffbenefit.difficulthelp.vo.DifficultHelpPageParam;
import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.aidFund.param.AidFundPageForm; import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.aidFund.param.AidFundPageForm;
@@ -21,6 +22,14 @@ public interface DifficultHelpCommonService extends BaseService<DifficultHelpInf
void validateApplyUser(DifficultHelpInfo difficultHelpInfo); void validateApplyUser(DifficultHelpInfo difficultHelpInfo);
/**
* 困难帮扶进入校工会办公室主任审批时,通知本次任务的实际参与人。
* @param task WF 已创建且参与人已落库的任务;仅 KNBF 办公室审批待办触发,
* 接收人使用任务参与人的 actorAccount(系统 loginname)
* @return 无返回值;开关关闭或非目标节点不发送,发送失败记录日志而不阻断流程
*/
void notifyOfficeApproval(ProcessTask task);
/** /**
* 导入实际金额数据处理 * 导入实际金额数据处理
* @param file 文件 * @param file 文件
@@ -6,6 +6,12 @@ import cn.hutool.json.JSONUtil;
import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.base.constant.RoleConstant; import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.exception.BaseException; import com.budwk.app.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.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.base.utils.ManyAddOrRenewUtil; 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.Lang;
import org.nutz.lang.random.R; import org.nutz.lang.random.R;
import org.nutz.lang.util.NutMap; import org.nutz.lang.util.NutMap;
import org.nutz.log.Log;
import org.nutz.log.Logs;
import org.nutz.mvc.upload.TempFile; import org.nutz.mvc.upload.TempFile;
import java.util.*; import java.util.*;
@@ -46,6 +54,8 @@ import java.util.stream.IntStream;
*/ */
@IocBean(args = {"refer:dao"}) @IocBean(args = {"refer:dao"})
public class DifficultHelpCommonServiceImpl extends BaseServiceImpl<DifficultHelpInfo> implements DifficultHelpCommonService { 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) { public DifficultHelpCommonServiceImpl(Dao dao) {
super(dao); super(dao);
@@ -53,6 +63,55 @@ public class DifficultHelpCommonServiceImpl extends BaseServiceImpl<DifficultHel
@Inject @Inject
private ManyAddOrRenewUtil manyAddOrRenewUtil; 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 @Override
public void validateApplyUser(DifficultHelpInfo difficultHelpInfo) { public void validateApplyUser(DifficultHelpInfo difficultHelpInfo) {
@@ -1 +0,0 @@
ALTER TABLE difficult_help_info ADD COLUMN applyMoney VARCHAR(200) NULL COMMENT '申请金额';
@@ -114,3 +114,4 @@ VALUES ('4d3e3472ae324552b16f0aaf07c37004', '501e0a4edca748ddb739ffb679170d50',
'SCHOOL_OFFICE_DIRECTOR', false, 9614, false, '17a7f8ad3ee947b4a26175049a9c253d', 1791517263500, 'SCHOOL_OFFICE_DIRECTOR', false, 9614, false, '17a7f8ad3ee947b4a26175049a9c253d', 1791517263500,
'17a7f8ad3ee947b4a26175049a9c253d', 1791517263500, false, null); '17a7f8ad3ee947b4a26175049a9c253d', 1791517263500, false, null);
ALTER TABLE difficult_help_info ADD COLUMN applyMoney VARCHAR(200) NULL COMMENT '申请金额';
@@ -0,0 +1,136 @@
START TRANSACTION;
-- 一级:分工会主席审批,节点 4dad0bef-3cf2-4a7d-bba3-35020b9b216d。
UPDATE zhgh_cqgmy.sys_menu
SET name = '分工会主席审批', initialPinyinName = 'f', note = '分工会主席审批',
permission = 'condolence.firstApproval'
WHERE id = '8e24dfdb70e448a899ea7de92319789c';
UPDATE zhgh_cqgmy.sys_menu
SET name = '分工会主席审批', initialPinyinName = 'f', note = '分工会主席审批',
permission = 'h5.condolence.firstApproval'
WHERE id = '330e42e0ae914f5392b96fb933f17b5b';
-- 二级:校工会办公室主任审批,节点 34f7938e-47a3-4b3a-b4e0-92b7a3f47865。
UPDATE zhgh_cqgmy.sys_menu
SET name = '校工会办公室主任审批', initialPinyinName = 'x', note = '校工会办公室主任审批',
permission = 'condolence.secondApproval'
WHERE id = '09a59e7a99204ceca8e5dbbf8b2fafcf';
UPDATE zhgh_cqgmy.sys_menu
SET name = '校工会办公室主任审批', initialPinyinName = 'x', note = '校工会办公室主任审批',
permission = 'h5.condolence.secondApproval'
WHERE id = '87a2dcfac0da4ec4870b65693807a4d1';
-- 三级:校工会主席审批,节点 ecdaef3b-edf4-4143-9a96-887dd5921711。
UPDATE zhgh_cqgmy.sys_menu
SET name = '校工会主席审批', initialPinyinName = 'x', note = '校工会主席审批',
permission = 'condolence.thirdApproval'
WHERE id = '0917928006574b2a99f7ef3e8804d873';
UPDATE zhgh_cqgmy.sys_menu
SET name = '校工会主席审批', initialPinyinName = 'x', note = '校工会主席审批',
permission = 'h5.condolence.thirdApproval'
WHERE id = 'c269f94a5c8a4a4a8c8de0bb8f8f9f9a';
-- 四级:分管校领导审批,节点 cbd41337-3c87-4959-9027-809df5c7ef5e。
INSERT INTO zhgh_cqgmy.sys_menu
(id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled,
permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt,
delFlag, processKey, platform, moduleId, isQuickEntry, picIcon, initialPinyinName,
isRecommendApp, isRecommendService)
VALUES ('3561e97f44d14b47ae07f63ff8312a90', '3b49290f7db44130b9f6394a8212321f', '00900009',
'分管校领导审批', null, 'menu', '/platform/condolence/fourthApproval', null, null, true, false,
'condolence.fourthApproval', '分管校领导审批', 146, false,
'17a7f8ad3ee947b4a26175049a9c253d', UNIX_TIMESTAMP() * 1000,
'17a7f8ad3ee947b4a26175049a9c253d', UNIX_TIMESTAMP() * 1000,
false, null, 'PC', '07d940ba34bd47399cda140894f5d86b', false, null, 'f', false, false);
INSERT INTO zhgh_cqgmy.sys_menu
(id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled,
permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt,
delFlag, processKey, platform, moduleId, isQuickEntry, picIcon, initialPinyinName,
isRecommendApp, isRecommendService)
VALUES ('f02d5a68bdeb4c63a1b8ce47296d7304', '024811fd168e44948e6ab84911fc26bb', '01220006',
'分管校领导审批', null, 'menu', '/platform/condolence/fourthApproval/h5', null, null, true, false,
'h5.condolence.fourthApproval', '分管校领导审批', 32, false,
'17a7f8ad3ee947b4a26175049a9c253d', UNIX_TIMESTAMP() * 1000,
'17a7f8ad3ee947b4a26175049a9c253d', UNIX_TIMESTAMP() * 1000,
false, null, 'H5', '586fffaeddc94a7493c81b6d69e546ab', false, '/platform/sys/file/download?id=d2095854e67dcf28dea1a4a975e90aba', 'f', false, false);
-- 清除之前的菜单角色权限,按审核角色重新配置。
-- 一级:分工会主席审批,节点 4dad0bef-3cf2-4a7d-bba3-35020b9b216d。
DELETE FROM zhgh_cqgmy.sys_role_menu WHERE menuId = '8e24dfdb70e448a899ea7de92319789c';
INSERT INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', '8e24dfdb70e448a899ea7de92319789c');
INSERT INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
values ('fd753e8a8c5a404e8e00ef8e0b0c69da', '8e24dfdb70e448a899ea7de92319789c');
DELETE FROM zhgh_cqgmy.sys_role_menu WHERE menuId = '330e42e0ae914f5392b96fb933f17b5b';
INSERT INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', '330e42e0ae914f5392b96fb933f17b5b');
INSERT INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
values ('fd753e8a8c5a404e8e00ef8e0b0c69da', '330e42e0ae914f5392b96fb933f17b5b');
-- 二级:校工会办公室主任审批,节点 34f7938e-47a3-4b3a-b4e0-92b7a3f47865。
DELETE FROM zhgh_cqgmy.sys_role_menu WHERE menuId = '09a59e7a99204ceca8e5dbbf8b2fafcf';
INSERT INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', '09a59e7a99204ceca8e5dbbf8b2fafcf');
INSERT INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
VALUES ('494ac71eb8b44d0e99e19b7fde6bd469', '09a59e7a99204ceca8e5dbbf8b2fafcf');
DELETE FROM zhgh_cqgmy.sys_role_menu WHERE menuId = '87a2dcfac0da4ec4870b65693807a4d1';
INSERT INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', '87a2dcfac0da4ec4870b65693807a4d1');
INSERT INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
VALUES ('494ac71eb8b44d0e99e19b7fde6bd469', '87a2dcfac0da4ec4870b65693807a4d1');
-- 三级:校工会主席审批,节点 ecdaef3b-edf4-4143-9a96-887dd5921711。
DELETE FROM zhgh_cqgmy.sys_role_menu WHERE menuId = '0917928006574b2a99f7ef3e8804d873';
INSERT INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', '0917928006574b2a99f7ef3e8804d873');
INSERT INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
VALUES ('6aece46b946e4fb9ac4cb15c0cd8c3e6', '0917928006574b2a99f7ef3e8804d873');
DELETE FROM zhgh_cqgmy.sys_role_menu WHERE menuId = 'c269f94a5c8a4a4a8c8de0bb8f8f9f9a';
INSERT INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'c269f94a5c8a4a4a8c8de0bb8f8f9f9a');
INSERT INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
VALUES ('6aece46b946e4fb9ac4cb15c0cd8c3e6', 'c269f94a5c8a4a4a8c8de0bb8f8f9f9a');
DELETE FROM zhgh_cqgmy.sys_role_menu WHERE menuId = '3561e97f44d14b47ae07f63ff8312a90';
INSERT INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', '3561e97f44d14b47ae07f63ff8312a90');
INSERT INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
VALUES ('b3854469e6f44901b22bb791cb538431', '3561e97f44d14b47ae07f63ff8312a90');
DELETE FROM zhgh_cqgmy.sys_role_menu WHERE menuId = 'f02d5a68bdeb4c63a1b8ce47296d7304';
INSERT INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'f02d5a68bdeb4c63a1b8ce47296d7304');
INSERT INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
VALUES ('b3854469e6f44901b22bb791cb538431', 'f02d5a68bdeb4c63a1b8ce47296d7304');
-- 补充职工慰问父菜单权限。
INSERT IGNORE INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', '3b49290f7db44130b9f6394a8212321f');
INSERT IGNORE INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
VALUES ('494ac71eb8b44d0e99e19b7fde6bd469', '3b49290f7db44130b9f6394a8212321f');
INSERT IGNORE INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
VALUES ('6aece46b946e4fb9ac4cb15c0cd8c3e6', '3b49290f7db44130b9f6394a8212321f');
INSERT IGNORE INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
VALUES ('b3854469e6f44901b22bb791cb538431', '3b49290f7db44130b9f6394a8212321f');
INSERT IGNORE INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
SELECT id, '3b49290f7db44130b9f6394a8212321f' FROM zhgh_cqgmy.sys_role WHERE code = 'BRANCH_UNION_CHAIRMAN' AND disabled = false AND delFlag = false;
INSERT IGNORE INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', '024811fd168e44948e6ab84911fc26bb');
INSERT IGNORE INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
VALUES ('494ac71eb8b44d0e99e19b7fde6bd469', '024811fd168e44948e6ab84911fc26bb');
INSERT IGNORE INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
VALUES ('6aece46b946e4fb9ac4cb15c0cd8c3e6', '024811fd168e44948e6ab84911fc26bb');
INSERT IGNORE INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
VALUES ('b3854469e6f44901b22bb791cb538431', '024811fd168e44948e6ab84911fc26bb');
INSERT IGNORE INTO zhgh_cqgmy.sys_role_menu (roleId, menuId)
SELECT id, '024811fd168e44948e6ab84911fc26bb' FROM zhgh_cqgmy.sys_role WHERE code = 'BRANCH_UNION_CHAIRMAN' AND disabled = false AND delFlag = false;
COMMIT;
@@ -0,0 +1,14 @@
truncate table condolence_type;
INSERT INTO condolence_type (id, code, name, money, way, disabled, isUploadFile, uploadFileDesc, sortNum, createdBy, createdAt, updatedBy, updatedAt, delFlag, isDisabled, location, uploadMeetingRecord, enable) VALUES ('00016fcb72234a8f95cd173665bbe563', 'C3', '生育(三孩)', 5000.00, '慰问金 (A)', false, null, null, 0, '17a7f8ad3ee947b4a26175049a9c253d', 1791539298087, '17a7f8ad3ee947b4a26175049a9c253d', 1791539298087, false, null, 8, false, true);
INSERT INTO condolence_type (id, code, name, money, way, disabled, isUploadFile, uploadFileDesc, sortNum, createdBy, createdAt, updatedBy, updatedAt, delFlag, isDisabled, location, uploadMeetingRecord, enable) VALUES ('3777d15929dd4c66b3aa9ae67dc62528', 'C2', '生育(二孩)', 3000.00, '慰问金 (A)', false, null, null, 0, '17a7f8ad3ee947b4a26175049a9c253d', 1791539214037, '17a7f8ad3ee947b4a26175049a9c253d', 1791539269588, false, null, 7, false, true);
INSERT INTO condolence_type (id, code, name, money, way, disabled, isUploadFile, uploadFileDesc, sortNum, createdBy, createdAt, updatedBy, updatedAt, delFlag, isDisabled, location, uploadMeetingRecord, enable) VALUES ('4cdf253d26044261a1a81359bbde1bb8', 'G', '直系亲属去世', 2000.00, '慰问金 (A)', false, true, '', 0, '17a7f8ad3ee947b4a26175049a9c253d', 1765427298440, '17a7f8ad3ee947b4a26175049a9c253d', 1791539615689, false, null, 2, false, true);
INSERT INTO condolence_type (id, code, name, money, way, disabled, isUploadFile, uploadFileDesc, sortNum, createdBy, createdAt, updatedBy, updatedAt, delFlag, isDisabled, location, uploadMeetingRecord, enable) VALUES ('4e316737e71047e0b01aea78524c1416', 'E', '生病住院', 1000.00, '慰问金 (A)', false, true, '每年累计不超过3000元、每人每年最多申请3次', 1, '29847d7826aa415a92d03b9576d01d3f', 1722084651317, '29847d7826aa415a92d03b9576d01d3f', 1791539558039, false, false, null, false, true);
INSERT INTO condolence_type (id, code, name, money, way, disabled, isUploadFile, uploadFileDesc, sortNum, createdBy, createdAt, updatedBy, updatedAt, delFlag, isDisabled, location, uploadMeetingRecord, enable) VALUES ('5b5f135e523d4cc5b5dc4355ffe9e024', 'B', '生日', 600.00, '慰问金 (A)', false, null, null, 0, '17a7f8ad3ee947b4a26175049a9c253d', 1791539148841, '17a7f8ad3ee947b4a26175049a9c253d', 1791539148841, false, null, 6, false, true);
INSERT INTO condolence_type (id, code, name, money, way, disabled, isUploadFile, uploadFileDesc, sortNum, createdBy, createdAt, updatedBy, updatedAt, delFlag, isDisabled, location, uploadMeetingRecord, enable) VALUES ('60f03a87b62b4823855814afdbf5672a', 'F', '会员去世', 3000.00, '慰问金 (A)', false, true, '需要提供购买的慰问品发票,慰问照片', 0, '17a7f8ad3ee947b4a26175049a9c253d', 1765427276486, '17a7f8ad3ee947b4a26175049a9c253d', 1791539570707, false, null, 1, false, true);
INSERT INTO condolence_type (id, code, name, money, way, disabled, isUploadFile, uploadFileDesc, sortNum, createdBy, createdAt, updatedBy, updatedAt, delFlag, isDisabled, location, uploadMeetingRecord, enable) VALUES ('b8ee30faacb2469593dfedb5b52565aa', 'D', '光荣退休', 2000.00, '慰问金 (A)', false, true, '需要提供购买的慰问品发票,慰问照片', 0, '17a7f8ad3ee947b4a26175049a9c253d', 1765427383848, '17a7f8ad3ee947b4a26175049a9c253d', 1791539529423, false, null, 3, false, true);
INSERT INTO condolence_type (id, code, name, money, way, disabled, isUploadFile, uploadFileDesc, sortNum, createdBy, createdAt, updatedBy, updatedAt, delFlag, isDisabled, location, uploadMeetingRecord, enable) VALUES ('c90cb10dce6542e99ae271bee6fe8cc0', 'C1', '生育(一孩)', 2000.00, '慰问金 (A)', false, true, '需要提供购买的慰问品发票,慰问照片(会员与婴儿合影照,或慰问人员与会员和婴儿的合影照)', 0, '17a7f8ad3ee947b4a26175049a9c253d', 1765427542820, '17a7f8ad3ee947b4a26175049a9c253d', 1791539263656, false, null, 5, false, true);
INSERT INTO condolence_type (id, code, name, money, way, disabled, isUploadFile, uploadFileDesc, sortNum, createdBy, createdAt, updatedBy, updatedAt, delFlag, isDisabled, location, uploadMeetingRecord, enable) VALUES ('cf4ce7af322d464f8f86d818fcc00203', 'A', '结婚', 1000.00, '慰问金 (A)', false, true, '需要提供购买的慰问品的发票、慰问照片、会员的结婚证复印件', 2, '29847d7826aa415a92d03b9576d01d3f', 1722084861840, '29847d7826aa415a92d03b9576d01d3f', 1768875320985, false, false, null, false, true);
@@ -0,0 +1,92 @@
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+89 -9
View File
@@ -1,17 +1,85 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<configuration scan="false" scanPeriod="60000" debug="false"> <configuration scan="false" scanPeriod="60000" debug="false">
<!-- 定义日志文件的存储路径 --> <!-- 日志存储路径 -->
<property name="LOG_HOME" value="./logs"/> <property name="LOG_HOME" value="./logs"/>
<!-- 控制台输出配置 --> <!-- 控制台输出 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder> <encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder> </encoder>
</appender> </appender>
<!-- INFO级别日志文件配置 -->
<!--
#################################################################################
# #
# 全文输出日志(一个文件存储)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"> <appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/info.log</file> <file>${LOG_HOME}/info.log</file>
<encoder> <encoder>
@@ -24,11 +92,11 @@
</filter> </filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/info-%d{yyyy-MM-dd}.log</fileNamePattern> <fileNamePattern>${LOG_HOME}/info-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory> <maxHistory>300</maxHistory>
</rollingPolicy> </rollingPolicy>
</appender> </appender>
<!-- ERROR级别日志文件配置 --> &lt;!&ndash; ERROR级别日志文件配置 &ndash;&gt;
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/error.log</file> <file>${LOG_HOME}/error.log</file>
<encoder> <encoder>
@@ -41,19 +109,31 @@
</filter> </filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/error-%d{yyyy-MM-dd}.log</fileNamePattern> <fileNamePattern>${LOG_HOME}/error-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory> <maxHistory>300</maxHistory>
</rollingPolicy> </rollingPolicy>
</appender> </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.eclipse.jetty" level="INFO"/>
<logger name="org.quartz" level="INFO"/> <logger name="org.quartz" level="INFO"/>
<logger name="org.nutz" level="DEBUG"/> <logger name="org.nutz" level="DEBUG"/>
<!-- 日志级别和appender的关联 --> &lt;!&ndash; 日志级别和appender的关联 &ndash;&gt;
<root level="DEBUG"> <root level="DEBUG">
<appender-ref ref="STDOUT"/> <appender-ref ref="STDOUT"/>
<appender-ref ref="DEBUG_FILE"/>
<appender-ref ref="INFO_FILE"/> <appender-ref ref="INFO_FILE"/>
<appender-ref ref="ERROR_FILE"/> <appender-ref ref="ERROR_FILE"/>
</root> </root>-->
<!--
#################################################################################
# #
# debug、info、error分文件输出 end #
# #
#################################################################################
-->
</configuration> </configuration>
@@ -0,0 +1 @@
7pKr23u2tZHcvXud
File diff suppressed because one or more lines are too long
@@ -380,7 +380,7 @@
</el-form> </el-form>
<span slot="footer" class="dialog-footer"> <span slot="footer" class="dialog-footer">
<el-button @click="setDialogVisible = false">取消</el-button> <el-button @click="setDialogVisible = false">取消</el-button>
<el-button type="primary" @click="doSetActivityUser" v-loading="settingLoading">确定</el-button> <el-button type="primary" :disabled="settingLoading" @click="doSetActivityUser">确定</el-button>
</span> </span>
</el-dialog> </el-dialog>
@@ -646,7 +646,7 @@ module.exports = {
doSearch(showFullLoading = false) { doSearch(showFullLoading = false) {
this.tableKey = new Date().getTime() this.tableKey = new Date().getTime()
this.pageForm.pageNumber = 1 this.pageForm.pageNumber = 1
this.pageData(showFullLoading === true) return this.pageData(showFullLoading === true)
}, },
async pageData(showFullLoading = false) { async pageData(showFullLoading = false) {
this.tableLoading = true this.tableLoading = true
@@ -679,8 +679,13 @@ module.exports = {
} }
} }
}, },
async doSetActivityUser() { doSetActivityUser() {
const valid = await this.$refs.setForm.validate() // 方法级状态保护与整页遮罩共同阻止慢请求期间的重复确认。
if (this.settingLoading) {
return
}
this.$refs.setForm.validate((valid) => {
if (!valid) { if (!valid) {
return return
} }
@@ -690,32 +695,58 @@ module.exports = {
return return
} }
const confirm = await this.$confirm("是否将符合搜索条件的用户设为活动人员?", "提示", { this.$confirm("是否将符合搜索条件的用户设为活动人员?", "提示", {
confirmButtonText: "确定", confirmButtonText: "确定",
cancelButtonText: "取消", cancelButtonText: "取消",
type: "warning" type: "warning"
}) }).then(() => {
if (this.settingLoading) {
return
}
if (confirm === "confirm") {
this.settingLoading = true this.settingLoading = true
const loading = this.$loading({
lock: true,
text: "正在设置活动人员,请稍候...",
spinner: "el-icon-loading",
background: "rgba(255, 255, 255, 0.65)"
})
const pageForm = this.normalizePageForm(this.pageForm) const pageForm = this.normalizePageForm(this.pageForm)
Object.assign(pageForm, this.formData) Object.assign(pageForm, this.formData)
const resp = await $.post("/platform/activity/basic/scope/doSetActivityUser", {
$.post("/platform/activity/basic/scope/doSetActivityUser", {
data: JSON.stringify(pageForm) data: JSON.stringify(pageForm)
}) }).then((resp) => {
if (resp.code === 0) { if (resp.code !== 0) {
// 将后端业务错误转入统一失败分支,直接展示接口返回的真实原因。
throw new Error(resp.msg || "设置活动人员失败")
}
this.setDialogVisible = false this.setDialogVisible = false
await this.getActivityGroup() // 设置成功后等待分组和人员列表刷新完成,再解除整页操作锁定。
this.doSearch() return this.getActivityGroup()
.then(() => this.doSearch())
.then(() => {
this.$message.success(resp.msg) this.$message.success(resp.msg)
this.formData = this.getDefaultFormData() this.formData = this.getDefaultFormData()
} else {
this.$message.error(resp.msg)
}
this.settingLoading = false
this.$emit("update:group_id", resp.data) this.$emit("update:group_id", resp.data)
this.$emit("group_change") this.$emit("group_change")
} })
}).then(null, (error) => {
// 兼容业务错误、权限异常和网络异常,避免请求失败后页面持续加载。
const response = error && error.responseJSON ? error.responseJSON : null
const errorMessage = response && response.msg
? response.msg
: error && error.message
? error.message
: "设置活动人员失败"
this.$message.error(errorMessage)
}).always(() => {
this.settingLoading = false
loading.close()
})
})
})
}, },
async getActivityGroup() { async getActivityGroup() {
const resp = await $.get("/platform/activity/basic/scope/getActivityUserScopeGroup") const resp = await $.get("/platform/activity/basic/scope/getActivityUserScopeGroup")
@@ -1,6 +1,6 @@
<template> <template>
<div class="signature-wrap"> <div class="signature-wrap">
<canvas id="canvas"></canvas> <canvas ref="canvas" @touchstart.stop @touchmove.stop></canvas>
<div :class="isLikelyMobile() ? 'action-buttons' : 'action-buttons pc-action-buttons'"> <div :class="isLikelyMobile() ? 'action-buttons' : 'action-buttons pc-action-buttons'">
<button @click="clear" class="action-button-danger" type="button">清空</button> <button @click="clear" class="action-button-danger" type="button">清空</button>
<button @click="undo" class="action-button-warning" type="button">撤销</button> <button @click="undo" class="action-button-warning" type="button">撤销</button>
@@ -30,23 +30,23 @@
// window.addEventListener("orientationchange", a) // window.addEventListener("orientationchange", a)
// a() // a()
let signature = null
module.exports = { module.exports = {
name: "sysSignature", name: "sysSignature",
data() { data() {
return { return {
content: null content: null,
signature: null
} }
}, },
methods: { methods: {
clear() { clear() {
signature.clear() this.signature.clear()
}, },
undo() { undo() {
signature.undo() this.signature.undo()
}, },
rotate() { rotate() {
signature.getRotateCanvas(90) this.signature.getRotateCanvas(90)
}, },
isLikelyMobile() { isLikelyMobile() {
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0; const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
@@ -56,7 +56,7 @@ module.exports = {
return hasMobileUA || (isTouchDevice && isSmallScreen); return hasMobileUA || (isTouchDevice && isSmallScreen);
}, },
save() { save() {
if (signature.isEmpty()) { if (this.signature.isEmpty()) {
// H5 页面使用统一的 Vant 提示;未加载 Vant 的独立签字页保留原生提示作为兼容兜底。 // H5 页面使用统一的 Vant 提示;未加载 Vant 的独立签字页保留原生提示作为兼容兜底。
if (typeof this.$toast === "function") { if (typeof this.$toast === "function") {
this.$toast({ this.$toast({
@@ -71,11 +71,11 @@ module.exports = {
} }
// const png = signature.getPNG() // const png = signature.getPNG()
//旋转一下 横过来 //旋转一下 横过来
const rotateCanvas = signature.getRotateCanvas(-90) const rotateCanvas = this.signature.getRotateCanvas(-90)
if (this.isLikelyMobile()) { if (this.isLikelyMobile()) {
this.$emit("save", rotateCanvas.toDataURL()) this.$emit("save", rotateCanvas.toDataURL())
} else { } else {
this.$emit("save", signature.toDataURL()) this.$emit("save", this.signature.toDataURL())
} }
} }
}, },
@@ -83,10 +83,10 @@ module.exports = {
const innerWidth = window.innerWidth const innerWidth = window.innerWidth
const innerHeight = window.innerHeight const innerHeight = window.innerHeight
const canvas = document.getElementById("canvas") const canvas = this.$refs.canvas
canvas.width = window.innerWidth canvas.width = window.innerWidth
canvas.height = window.innerHeight canvas.height = window.innerHeight
signature = new SmoothSignature(canvas, { this.signature = new SmoothSignature(canvas, {
width: innerWidth - 30, width: innerWidth - 30,
height: innerHeight - 30, height: innerHeight - 30,
scale: 2, scale: 2,
@@ -95,6 +95,15 @@ module.exports = {
color: "#000000" color: "#000000"
// bgColor: "#f6f6f6" // bgColor: "#f6f6f6"
}) })
canvas.addEventListener("touchstart", this.signature.onDrawStart, { passive: false })
canvas.addEventListener("touchmove", this.signature.onDrawMove, { passive: false })
document.addEventListener("touchend", this.signature.onDrawEnd, { passive: false })
document.addEventListener("touchcancel", this.signature.onDrawEnd, { passive: false })
},
beforeDestroy() {
if (this.signature) {
this.signature.removeListener()
}
} }
} }
</script> </script>
@@ -35,22 +35,59 @@ module.exports = {
}, },
data() { data() {
return { return {
//前端唯一标识 当然后端还有用户id作为唯一标识 // 二维码携带本次组件实例的标识,与服务端当前登录用户共同确定签字记录。
id: new Date().getTime() + Math.floor(Math.random() * 10000) + 1, id: new Date().getTime() + Math.floor(Math.random() * 10000) + 1,
signatureAddress: null, signatureAddress: null,
interval: null, interval: null,
connectionWaitResolve: null,
unsubscribeSignature: null,
signatureContent: null, signatureContent: null,
loading: true, loading: true,
} }
}, },
methods: { methods: {
async init() { /**
const {code, data} = await this.$axios.post("/platform/signature/get") * 无请求参数,返回 Promise;响应 code 为0表示成功,data.signature 为已有签字图片地址。
if (code === 0) { * 请求结束后由 mounted 的 finally 统一关闭 loading。
this.signatureContent = data && data.signature */
this.$emit("input", this.signatureContent) init() {
}
this.signatureAddress = origin + "/platform/signature/scanPcCode?id=" + this.id this.signatureAddress = origin + "/platform/signature/scanPcCode?id=" + this.id
console.log(this.signatureAddress)
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() { signatureAgain() {
const val = sessionStorage.getItem("h5-scan-code-signature-" + this.id) const val = sessionStorage.getItem("h5-scan-code-signature-" + this.id)
@@ -58,25 +95,42 @@ module.exports = {
this.$emit("input", val) this.$emit("input", val)
} }
}, },
async mounted() { mounted() {
await this.init() this.loading = true
webSocketPubSub.subscribe("h5-scan-code-signature", (data) => { // 先登记消息回调,连接成功后由公共连接加入房间,避免请求期间遗漏订阅。
console.info("ws:收到签字成功消息:", data) this.unsubscribeSignature = webSocketPubSub.subscribe("h5-scan-code-signature", (data) => {
this.signatureContent = data.value this.signatureContent = data.value
this.$emit("input", data.value) this.$emit("input", data.value)
}) })
this.init()
// 可选:轮询或 watch ready 状态 .then(() => {
const checkReady = () => { if (!this._isBeingDestroyed && !this._isDestroyed) {
if (webSocketPubSub.ws && webSocketPubSub.ws.readyState === WebSocket.OPEN) { return this.waitForConnection()
console.info("ws.readyState is open"); }
this.loading = false; })
console.log(this.signatureAddress) .catch(() => {
} else { if (!this._isBeingDestroyed && !this._isDestroyed) {
setTimeout(checkReady, 100); 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> </script>
@@ -9,6 +9,7 @@
progress progress
:accept="accept" :accept="accept"
:max-count="upload_number" :max-count="upload_number"
:upload-text="upload_text"
title="" title=""
description="" description=""
tip="" tip=""
@@ -50,6 +51,11 @@ module.exports = {
default: 1, default: 1,
required: false required: false
}, },
upload_text: {
type: String,
default: "",
required: false
},
// 上传返回id或url // 上传返回id或url
upload_result_type: { upload_result_type: {
type: String, type: String,
@@ -101,6 +107,7 @@ module.exports = {
this.fileList = val.map((v) => { this.fileList = val.map((v) => {
return { return {
...v, ...v,
file: v.file || { name: v.name },
status: null, status: null,
isImage: ["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(v.name.split(".").pop().toLowerCase()) isImage: ["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(v.name.split(".").pop().toLowerCase())
} }
@@ -118,6 +125,7 @@ module.exports = {
this.fileList = val.map((v) => { this.fileList = val.map((v) => {
return { return {
...v, ...v,
file: v.file || { name: v.name },
url: v.url ? v.url : v.response?.data, url: v.url ? v.url : v.response?.data,
isImage: ["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(v.name.split(".").pop().toLowerCase()), isImage: ["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(v.name.split(".").pop().toLowerCase()),
status: null status: null
@@ -171,8 +179,8 @@ module.exports = {
f.url = resp.data f.url = resp.data
f.response = resp f.response = resp
f.percentage = 100 f.percentage = 100
f.isImage = true f.isImage = ["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(f.name.split(".").pop().toLowerCase())
delete f.file f.file = { name: f.name }
delete f.content delete f.content
} else { } else {
f.status = "fail" f.status = "fail"
+114 -58
View File
@@ -443,50 +443,76 @@
constructor() { constructor() {
this.ws = null this.ws = null
this.subscribers = new Map() this.subscribers = new Map()
this.isSubscribed = false // 添加订阅状态标记 this.isSubscribed = false
this.connectTimer = null
this.reconnectTimer = null
this.pingTimer = null
this.isDisposed = false
this.init() this.init()
} }
// 页面只创建一个心跳任务;每次连接自行绑定事件及握手超时。
init() { init() {
try {
this.connect()
this.setupEventListeners()
this.setupPing()
this.setupWindowEvents() this.setupWindowEvents()
} catch (ex) { this.setupPing()
console.info(ex) this.connect()
}
} }
// 正在连接或关闭时等待对应事件,避免同时创建多个连接。
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 = window.location.host + "${base}/websocket"
const protocol = window.location.protocol === "http:" ? "ws://" : "wss://" const protocol = window.location.protocol === "http:" ? "ws://" : "wss://"
this.ws = new WebSocket(protocol + WS_URL) 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) { subscribe(type, callback) {
if (!this.subscribers.has(type)) { if (!this.subscribers.has(type)) {
this.subscribers.set(type, new Set()) this.subscribers.set(type, new Set())
} }
this.subscribers.get(type).add(callback) const callbacks = this.subscribers.get(type)
callbacks.add(callback)
setTimeout(() => {
// 所有订阅完成后,发送 join 消息
if (!this.isSubscribed && this.ws.readyState === WebSocket.OPEN) {
this.isSubscribed = true
this.sendJoinMessage() this.sendJoinMessage()
return () => {
callbacks.delete(callback)
if (callbacks.size === 0 && this.subscribers.get(type) === callbacks) {
this.subscribers.delete(type)
}
} }
}, 2000)
} }
// 事件处理 // 事件绑定到本次连接,旧连接的延迟事件不得影响新连接。
setupEventListeners() { setupEventListeners() {
// 消息处理 const socket = this.ws
this.ws.onmessage = (event) => { socket.onmessage = (event) => {
if (this.isDisposed || this.ws !== socket) {
return
}
try { try {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
// 触发订阅者的回调
if (this.subscribers.has(data.action)) { if (this.subscribers.has(data.action)) {
this.subscribers.get(data.action).forEach((callback) => { this.subscribers.get(data.action).forEach((callback) => {
try { try {
@@ -500,82 +526,112 @@
console.error("消息解析错误:", error) console.error("消息解析错误:", error)
} }
} }
socket.onopen = () => {
// 连接建立 if (this.isDisposed || this.ws !== socket) {
this.ws.onopen = () => { return
// this.ws.send( }
// JSON.stringify({ clearTimeout(this.connectTimer)
// room: store.state.room, this.connectTimer = null
// action: "join" this.sendJoinMessage()
// })
// )
// 连接建立时不立即发送 join,等待订阅完成
this.ping() 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() { 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({ this.send({
room: store.state.room, room: store.state.room,
action: "join" action: "join"
}) })
this.isSubscribed = true
} }
// 心跳检测 // 沿用原心跳间隔,仅对已建立的连接发送心跳。
setupPing() { setupPing() {
setInterval(() => this.ping(), (11624317 / 1000) * 2) this.pingTimer = setInterval(() => this.ping(), (11624317 / 1000) * 2)
} }
// CONNECTING/CLOSING 状态分别由握手超时和关闭事件处理。
ping() { ping() {
if (this.isDisposed) {
return
}
if (this.ws && this.ws.readyState === WebSocket.OPEN) { if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try { try {
this.ws.send("{}") this.ws.send("{}")
} catch (e) { } catch (error) {
this.reconnect() console.warn("WebSocket 心跳发送失败,等待关闭后重连", error)
this.ws.close()
} }
} else { } else if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
this.reconnect() this.reconnect()
} }
} }
// 关闭事件和心跳共用一个重连任务,避免重复定时器创建并行连接。
reconnect() { reconnect() {
if (this.ws) { if (this.isDisposed || this.reconnectTimer !== null ||
this.ws.close() // 确保旧连接已关闭 (this.ws && this.ws.readyState !== WebSocket.CLOSED)) {
return
} }
setTimeout(() => { this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.connect() this.connect()
this.setupEventListeners() }, 3000)
// 如果之前已订阅,重新发送join消息
if (this.isSubscribed) {
setTimeout(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.sendJoinMessage()
}
}, 1000)
}
}, 3000) // 延迟3秒重连
} }
// 窗口事件处理 // 真正离开页面时释放连接和定时器;进入往返缓存时保留页面状态。
setupWindowEvents() { setupWindowEvents() {
let _beforeUnload_time = 0 window.addEventListener("pagehide", (event) => {
window.onbeforeunload = () => { if (event.persisted) {
_beforeUnload_time = new Date().getTime() return
} }
window.onunload = () => { this.isDisposed = true
const _gap_time = new Date().getTime() - _beforeUnload_time clearTimeout(this.connectTimer)
if (_gap_time <= 5) { 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({ this.send({
room: "${@auth.getPrincipalProperty('loginname')}:${@auth.getSessionId()}", room: store.state.room,
action: "left" action: "left"
}) })
} catch (error) {
console.warn("WebSocket 离开房间消息发送失败", error)
} }
} }
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
this.ws.close()
}
})
} }
// message 为消息对象或 JSON 字符串;连接未建立时不发送,返回值为 undefined。
send(message) { send(message) {
if (this.ws) { if (this.ws && this.ws.readyState === WebSocket.OPEN) {
const messageStr = typeof message === "string" ? message : JSON.stringify(message) const messageStr = typeof message === "string" ? message : JSON.stringify(message)
this.ws.send(messageStr) this.ws.send(messageStr)
} }
@@ -68,13 +68,14 @@
<link rel="stylesheet" href="${base!}/assets/platform/plugins/vxe/vxe-pc-ui.css" /> <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" /> <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> <!-- 使用随应用打包的本地资源,按工具库、UI、表格的依赖顺序加载,避免外链失效影响页面。 -->
<script src="https://vxeui.com/umd/vxe-pc-ui@3.1.25/lib/index.umd.min.js"></script> <script src="${base!}/assets/platform/plugins/vxe/xe-utils.js"></script>
<script src="https://vxeui.com/umd/vxe-table@3.9.0/lib/index.umd.min.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 --> <!-- 引入 form-create 和 designer -->
<script src="${base!}/assets/platform/plugins/form-create/form-create.min.js"></script> <script src="${base!}/assets/platform/plugins/form-create/form-create.min.js"></script>
<script src="${base!}/assets/platform/plugins/form-create/index.umd.js"></script> <script src="${base!}/assets/platform/plugins/form-create/index.umd.js?v=20261030"></script>
<!-- swiper --> <!-- swiper -->
<script src="${base!}/assets/platform/plugins/swiper/swiper-bundle.js"></script> <script src="${base!}/assets/platform/plugins/swiper/swiper-bundle.js"></script>
@@ -153,51 +154,76 @@
constructor() { constructor() {
this.ws = null this.ws = null
this.subscribers = new Map() this.subscribers = new Map()
this.isSubscribed = false // 添加订阅状态标记 this.isSubscribed = false
this.connectTimer = null
this.reconnectTimer = null
this.pingTimer = null
this.isDisposed = false
this.init() this.init()
} }
// 页面只创建一个心跳任务;每次连接自行绑定事件及握手超时。
init() { init() {
try {
this.connect()
this.setupEventListeners()
this.setupPing()
this.setupWindowEvents() this.setupWindowEvents()
} catch (ex) { this.setupPing()
console.info(ex) this.connect()
}
} }
// 正在连接或关闭时等待对应事件,避免同时创建多个连接。
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 = window.location.host + "${base}/websocket"
// const WS_URL = 'zhgh.jshvc.edu.cn' + "${base}/websocket"
const protocol = window.location.protocol === "http:" ? "ws://" : "wss://" const protocol = window.location.protocol === "http:" ? "ws://" : "wss://"
this.ws = new WebSocket(protocol + WS_URL) 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) { subscribe(type, callback) {
if (!this.subscribers.has(type)) { if (!this.subscribers.has(type)) {
this.subscribers.set(type, new Set()) this.subscribers.set(type, new Set())
} }
this.subscribers.get(type).add(callback) const callbacks = this.subscribers.get(type)
callbacks.add(callback)
setTimeout(() => {
// 所有订阅完成后,发送 join 消息
if (!this.isSubscribed && this.ws.readyState === WebSocket.OPEN) {
this.isSubscribed = true
this.sendJoinMessage() this.sendJoinMessage()
return () => {
callbacks.delete(callback)
if (callbacks.size === 0 && this.subscribers.get(type) === callbacks) {
this.subscribers.delete(type)
}
} }
}, 1000)
} }
// 事件处理 // 事件绑定到本次连接,旧连接的延迟事件不得影响新连接。
setupEventListeners() { setupEventListeners() {
// 消息处理 const socket = this.ws
this.ws.onmessage = (event) => { socket.onmessage = (event) => {
if (this.isDisposed || this.ws !== socket) {
return
}
try { try {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
// 触发订阅者的回调
if (this.subscribers.has(data.action)) { if (this.subscribers.has(data.action)) {
this.subscribers.get(data.action).forEach((callback) => { this.subscribers.get(data.action).forEach((callback) => {
try { try {
@@ -211,83 +237,112 @@
console.error("消息解析错误:", error) console.error("消息解析错误:", error)
} }
} }
socket.onopen = () => {
// 连接建立 if (this.isDisposed || this.ws !== socket) {
this.ws.onopen = () => { return
// this.ws.send( }
// JSON.stringify({ clearTimeout(this.connectTimer)
// room: store.state.room, this.connectTimer = null
// action: "join" this.sendJoinMessage()
// })
// )
// 连接建立时不立即发送 join,等待订阅完成
this.ping() 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() { 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({ this.send({
room: store.state.room, room: store.state.room,
action: "join" action: "join"
}) })
this.isSubscribed = true
} }
// 心跳检测 // 沿用原心跳间隔,仅对已建立的连接发送心跳。
setupPing() { setupPing() {
setInterval(() => this.ping(), (11624317 / 1000) * 2) this.pingTimer = setInterval(() => this.ping(), (11624317 / 1000) * 2)
} }
// CONNECTING/CLOSING 状态分别由握手超时和关闭事件处理。
ping() { ping() {
if (this.isDisposed) {
return
}
if (this.ws && this.ws.readyState === WebSocket.OPEN) { if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try { try {
this.ws.send("{}") this.ws.send("{}")
} catch (e) { } catch (error) {
this.reconnect() console.warn("WebSocket 心跳发送失败,等待关闭后重连", error)
this.ws.close()
} }
} else { } else if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
this.reconnect() this.reconnect()
} }
} }
// 关闭事件和心跳共用一个重连任务,避免重复定时器创建并行连接。
reconnect() { reconnect() {
if (this.ws) { if (this.isDisposed || this.reconnectTimer !== null ||
this.ws.close() // 确保旧连接已关闭 (this.ws && this.ws.readyState !== WebSocket.CLOSED)) {
return
} }
setTimeout(() => { this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.connect() this.connect()
this.setupEventListeners() }, 1000)
// 如果之前已订阅,重新发送join消息
if (this.isSubscribed) {
setTimeout(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.sendJoinMessage()
}
}, 500)
}
}, 1000) // 延迟2秒重连
} }
// 窗口事件处理 // 真正离开页面时释放连接和定时器;进入往返缓存时保留页面状态。
setupWindowEvents() { setupWindowEvents() {
let _beforeUnload_time = 0 window.addEventListener("pagehide", (event) => {
window.onbeforeunload = () => { if (event.persisted) {
_beforeUnload_time = new Date().getTime() return
} }
window.onunload = () => { this.isDisposed = true
const _gap_time = new Date().getTime() - _beforeUnload_time clearTimeout(this.connectTimer)
if (_gap_time <= 5) { 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({ this.send({
room: "${@auth.getPrincipalProperty('loginname')}:${@auth.getSessionId()}", room: store.state.room,
action: "left" action: "left"
}) })
} catch (error) {
console.warn("WebSocket 离开房间消息发送失败", error)
} }
} }
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
this.ws.close()
}
})
} }
// message 为消息对象或 JSON 字符串;连接未建立时不发送,返回值为 undefined。
send(message) { send(message) {
if (this.ws) { if (this.ws && this.ws.readyState === WebSocket.OPEN) {
const messageStr = typeof message === "string" ? message : JSON.stringify(message) const messageStr = typeof message === "string" ? message : JSON.stringify(message)
this.ws.send(messageStr) this.ws.send(messageStr)
} }
@@ -481,7 +536,8 @@
align-items: center; align-items: center;
gap: 24px; gap: 24px;
height: 100%; height: 100%;
max-width: 310px; /* 用户区按完整姓名、账号及操作按钮的实际宽度布局,避免退出按钮溢出后被顶栏裁切。 */
width: max-content;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -617,6 +673,26 @@
height: 100%; 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) { @media (max-width: 1024px) {
.v4-nav { .v4-nav {
gap: 24px; gap: 24px;
@@ -139,7 +139,7 @@ const act = {
} }
if (act.url === "/platform/activity/apply" if (act.url === "/platform/activity/apply"
&& !this.$auth.hasRoleOr(["SYSADMIN", "SCHOOL_UNION_ADMIN", "BRANCH_UNION_CHAIRMAN", "BRANCH_UNION_WENTI_SPORTS"])) { && !this.$auth.hasRoleOr(["SYSADMIN", "SCHOOL_UNION_ADMIN", "BRANCH_UNION_CHAIRMAN", "BRANCH_UNION_WENTI_SPORTS"])) {
return this.$message.warning("PC端暂未开放报名,请前往移动端报名!") return this.$message.warning("温馨提示:您没有参加该活动的权限!")
} }
window.open(act.url) window.open(act.url)
@@ -116,6 +116,69 @@ layout("/layouts/v4/baseLayout.html"){
object-fit: contain; object-fit: contain;
} }
/* 最新活动的作用域样式会在组件挂载后注入,首屏先固定布局和图片尺寸,避免刷新时放大闪烁。 */
#v4-home-app .act-card .activity-section-wrapper,
#v4-home-app .act-card .section-act,
#v4-home-app .act-card .activity-swiper {
width: 100%;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
overflow: hidden;
}
#v4-home-app .act-card .activity-swiper-container {
position: relative;
width: 100%;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
padding: 9px 54px 12px;
overflow: hidden;
}
#v4-home-app .act-card .activity-swiper .swiper-slide {
width: 100%;
height: auto;
min-width: 0;
flex-shrink: 0;
margin-right: 18px;
}
#v4-home-app .act-card .activity-swiper .img-box {
width: 100%;
height: 128px;
border-radius: 8px;
overflow: hidden;
}
#v4-home-app .act-card .activity-swiper .img-box img {
display: block;
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 128px !important;
object-fit: fill !important;
}
@media (min-width: 768px) {
#v4-home-app .act-card .activity-swiper .swiper-slide {
width: calc((100% - 18px) / 2);
}
}
@media (min-width: 1024px) {
#v4-home-app .act-card .activity-swiper .swiper-slide {
width: calc((100% - 54px) / 4);
}
}
@media (min-width: 1200px) {
#v4-home-app .act-card .activity-swiper .swiper-slide {
width: calc((100% - 72px) / 5);
}
}
</style> </style>
<div class="v4-container" id="v4-home-app"> <div class="v4-container" id="v4-home-app">
@@ -221,8 +221,7 @@ layout("/layouts/platform.html"){
placeholder="请输入姓名" filterable clearable placeholder="请输入姓名" filterable clearable
style="width: 100%" size="small" v-if="awardsMode==1"> style="width: 100%" size="small" v-if="awardsMode==1">
<el-option <el-option
v-for="item in userList" v-for="item in getSelectableUserList(row)"
:disabled="item.disabled"
:key="item.id" :key="item.id"
:label="item.username+'('+item.loginname+')'+item.sex" :label="item.username+'('+item.loginname+')'+item.sex"
:value="item.id"> :value="item.id">
@@ -721,10 +720,15 @@ layout("/layouts/platform.html"){
} }
return score; return score;
}, },
getSelectableUserList(row) {
// 其他行已经选择的人员不再显示,当前行人员保留用于姓名正常回显。
const selectedUserIds = this.userData
.map(item => item.userId)
.filter(userId => userId)
return this.userList.filter(item => item.id === row.userId || !selectedUserIds.includes(item.id))
},
userDetailsChange(val, row) { userDetailsChange(val, row) {
const useridArr = this.userData.map(v => v.userId)
this.userList.forEach(v => { this.userList.forEach(v => {
v.disabled = useridArr.includes(v.id)
if (row && row.userId === v.id) { if (row && row.userId === v.id) {
const o = { const o = {
unionId: v.unionId, unionId: v.unionId,
@@ -601,6 +601,8 @@
eventForm: { eventForm: {
isLeader: false isLeader: false
}, },
// 独立记录当前编辑项目,避免下拉框切换后丢失切换前项目标识。
currentEventId: "",
eventsIds: [], eventsIds: [],
rightChecked: [], rightChecked: [],
checkedEventInfo: [], checkedEventInfo: [],
@@ -778,8 +780,10 @@
this.checkedEventInfo = this.checkedEvents.map((v) => { this.checkedEventInfo = this.checkedEvents.map((v) => {
v.eventId = v.id v.eventId = v.id
if (v.projectType === "1") { if (v.projectType === "1") {
v.athletesMaxNum = 3 // “所有单项”保存默认值,具体单项以0表示尚未个性化设置。
v.restrictRegNumber = 2 const isAllItems = v.eventId === "allItems"
v.athletesMaxNum = isAllItems ? 3 : 0
v.restrictRegNumber = isAllItems ? 2 : 0
v.leanderNum = 0 v.leanderNum = 0
v.coachNum = 0 v.coachNum = 0
v.applyUserModel = 1 v.applyUserModel = 1
@@ -791,7 +795,23 @@
return v return v
}) })
}, },
cacheCurrentEventForm() {
if (!this.currentEventId || !this.eventForm) {
return
}
const current = clone(this.eventForm)
current.checkedEventId = this.currentEventId
current.eventId = this.currentEventId
const index = this.checkedEventInfo.findIndex((item) => item.eventId === this.currentEventId)
if (index >= 0) {
this.checkedEventInfo.splice(index, 1, current)
} else {
this.checkedEventInfo.push(current)
}
},
async eventChange(val) { async eventChange(val) {
this.cacheCurrentEventForm()
const data = this.checkedEventInfo.find((v) => v.eventId === val) const data = this.checkedEventInfo.find((v) => v.eventId === val)
const resp = await $.get("/platform/activity/sports/common/getEventData", { id: val }) const resp = await $.get("/platform/activity/sports/common/getEventData", { id: val })
await this.$nextTick() await this.$nextTick()
@@ -804,10 +824,10 @@
projectType: resp.data.projectType, projectType: resp.data.projectType,
leanderNum: 0, leanderNum: 0,
coachNum: 0, coachNum: 0,
athletesMaxNum: resp.data.projectType === 1 ? 3 : null, athletesMaxNum: resp.data.projectType === 1 ? 0 : null,
restrictTotalTeam: 1, restrictTotalTeam: 1,
substituteNum: 0, substituteNum: 0,
restrictRegNumber: resp.data.projectType === 1 ? 2 : null, restrictRegNumber: resp.data.projectType === 1 ? 0 : null,
isLeader: false, isLeader: false,
applyUserModel: 1 applyUserModel: 1
} }
@@ -815,33 +835,27 @@
this.projectType = resp.data.projectType this.projectType = resp.data.projectType
} }
this.$set(this.eventForm, "checkedEventId", val) this.$set(this.eventForm, "checkedEventId", val)
this.currentEventId = val
}, },
async saveEvent() { saveEvent() {
const data = clone(this.eventForm) const data = clone(this.eventForm)
if (data.checkedEventId === "allItems") { if (data.checkedEventId === "allItems") {
this.checkedEventInfo.forEach((v) => { const allItemsIndex = this.checkedEventInfo.findIndex((v) => v.eventId === "allItems")
if (v.projectType === "1") { if (allItemsIndex >= 0) {
v.applyUserModel = this.eventForm.applyUserModel this.checkedEventInfo.splice(allItemsIndex, 1, data)
v.athletesMaxNum = data.athletesMaxNum
v.restrictRegNumber = data.restrictRegNumber
v.unionLimit = data.unionLimit
} }
})
} else { } else {
const info = this.checkedEventInfo.find((v) => v.eventId === data.checkedEventId) const info = this.checkedEventInfo.find((v) => v.eventId === data.checkedEventId)
if (info != null) { if (info != null) {
const index = this.checkedEventInfo.findIndex((v) => v.eventId === data.checkedEventId) const index = this.checkedEventInfo.findIndex((v) => v.eventId === data.checkedEventId)
this.checkedEventInfo.splice(index, 1) this.checkedEventInfo.splice(index, 1)
if (info.projectType === "1") {
this.$set(this.eventForm, "athletesMaxNum", data.athletesMaxNum)
this.$set(this.eventForm, "restrictRegNumber", data.restrictRegNumber)
}
this.$set(this.eventForm, "checkedEventId", info.eventId)
} }
data.eventId = data.checkedEventId data.eventId = data.checkedEventId
this.checkedEventInfo.push(data) this.checkedEventInfo.push(data)
} }
await this.doSave() this.eventForm = clone(data)
this.currentEventId = data.checkedEventId
return this.doSave()
}, },
prev() { prev() {
this.active-- this.active--
@@ -949,6 +963,7 @@
this.copyTemplateMode = false this.copyTemplateMode = false
this.copyTemplateName = "" this.copyTemplateName = ""
this.pushTodoFlag = false this.pushTodoFlag = false
this.currentEventId = ""
this.events = await this.getEvents(2) this.events = await this.getEvents(2)
await this.getActivityGroup() await this.getActivityGroup()
this.active = 0 this.active = 0
@@ -1028,6 +1043,7 @@
async openEdit(row, flag, copyTemplateMode = false) { async openEdit(row, flag, copyTemplateMode = false) {
this.copyTemplateMode = !!copyTemplateMode this.copyTemplateMode = !!copyTemplateMode
this.pushTodoFlag = false this.pushTodoFlag = false
this.currentEventId = ""
this.active = flag ? 0 : this.active this.active = flag ? 0 : this.active
await this.getActivityGroup() await this.getActivityGroup()
const { id } = row const { id } = row
@@ -1184,7 +1200,8 @@
} }
if (this.eventForm.checkedEventId == "allItems") { if (this.eventForm.checkedEventId == "allItems") {
events = events.filter((v) => v.projectType == 1 && v.id != "allItems") // “所有单项”配置随具体单项提交,由后端统一校验并补齐继承值。
events = events.filter((v) => v.projectType == 1)
} else { } else {
this.$set(this.eventForm, "activityId", this.formData.id) this.$set(this.eventForm, "activityId", this.formData.id)
this.$set(this.eventForm, "eventId", this.eventForm.checkedEventId) this.$set(this.eventForm, "eventId", this.eventForm.checkedEventId)
@@ -1192,9 +1209,10 @@
this.$set(this.eventForm, "startAgeDate", this.eventForm.ageDate[0]) this.$set(this.eventForm, "startAgeDate", this.eventForm.ageDate[0])
this.$set(this.eventForm, "endAgeDate", this.eventForm.ageDate[1]) this.$set(this.eventForm, "endAgeDate", this.eventForm.ageDate[1])
} }
events = this.eventForm const allItems = this.checkedEventInfo.find((v) => v.eventId === "allItems")
events = allItems ? [clone(allItems), this.eventForm] : [this.eventForm]
if (this.copyTemplateMode) { if (this.copyTemplateMode) {
this.cleanCopyTemplateData(null, [events]) this.cleanCopyTemplateData(null, events)
} }
} }
const loading = this.$loading({ const loading = this.$loading({
@@ -1215,6 +1233,7 @@
this.copyTemplateMode = false this.copyTemplateMode = false
} }
await this.openEdit(formData, false) await this.openEdit(formData, false)
this.currentEventId = this.eventForm.checkedEventId || ""
this.$message.success(resp.msg) this.$message.success(resp.msg)
} else { } else {
this.$message.error(resp.msg) this.$message.error(resp.msg)
@@ -1,4 +1,11 @@
const REGISTER_INFO_COMPONENT = { const REGISTER_INFO_COMPONENT = {
// 默认展示换届报告,仅由不需要查看该字段的业务页面显式关闭。
props: {
hideReplaceReport: {
type: Boolean,
default: false
}
},
template: ` template: `
<div> <div>
<el-tabs tab-position="top" v-model="activeName"> <el-tabs tab-position="top" v-model="activeName">
@@ -36,7 +43,7 @@ const REGISTER_INFO_COMPONENT = {
{{viewData.createTime}} {{viewData.createTime}}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="会费标准" :span="4"> <el-descriptions-item label="会费标准" :span="4">
{{viewData.due}} {{ viewData.due === null || viewData.due === undefined ? "--" : viewData.due + " 元/年" }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item :span="4"> <el-descriptions-item :span="4">
<template slot="label">社团介绍</template> <template slot="label">社团介绍</template>
@@ -66,7 +73,7 @@ const REGISTER_INFO_COMPONENT = {
<template slot="label">其他附件</template> <template slot="label">其他附件</template>
<file-preview :files="viewData.files" complete_result></file-preview> <file-preview :files="viewData.files" complete_result></file-preview>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item :span="2"> <el-descriptions-item :span="2" v-if="!hideReplaceReport">
<template slot="label">换届报告</template> <template slot="label">换届报告</template>
<file-preview :files="viewData.replaceReport" complete_result></file-preview> <file-preview :files="viewData.replaceReport" complete_result></file-preview>
</el-descriptions-item> </el-descriptions-item>
@@ -68,10 +68,12 @@ layout("/layouts/platform.html"){
</el-steps> </el-steps>
<!-- 第一步:基础信息表单 --> <!-- 第一步:基础信息表单 -->
<!-- 本表单自行提示首个必填错误,关闭全局校验弹窗增强以免重复提示。 -->
<el-form <el-form
v-show="step === 0" v-show="step === 0"
:model="formData" :model="formData"
ref="baseForm" ref="baseForm"
no-auto-enhance
:rules="baseRules" :rules="baseRules"
label-width="150px" label-width="150px"
class="main-form" class="main-form"
@@ -135,7 +137,9 @@ layout("/layouts/platform.html"){
v-model="formData.due" v-model="formData.due"
placeholder="请输入会费标准" placeholder="请输入会费标准"
readonly readonly
></el-input> >
<template slot="append">元/年</template>
</el-input>
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
@@ -146,7 +150,7 @@ layout("/layouts/platform.html"){
<el-form-item <el-form-item
class="is-required" class="is-required"
label="本年度活动开展情况" label="本年度活动开展情况"
prop="yearActivity" prop="yearActivityList"
> >
<span class="tip-text">说明:非赛事活动无需填写赛事举办单位、获奖情况。</span> <span class="tip-text">说明:非赛事活动无需填写赛事举办单位、获奖情况。</span>
<el-table <el-table
@@ -638,6 +642,7 @@ layout("/layouts/platform.html"){
id: GetQueryString("bizId"), id: GetQueryString("bizId"),
taskId: GetQueryString("taskId"), taskId: GetQueryString("taskId"),
step: 0, // 当前步骤(0-基础信息,1-财务收支,2-成员名单) step: 0, // 当前步骤(0-基础信息,1-财务收支,2-成员名单)
formLoading: false,
isRegistered: false, // 是否已登记 isRegistered: false, // 是否已登记
currentClubId: "", // 当前选中社团ID currentClubId: "", // 当前选中社团ID
@@ -656,7 +661,7 @@ layout("/layouts/platform.html"){
year: new Date().getFullYear() + "", // 默认当前年度 year: new Date().getFullYear() + "", // 默认当前年度
clubId: "", clubId: "",
foundTime: "", foundTime: "",
due: "", due: null,
clubName: "", clubName: "",
yearActivityList: [{}], // 本年度活动列表 yearActivityList: [{}], // 本年度活动列表
plans: [{}], // 下年度计划列表 plans: [{}], // 下年度计划列表
@@ -671,7 +676,10 @@ layout("/layouts/platform.html"){
}, },
// 表单校验规则 // 表单校验规则
baseRules: {}, baseRules: {
yearActivityList: [{validator: this.validateActivityRows, trigger: ['change', 'blur']}],
plans: [{validator: this.validateActivityRows, trigger: ['change', 'blur']}]
},
financeRules: {}, financeRules: {},
memberRules: {} memberRules: {}
}; };
@@ -724,6 +732,28 @@ layout("/layouts/platform.html"){
} }
}, },
methods: { methods: {
// 在列表级逐行校验,避免嵌套表单项未注册、空数组或纯空白文本绕过必填。
validateActivityRows(rule, value, callback) {
const currentYear = rule.field === 'yearActivityList';
const label = currentYear ? '本年度活动开展情况' : '下一年度活动计划';
const fields = currentYear
? [['activityTime', '活动时间'], ['activityName', '活动主题/赛事名称'], ['activityAddress', '活动地点'], ['joinNum', '参加人数']]
: [['activityName', '活动主题/赛事名称'], ['activityUnit', '活动/赛事主办单位'], ['activityAddress', '活动地点']];
if (!Array.isArray(value) || value.length === 0) {
callback(new Error(label + '至少填写一条'));
return;
}
for (let i = 0; i < value.length; i++) {
for (const field of fields) {
const content = value[i] && value[i][field[0]];
if (content === null || content === undefined || String(content).trim() === '') {
callback(new Error(label + '第' + (i + 1) + '条:' + field[1] + '不能为空'));
return;
}
}
}
callback();
},
/************************ 通用工具方法 ************************/ /************************ 通用工具方法 ************************/
// 格式化数字(保留2位小数,空值显示0) // 格式化数字(保留2位小数,空值显示0)
formatNumber(num) { formatNumber(num) {
@@ -1110,7 +1140,7 @@ layout("/layouts/platform.html"){
if (!val) return; if (!val) return;
const club = this.clubOptions.find(item => item.id === val); const club = this.clubOptions.find(item => item.id === val);
// 更新社团基础信息 // 更新社团基础信息
this.formData.due = club?.due || ""; this.formData.due = club && club.due !== null && club.due !== undefined ? club.due : null;
this.formData.foundTime = club?.foundTime this.formData.foundTime = club?.foundTime
? this.$moment(club.foundTime).format("YYYY-MM-DD") ? this.$moment(club.foundTime).format("YYYY-MM-DD")
: this.$moment().format("YYYY-MM-DD"); : this.$moment().format("YYYY-MM-DD");
@@ -1161,73 +1191,63 @@ layout("/layouts/platform.html"){
// 步骤前进 // 步骤前进
this.step++; this.step++;
}, },
// 保存 // 三个操作共用基础信息校验,失败时返回第一步展示行号和必填字段。
async handleSave() { validateBaseBeforeAction(action) {
try { if (this.formLoading) return;
this.recalculateCensus(); this.$refs.baseForm.validate((valid, fields) => {
const resp = await this.$axios.post("/platform/club/examine/apply/save", { if (!valid) {
data: JSON.stringify(this.formData), this.step = 0;
incomeDetailed: JSON.stringify(this.financeData.incomeDetailed), const firstError = Object.values(fields || {}).flat()[0];
incomeCensus: JSON.stringify(this.financeData.incomeCensus) this.$message.warning('温馨提示:' + (firstError ? firstError.message : '请补全基础信息必填项'));
return;
}
action();
}); });
if (resp.code === 0) {
this.$message.success(resp.msg);
commonUtil.pjaxPush('/platform/club/examine/mine');
}
} catch (error) {
this.handleError(error, "保存失败");
}
}, },
// 提交(新增) // 仅接收本页三个固定操作的接口后缀;data为登记实体JSON,另两项为财务数组JSON。
async handleSubmit() { // 接口返回{code,msg,data},code为0时保存成功,否则展示后端校验提示。
try { requestExamineAction(action) {
if (this.formLoading) return;
this.recalculateCensus(); this.recalculateCensus();
await this.$confirm("确定要提交此申请吗?", "提示", { const params = {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "info"
});
const resp = await this.$axios.post("/platform/club/examine/apply/submit", {
data: JSON.stringify(this.formData), data: JSON.stringify(this.formData),
incomeDetailed: JSON.stringify(this.financeData.incomeDetailed), incomeDetailed: JSON.stringify(this.financeData.incomeDetailed),
incomeCensus: JSON.stringify(this.financeData.incomeCensus) incomeCensus: JSON.stringify(this.financeData.incomeCensus)
}); };
if (action === 'submitAgain') params.taskId = this.taskId;
this.formLoading = true;
return this.$axios.post('/platform/club/examine/apply/' + action, params)
.then((resp) => {
if (resp.code === 0) { if (resp.code === 0) {
this.$message.success(resp.msg); this.$message.success(resp.msg);
commonUtil.pjaxPush('/platform/club/examine/mine'); commonUtil.pjaxPush('/platform/club/examine/mine');
} else { } else {
this.$message.warning(resp.msg); this.$message.warning(resp.msg);
} }
} catch (error) { })
if (error !== "cancel") { .catch(() => {
this.handleError(error, "提交失败"); // 后端校验和网络异常已由全局axios拦截器提示,此处仅结束失败请求,避免再次弹窗。
} })
} .finally(() => {
this.formLoading = false;
});
}, },
// 提交(任务处理) handleSave() {
async handleFinishTask() { this.validateBaseBeforeAction(() => this.requestExamineAction('save'));
try { },
this.recalculateCensus(); handleSubmit() {
await this.$confirm("您确定要提交吗?", "提示", { this.validateBaseBeforeAction(() => {
confirmButtonText: "确定", this.$confirm('确定要提交此申请吗?', '提示', {
cancelButtonText: "取消", confirmButtonText: '确定', cancelButtonText: '取消', type: 'info'
type: "warning" }).then(() => this.requestExamineAction('submit')).catch(() => {});
}); });
const resp = await this.$axios.post('/platform/club/examine/apply/submitAgain', { },
data: JSON.stringify(this.formData), handleFinishTask() {
incomeDetailed: JSON.stringify(this.financeData.incomeDetailed), this.validateBaseBeforeAction(() => {
incomeCensus: JSON.stringify(this.financeData.incomeCensus), this.$confirm('您确定要提交吗?', '提示', {
taskId: this.taskId confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(() => this.requestExamineAction('submitAgain')).catch(() => {});
}); });
if (resp.code === 0) {
this.$message.success(resp.msg);
commonUtil.pjaxPush('/platform/club/examine/mine');
}
} catch (error) {
if (error !== "cancel") {
this.handleError(error, "提交失败");
}
}
} }
} }
}); });
@@ -10,7 +10,7 @@
<el-descriptions v-if="viewData" :column="3" border class="table_fixed"> <el-descriptions v-if="viewData" :column="3" border class="table_fixed">
<el-descriptions-item label="社团名称">{{ viewData.clubName }}</el-descriptions-item> <el-descriptions-item label="社团名称">{{ viewData.clubName }}</el-descriptions-item>
<el-descriptions-item label="成立时间">{{ viewData.foundTime }} </el-descriptions-item> <el-descriptions-item label="成立时间">{{ viewData.foundTime }} </el-descriptions-item>
<el-descriptions-item label="会费标准">{{ viewData.due }}</el-descriptions-item> <el-descriptions-item label="会费标准">{{ viewData.due === null || viewData.due === undefined ? "--" : viewData.due + " 元/年" }}</el-descriptions-item>
<el-descriptions-item span="3"> <el-descriptions-item span="3">
<template slot="label">工作总结</template> <template slot="label">工作总结</template>
<file-preview :files="viewData.summaryFiles" complete_result></file-preview> <file-preview :files="viewData.summaryFiles" complete_result></file-preview>
@@ -73,7 +73,7 @@ layout("/layouts/platform.html"){
<el-descriptions-item label="联系人邮箱">{{viewData.concatPersonEmail}}</el-descriptions-item> <el-descriptions-item label="联系人邮箱">{{viewData.concatPersonEmail}}</el-descriptions-item>
<el-descriptions-item> <el-descriptions-item>
<template slot="label">会费标准</template> <template slot="label">会费标准</template>
{{viewData.due}} {{ viewData.due === null || viewData.due === undefined ? "--" : viewData.due + " 元/年" }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item :span="2"> <el-descriptions-item :span="2">
<template slot="label">当前人数</template> <template slot="label">当前人数</template>
@@ -63,7 +63,7 @@ layout("/layouts/platform.html"){
<el-descriptions-item label="社团编码">{{viewData.clubCode}}</el-descriptions-item> <el-descriptions-item label="社团编码">{{viewData.clubCode}}</el-descriptions-item>
<el-descriptions-item label="社团类型">{{viewData.typeName}}</el-descriptions-item> <el-descriptions-item label="社团类型">{{viewData.typeName}}</el-descriptions-item>
<el-descriptions-item label="发起人">{{viewData.sponsorName}}</el-descriptions-item> <el-descriptions-item label="发起人">{{viewData.sponsorName}}</el-descriptions-item>
<el-descriptions-item label="会费标准">{{viewData.due}}</el-descriptions-item> <el-descriptions-item label="会费标准">{{ viewData.due === null || viewData.due === undefined ? "--" : viewData.due + " 元/年" }}</el-descriptions-item>
<el-descriptions-item label="当前人数">{{viewData.currentPeopleNum}}</el-descriptions-item> <el-descriptions-item label="当前人数">{{viewData.currentPeopleNum}}</el-descriptions-item>
<el-descriptions-item span="2"> <el-descriptions-item span="2">
<template slot="label">社团介绍</template> <template slot="label">社团介绍</template>
@@ -24,7 +24,13 @@ const CLUB_FORM_TEMPLATE = {
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="会费标准" prop="due"> <el-form-item label="会费标准" prop="due">
<el-input placeholder="请填写会费标准" v-model="formData.due"> <el-input
min="0"
placeholder="请填写会费标准"
step="0.01"
type="number"
v-model.number="formData.due"
>
<template slot="append">元/年</template> <template slot="append">元/年</template>
</el-input> </el-input>
</el-form-item> </el-form-item>
@@ -156,12 +162,15 @@ const CLUB_FORM_TEMPLATE = {
return { return {
formData: { formData: {
clubName: "", clubName: "",
clubType: "" clubType: "",
due: null
}, },
formRules: { formRules: {
clubName: [{ required: true, message: "请填写社团名称", trigger: ["blur", "change"] }], clubName: [{ required: true, message: "请填写社团名称", trigger: ["blur", "change"] }],
clubCode: [{ required: false, message: "请填写社团编码", trigger: ["blur", "change"] }], clubCode: [{ required: false, message: "请填写社团编码", trigger: ["blur", "change"] }],
clubType: [{ required: true, message: "请选择社团类型", trigger: ["blur", "change"] }], clubType: [{ required: true, message: "请选择社团类型", trigger: ["blur", "change"] }],
// 会费标准只保存数字,单位“元/年”由页面统一展示。
due: [{ type: "number", min: 0, message: "会费标准必须为不小于0的数字", trigger: ["blur", "change"] }],
//concatPerson: [{ required: true, message: "请选择社团联系人", trigger: ["blur", "change"] }], //concatPerson: [{ required: true, message: "请选择社团联系人", trigger: ["blur", "change"] }],
foundTime: [{ required: true, message: "请选择成立时间", trigger: ["blur", "change"] }] foundTime: [{ required: true, message: "请选择成立时间", trigger: ["blur", "change"] }]
} }
@@ -73,7 +73,7 @@ layout("/layouts/platform.html"){
</el-card> </el-card>
<template #public> <template #public>
<register-info ref="registerInfoRef"> <register-info ref="registerInfoRef" :hide-replace-report="true">
<div v-if="showApprovalForm"> <div v-if="showApprovalForm">
<div class="process-title"> <div class="process-title">
{{formData.taskName}} {{formData.taskName}}
@@ -55,7 +55,7 @@ layout("/layouts/platform.html"){
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-descriptions-item>--> </el-descriptions-item>-->
<el-descriptions-item label="证明人"> <!-- <el-descriptions-item label="证明人">
<el-form-item prop="certifierUserId" label="证明人"> <el-form-item prop="certifierUserId" label="证明人">
<el-select <el-select
style="width: 100%" style="width: 100%"
@@ -75,7 +75,7 @@ layout("/layouts/platform.html"){
</el-option> </el-option>
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-descriptions-item> </el-descriptions-item> -->
<el-descriptions-item label="慰问类型"> <el-descriptions-item label="慰问类型">
<el-form-item prop="type" label="慰问类型"> <el-form-item prop="type" label="慰问类型">
<el-select v-model="formData.type" @change="typeChange" style="width: 100%" <el-select v-model="formData.type" @change="typeChange" style="width: 100%"
@@ -112,14 +112,14 @@ layout("/layouts/platform.html"){
</el-form-item> </el-form-item>
</el-descriptions-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-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-input>
</el-form-item> </el-form-item>
</el-descriptions-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-form-item prop="bankUserName" label="户名">
<el-input maxlength="20" v-model="formData.bankUserName" <el-input maxlength="20" v-model="formData.bankUserName"
placeholder="请填写户名" placeholder="请填写户名"
@@ -127,7 +127,7 @@ layout("/layouts/platform.html"){
</el-form-item> </el-form-item>
</el-descriptions-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-form-item prop="bankOfDeposit" label="开户行">
<el-input maxlength="20" v-model="formData.bankOfDeposit" <el-input maxlength="20" v-model="formData.bankOfDeposit"
placeholder="请填写开户行" placeholder="请填写开户行"
@@ -173,10 +173,12 @@ layout("/layouts/platform.html"){
</template> </template>
</el-descriptions>
<el-descriptions :column="3" border>
<el-descriptions-item label="申请事由" :span="3"> <el-descriptions-item label="申请事由" :span="3">
<el-form-item prop="remark" label="申请事由"> <el-form-item prop="remark" label="申请事由">
<el-input maxlength="500" v-model="formData.remark" placeholder="请填写申请事由" <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-form-item>
</el-descriptions-item> </el-descriptions-item>
@@ -219,6 +221,15 @@ layout("/layouts/platform.html"){
new Vue({ new Vue({
el: "#app", el: "#app",
store, store,
components: {
ElDatePicker: {
extends: ELEMENT.DatePicker,
mounted() {
// 手动输入触发 change 前初始化日期面板,避免 picker 尚未创建。
if (!this.picker) this.mountPicker()
}
}
},
data() { data() {
return { return {
bizId: GetQueryString("bizId"), bizId: GetQueryString("bizId"),
@@ -229,7 +240,10 @@ layout("/layouts/platform.html"){
payUserId: [{ required: true, message: "必填", trigger: ["change", "blur"] }], payUserId: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
type: [{ required: true, message: "必填", trigger: ["change", "blur"] }], type: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
way: [{ 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"] }], occurTime: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
child: [{ required: true, message: "必填", trigger: ["change", "blur"] }], child: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
hospital: [{ required: true, message: "必填", trigger: ["change", "blur"] }], hospital: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
@@ -238,14 +252,20 @@ layout("/layouts/platform.html"){
hospitalBy: [{ required: true, message: "必填", trigger: ["change", "blur"] }], hospitalBy: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
deadImmediateFamily: [{ required: true, message: "必填", trigger: ["change", "blur"] }], deadImmediateFamily: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
remark: [{ 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"] }], signature: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
handlerUserId: [{ required: true, message: "必填", trigger: ["change", "blur"] }], handlerUserId: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
certifierUserId: [{ required: true, message: "必填", trigger: ["change", "blur"] }], // certifierUserId: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
bankCardNumber: [{ 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"] }], bankUserName: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
bankOfDeposit: [{ 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"] }], leaveHospitalTime: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
hospitalizationTime: [{ required: true, message: "必填", trigger: ["change", "blur"] }], hospitalizationTime: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
}, },
@@ -259,6 +279,9 @@ layout("/layouts/platform.html"){
} }
}, },
methods: { methods: {
validateMoney(value) {
return /^(0|[1-9][0-9]*)(\.[0-9]{1,2})?$/.test(String(value)) && Number(value) > 0 && Number(value) <= 10000
},
createRemoteMethod(options) { createRemoteMethod(options) {
return (keyword) => { return (keyword) => {
this.selectQueryUser(keyword, options) this.selectQueryUser(keyword, options)
@@ -81,6 +81,9 @@ layout("/layouts/platform.html"){
:rules="[{required:true,message:'必填',trigger:['change','blur']}]"> :rules="[{required:true,message:'必填',trigger:['change','blur']}]">
<user-opinion-textarea v-model="formData.tf_opinion"></user-opinion-textarea> <user-opinion-textarea v-model="formData.tf_opinion"></user-opinion-textarea>
</el-form-item> </el-form-item>
<el-form-item label="签字" prop="tf_sign" :rules="[{required:true,message:'请完成审批签字',trigger:['change','blur']}]">
<pc-signature v-model="formData.tf_sign" :key="formData.processTaskId"></pc-signature>
</el-form-item>
</el-form> </el-form>
<el-row type="flex" justify="end"> <el-row type="flex" justify="end">
<el-button @click="handleTaskAction(1)" size="small" type="primary">同意申请</el-button> <el-button @click="handleTaskAction(1)" size="small" type="primary">同意申请</el-button>
@@ -135,18 +138,26 @@ layout("/layouts/platform.html"){
this.showApprovalForm = true this.showApprovalForm = true
this.formData = { this.formData = {
processTaskId: row.taskId, processTaskId: row.taskId,
taskName: row.curTaskName taskName: row.curTaskName,
tf_sign: "",
tf_opinion: ""
} }
this.$refs.condolenceInfoRef.onOpen(row) this.$refs.condolenceInfoRef.onOpen(row)
}) })
}, },
handleTaskAction(val) { async handleTaskAction(val) {
try {
const valid = await this.$refs.formRef.validate()
if (!valid) return
} catch (error) {
return
}
this.$confirm("您确定要提交吗?", "提示", { this.$confirm("您确定要提交吗?", "提示", {
confirmButtonText: "确定", confirmButtonText: "确定",
cancelButtonText: "取消", cancelButtonText: "取消",
type: "warning" type: "warning"
}).then(() => { }).then(() => {
this.$axios.post("/flow/common/executeTask", { this.$axios.post("/platform/condolence/approval/executeTask", {
data: JSON.stringify({ data: JSON.stringify({
...this.formData, ...this.formData,
submitType: val submitType: val
@@ -65,6 +65,9 @@ const condolenceInfo = {
<dict-tag :options="dict.type.PROCESS_TASK_SUBMIT_TYPE" <dict-tag :options="dict.type.PROCESS_TASK_SUBMIT_TYPE"
:value="task.ext.submitType"></dict-tag> :value="task.ext.submitType"></dict-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="审批签字" :span="3" v-if="task.taskFormData && task.taskFormData.sign">
<el-image :src="task.taskFormData.sign" style="height: 80px" fit="contain"></el-image>
</el-descriptions-item>
<el-descriptions-item label="办理意见" v-if="!task.ext.isFirstTaskNode">{{ <el-descriptions-item label="办理意见" v-if="!task.ext.isFirstTaskNode">{{
task.taskFormData.opinion }} task.taskFormData.opinion }}
</el-descriptions-item> </el-descriptions-item>
@@ -0,0 +1,229 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app">
<guava ref="guava">
<el-card shadow="never">
<search @search="doSearch">
<search-item label="年度">
<el-date-picker
v-model="pageForm.year"
type="year"
value-format="yyyy"
placeholder="请选择年度"
style="width: 100%"
@change="doSearch"
></el-date-picker>
</search-item>
<search-item label="姓名/工号">
<el-input placeholder="请输入姓名或工号" clearable v-model="pageForm.searchKeyword"
@keyup.enter.native="doSearch">
</el-input>
</search-item>
<search-item label="慰问类型">
<el-select v-model="pageForm.type" @change="doSearch" style="width: 100%"
placeholder="请选择慰问类型" filterable clearable>
<el-option v-for="item in typeOptions"
:value="item.id"
:key="item.id"
:label="item.name"
></el-option>
</el-select>
</search-item>
<search-item label="所属工会">
<el-select v-model="pageForm.unionId" placeholder="请选择所属工会" filterable clearable
@change="doSearch" style="width: 100%">
<el-option
v-for="item in unionList"
:key="item.id"
:label="item.name"
:value="item.id">
</el-option>
</el-select>
</search-item>
<search-item label="所属单位">
<el-select @change="doSearch"
clearable
filterable
placeholder="请选择单位" style="width: 100%" v-model="pageForm.unitId">
<el-option :key="item.id" :label="item.name" :value="item.id" v-for="item in unitList"></el-option>
</el-select>
</search-item>
</search>
</el-card>
<el-card shadow="never">
<table-tool label="申请列表">
<el-radio-group v-model="pageForm.approval" size="small" @change="doSearch">
<el-radio-button :label="true">已审核</el-radio-button>
<el-radio-button :label="false">未审核</el-radio-button>
</el-radio-group>
</table-tool>
<el-table :data="tableData" @sort-change="pageOrder" style="width: 100%">
<el-table-column type="index" width="50px" label="序号" :index="indexMethod"></el-table-column>
<el-table-column
:label="column.label"
:prop="column.prop"
:key="column.prop"
:width="column.width"
:sortable="column.sortable"
align="center"
header-align="center"
show-overflow-tooltip
v-for="column in tableColumns"
>
<template v-slot="{ row }" v-if="column.prop === 'instanceState'">
<enum-tag :value="row.instanceState" name="ProcessInstanceStateEnum" label_key="message" size="small"></enum-tag>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="300px">
<template slot-scope="{row}">
<el-button @click="onView(row)" size="mini" type="primary">查看</el-button>
<el-button v-if="row.taskState === 10" @click="onAudit(row)" size="mini" type="primary">审核</el-button>
<el-button v-if="row.canRevoke" @click="onRevoke(row)" size="mini" type="danger">撤回</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
<template #edit>
<condolence-info ref="condolenceInfoRef">
<div v-if="showApprovalForm">
<div class="process-title">
{{formData.taskName}}
</div>
<el-form :model="formData" ref="formRef" :rules="formRules" label-width="0" label-suffix=":"
class="flow-task-form">
<el-form-item label="审批意见" prop="tf_opinion"
:rules="[{required:true,message:'必填',trigger:['change','blur']}]">
<user-opinion-textarea v-model="formData.tf_opinion"></user-opinion-textarea>
</el-form-item>
<el-form-item label="签字" prop="tf_sign" :rules="[{required:true,message:'请完成审批签字',trigger:['change','blur']}]">
<pc-signature v-model="formData.tf_sign" :key="formData.processTaskId"></pc-signature>
</el-form-item>
</el-form>
<el-row type="flex" justify="end">
<el-button @click="handleTaskAction(1)" size="small" type="primary">同意申请</el-button>
<el-button @click="handleTaskAction(2)" size="small" type="danger">拒绝申请</el-button>
<el-button @click="handleTaskAction(6)" size="small" type="info">退回到发起人</el-button>
<el-button @click="$refs.guava.index()" size="small">取消</el-button>
</el-row>
</div>
</condolence-info>
</template>
</guava>
</div>
<script nonce="${cspNonce!}">
<!--#include('../common/info.js'){}#-->
new Vue({
el: "#app",
store,
mixins: [initTableMixins],
components: {
"condolence-info": condolenceInfo
},
data() {
return {
pageForm: {
approval: false
},
typeOptions: [],
tableColumns: [
{prop: 'helpUserName', label: '慰问对象'},
{prop: 'helpLoginName', label: '慰问对象工号'},
{prop: 'applyUserName', label: '申请人'},
{prop: 'applyUnionName', label: '所属工会'},
{prop: 'applyUnitName', label: '所属单位'},
{prop: 'typeName', label: '慰问类型'},
{prop: 'curTaskName', label: '当前节点'},
{prop: 'instanceState', label: '流程状态'},
],
unionList: [],
unitList: [],
formData: {},
showApprovalForm: false
}
},
methods: {
onView(row) {
this.$refs.guava.edit(()=>{
this.showApprovalForm = false
this.$refs.condolenceInfoRef.onOpen(row)
})
},
onAudit(row) {
this.$refs.guava.edit(()=>{
this.showApprovalForm = true
this.formData = {
processTaskId: row.taskId,
taskName: row.curTaskName,
tf_sign: "",
tf_opinion: ""
}
this.$refs.condolenceInfoRef.onOpen(row)
})
},
async handleTaskAction(val) {
try {
const valid = await this.$refs.formRef.validate()
if (!valid) return
} catch (error) {
return
}
this.$confirm("您确定要提交吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(() => {
this.$axios.post("/platform/condolence/approval/executeTask", {
data: JSON.stringify({
...this.formData,
submitType: val
})
}).then((res) => {
if (res.code === 0) {
this.$refs.guava.index()
this.$message.success(res.msg)
this.doSearch()
}
})
})
},
onRevoke(row) {
this.$confirm("您确定要撤回吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "info"
}).then(() => {
this.$axios.post("/flow/common/revokeTask", {taskId: row.taskId}).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg)
this.doSearch()
}
})
})
},
queryCondolenceType() {
this.$axios.post('/platform/condolence/type/queryCondolenceType')
.then((resp) => {
this.typeOptions = resp.data
})
},
},
async created() {
this.unionList = await this.$businessTool.listUnion()
this.unitList = await this.$businessTool.listUnit()
this.queryCondolenceType()
this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -62,6 +62,13 @@ layout("/layouts/platform.html"){
<el-button @click="onView(row)" size="mini" type="primary">查看</el-button> <el-button @click="onView(row)" size="mini" type="primary">查看</el-button>
<el-button v-if="row.taskKey === 'startTask' || !row.instanceId" @click="onEdit(row)" size="mini" type="primary">编辑</el-button> <el-button v-if="row.taskKey === 'startTask' || !row.instanceId" @click="onEdit(row)" size="mini" type="primary">编辑</el-button>
<el-button v-if="row.canRevoke" size="mini" type="danger" @click="onRevoke(row)">撤回</el-button> <el-button v-if="row.canRevoke" size="mini" type="danger" @click="onRevoke(row)">撤回</el-button>
<el-dropdown v-if="row.createdBy === $store.state.user.id && row.instanceState == 20" trigger="click" @command="format => doExportApply(row, format)">
<el-button size="mini" type="primary">导出</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="pdf">导出 PDF</el-dropdown-item>
<el-dropdown-item command="word">导出 Word</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<el-button v-if="row.taskKey === 'startTask' || !row.instanceId" @click="onDelete(row.id)" size="mini" type="danger"> <el-button v-if="row.taskKey === 'startTask' || !row.instanceId" @click="onDelete(row.id)" size="mini" type="danger">
删除 删除
</el-button> </el-button>
@@ -102,6 +109,9 @@ layout("/layouts/platform.html"){
} }
}, },
methods: { methods: {
doExportApply(row, format) {
this.$downLoad('/platform/condolence/mine/doExportApply?id=' + row.id + '&print=' + (format === 'pdf'))
},
onAdd() { onAdd() {
commonUtil.pjaxPush('/platform/condolence/apply') commonUtil.pjaxPush('/platform/condolence/apply')
}, },
@@ -101,6 +101,9 @@ layout("/layouts/platform.html"){
:rules="[{required:true,message:'必填',trigger:['change','blur']}]"> :rules="[{required:true,message:'必填',trigger:['change','blur']}]">
<user-opinion-textarea v-model="formData.tf_opinion"></user-opinion-textarea> <user-opinion-textarea v-model="formData.tf_opinion"></user-opinion-textarea>
</el-form-item> </el-form-item>
<el-form-item label="签字" prop="tf_sign" :rules="[{required:true,message:'请完成审批签字',trigger:['change','blur']}]">
<pc-signature v-model="formData.tf_sign" :key="formData.processTaskId"></pc-signature>
</el-form-item>
</el-form> </el-form>
<el-row type="flex" justify="end"> <el-row type="flex" justify="end">
<el-button @click="handleTaskAction(1)" size="small" type="primary">同意申请</el-button> <el-button @click="handleTaskAction(1)" size="small" type="primary">同意申请</el-button>
@@ -158,18 +161,26 @@ layout("/layouts/platform.html"){
this.showApprovalForm = true this.showApprovalForm = true
this.formData = { this.formData = {
processTaskId: row.taskId, processTaskId: row.taskId,
taskName: row.curTaskName taskName: row.curTaskName,
tf_sign: "",
tf_opinion: ""
} }
this.$refs.condolenceInfoRef.onOpen(row) this.$refs.condolenceInfoRef.onOpen(row)
}) })
}, },
handleTaskAction(val) { async handleTaskAction(val) {
try {
const valid = await this.$refs.formRef.validate()
if (!valid) return
} catch (error) {
return
}
this.$confirm("您确定要提交吗?", "提示", { this.$confirm("您确定要提交吗?", "提示", {
confirmButtonText: "确定", confirmButtonText: "确定",
cancelButtonText: "取消", cancelButtonText: "取消",
type: "warning" type: "warning"
}).then(() => { }).then(() => {
this.$axios.post("/flow/common/executeTask", { this.$axios.post("/platform/condolence/approval/executeTask", {
data: JSON.stringify({ data: JSON.stringify({
...this.formData, ...this.formData,
submitType: val submitType: val
@@ -101,6 +101,9 @@ layout("/layouts/platform.html"){
:rules="[{required:true,message:'必填',trigger:['change','blur']}]"> :rules="[{required:true,message:'必填',trigger:['change','blur']}]">
<user-opinion-textarea v-model="formData.tf_opinion"></user-opinion-textarea> <user-opinion-textarea v-model="formData.tf_opinion"></user-opinion-textarea>
</el-form-item> </el-form-item>
<el-form-item label="签字" prop="tf_sign" :rules="[{required:true,message:'请完成审批签字',trigger:['change','blur']}]">
<pc-signature v-model="formData.tf_sign" :key="formData.processTaskId"></pc-signature>
</el-form-item>
</el-form> </el-form>
<el-row type="flex" justify="end"> <el-row type="flex" justify="end">
<el-button @click="handleTaskAction(1)" size="small" type="primary">同意申请</el-button> <el-button @click="handleTaskAction(1)" size="small" type="primary">同意申请</el-button>
@@ -158,18 +161,26 @@ layout("/layouts/platform.html"){
this.showApprovalForm = true this.showApprovalForm = true
this.formData = { this.formData = {
processTaskId: row.taskId, processTaskId: row.taskId,
taskName: row.curTaskName taskName: row.curTaskName,
tf_sign: "",
tf_opinion: ""
} }
this.$refs.condolenceInfoRef.onOpen(row) this.$refs.condolenceInfoRef.onOpen(row)
}) })
}, },
handleTaskAction(val) { async handleTaskAction(val) {
try {
const valid = await this.$refs.formRef.validate()
if (!valid) return
} catch (error) {
return
}
this.$confirm("您确定要提交吗?", "提示", { this.$confirm("您确定要提交吗?", "提示", {
confirmButtonText: "确定", confirmButtonText: "确定",
cancelButtonText: "取消", cancelButtonText: "取消",
type: "warning" type: "warning"
}).then(() => { }).then(() => {
this.$axios.post("/flow/common/executeTask", { this.$axios.post("/platform/condolence/approval/executeTask", {
data: JSON.stringify({ data: JSON.stringify({
...this.formData, ...this.formData,
submitType: val submitType: val
@@ -37,12 +37,12 @@ const basicForm = {
<el-radio-button :label="false">不上传</el-radio-button> <el-radio-button :label="false">不上传</el-radio-button>
</el-radio-group> </el-radio-group>
</el-form-item>--> </el-form-item>-->
<!-- <el-form-item label="是否上传附件" prop="isUploadFile">--> <el-form-item label="是否上传附件" prop="isUploadFile">
<!-- <el-radio-group v-model="formData.isUploadFile">--> <el-radio-group v-model="formData.isUploadFile">
<!-- <el-radio-button :label="true">是</el-radio-button>--> <el-radio-button :label="true">是</el-radio-button>
<!-- <el-radio-button :label="false">否</el-radio-button>--> <el-radio-button :label="false">否</el-radio-button>
<!-- </el-radio-group>--> </el-radio-group>
<!-- </el-form-item>--> </el-form-item>
<el-form-item label="上传附件说明" prop="uploadFileDesc" v-if="formData.isUploadFile"> <el-form-item label="上传附件说明" prop="uploadFileDesc" v-if="formData.isUploadFile">
<el-input v-model="formData.uploadFileDesc" maxlength="50" clearable placeholder="请填写上传附件说明"></el-input> <el-input v-model="formData.uploadFileDesc" maxlength="50" clearable placeholder="请填写上传附件说明"></el-input>
</el-form-item> </el-form-item>
@@ -26,7 +26,7 @@ layout("/layouts/platform.html"){
</el-form-item> </el-form-item>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="受补助人"> <el-descriptions-item label="受补助人">
<el-form-item prop="userName"> <el-form-item prop="userId">
<template v-if="formData.mode == 2"> <template v-if="formData.mode == 2">
<el-select :remote-method="queryRecipients" <el-select :remote-method="queryRecipients"
@change="userChange" clearable filterable @change="userChange" clearable filterable
@@ -51,7 +51,7 @@ layout("/layouts/platform.html"){
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="身份证号" :span="2"> <el-descriptions-item label="身份证号" :span="2">
<el-form-item prop="idCard"> <el-form-item prop="idCard">
<el-input maxlength="20" clearable placeholder="请填写身份证号" <el-input clearable placeholder="请填写身份证号"
v-model="formData.idCard" maxlength="18"></el-input> v-model="formData.idCard" maxlength="18"></el-input>
</el-form-item> </el-form-item>
</el-descriptions-item> </el-descriptions-item>
@@ -84,7 +84,7 @@ layout("/layouts/platform.html"){
<el-descriptions-item label="手机号码"> <el-descriptions-item label="手机号码">
<el-form-item prop="mobile"> <el-form-item prop="mobile">
<el-input maxlength="11" clearable placeholder="请填写手机号码" <el-input maxlength="11" clearable placeholder="请填写手机号码"
v-model="formData.mobile" maxlength="11"></el-input> v-model="formData.mobile"></el-input>
</el-form-item> </el-form-item>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="家庭年总收入(万元)"> <el-descriptions-item label="家庭年总收入(万元)">
@@ -103,6 +103,7 @@ layout("/layouts/platform.html"){
<el-input-number <el-input-number
v-model="formData.homeNumber" v-model="formData.homeNumber"
:min="1" :min="1"
:precision="0"
:step="1" :step="1"
style="width: 100%" style="width: 100%"
placeholder="请填写家庭人数"> placeholder="请填写家庭人数">
@@ -113,7 +114,7 @@ layout("/layouts/platform.html"){
<el-form-item prop="homeAddress"> <el-form-item prop="homeAddress">
<el-input clearable <el-input clearable
style="width: 100%" style="width: 100%"
v-model="formData.homeAddress" v-model="formData.homeAddress" maxlength="200"
placeholder="请填写家庭住址"> placeholder="请填写家庭住址">
</el-input> </el-input>
</el-form-item> </el-form-item>
@@ -132,7 +133,7 @@ layout("/layouts/platform.html"){
<!-- </el-descriptions-item>--> <!-- </el-descriptions-item>-->
<el-descriptions-item label="银行卡号" > <el-descriptions-item label="银行卡号" >
<el-form-item prop="bankCardNum"> <el-form-item prop="bankCardNum">
<el-input maxlength="20" clearable placeholder="请填写银行卡号" <el-input maxlength="19" clearable placeholder="请填写银行卡号"
v-model="formData.bankCardNum"></el-input> v-model="formData.bankCardNum"></el-input>
</el-form-item> </el-form-item>
</el-descriptions-item> </el-descriptions-item>
@@ -152,7 +153,7 @@ layout("/layouts/platform.html"){
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="申请金额(元)"> <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-input-number v-model="formData.applyMoney" :precision="2" :min="0.01" placeholder="请输入申请金额"></el-input-number>
</el-form-item> </el-form-item>
</el-descriptions-item> </el-descriptions-item>
@@ -198,8 +199,8 @@ layout("/layouts/platform.html"){
<template slot-scope="{row,$index}"> <template slot-scope="{row,$index}">
<el-form-item label-width="0" <el-form-item label-width="0"
:prop="'familyList.'+$index+'.relation'" :prop="'familyList.'+$index+'.relation'"
:rules="[{required:true,message:'必填',trigger:['change','blur']}]"> :rules="textRules('与本人关系', 50)">
<el-input v-model="row.relation" placeholder="请填写与本人关系" clearable></el-input> <el-input v-model="row.relation" maxlength="50" placeholder="请填写与本人关系" clearable></el-input>
</el-form-item> </el-form-item>
</template> </template>
</el-table-column> </el-table-column>
@@ -207,8 +208,8 @@ layout("/layouts/platform.html"){
<template slot-scope="{row,$index}"> <template slot-scope="{row,$index}">
<el-form-item label-width="0" <el-form-item label-width="0"
:prop="'familyList.'+$index+'.name'" :prop="'familyList.'+$index+'.name'"
:rules="[{required:true,message:'必填',trigger:['change','blur']}]"> :rules="textRules('姓名', 100)">
<el-input v-model="row.name" placeholder="请填写姓名" clearable></el-input> <el-input v-model="row.name" maxlength="100" placeholder="请填写姓名" clearable></el-input>
</el-form-item> </el-form-item>
</template> </template>
</el-table-column> </el-table-column>
@@ -216,9 +217,9 @@ layout("/layouts/platform.html"){
<template slot-scope="{row,$index}"> <template slot-scope="{row,$index}">
<el-form-item label-width="0" <el-form-item label-width="0"
:prop="'familyList.'+$index+'.age'" :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" <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> </el-form-item>
</template> </template>
</el-table-column> </el-table-column>
@@ -226,8 +227,8 @@ layout("/layouts/platform.html"){
<template slot-scope="{row,$index}"> <template slot-scope="{row,$index}">
<el-form-item label-width="0" <el-form-item label-width="0"
:prop="'familyList.'+$index+'.homeUnit'" :prop="'familyList.'+$index+'.homeUnit'"
:rules="[{required:true,message:'必填',trigger:['change','blur']}]"> :rules="textRules('工作单位', 200)">
<el-input v-model="row.homeUnit" placeholder="请填写工作单位" clearable></el-input> <el-input v-model="row.homeUnit" maxlength="200" placeholder="请填写工作单位" clearable></el-input>
</el-form-item> </el-form-item>
</template> </template>
</el-table-column> </el-table-column>
@@ -235,9 +236,9 @@ layout("/layouts/platform.html"){
<template slot-scope="{row,$index}"> <template slot-scope="{row,$index}">
<el-form-item label-width="0" <el-form-item label-width="0"
:prop="'familyList.'+$index+'.yearIncome'" :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="年收入" <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> </el-form-item>
</template> </template>
</el-table-column> </el-table-column>
@@ -245,8 +246,8 @@ layout("/layouts/platform.html"){
<template slot-scope="{row,$index}"> <template slot-scope="{row,$index}">
<el-form-item label-width="0" <el-form-item label-width="0"
:prop="'familyList.'+$index+'.remarks'" :prop="'familyList.'+$index+'.remarks'"
:rules="[{required:true,message:'必填',trigger:['change','blur']}]"> :rules="textRules('备注', 500)">
<el-input v-model="row.remarks" placeholder="请填写备注" clearable></el-input> <el-input v-model="row.remarks" maxlength="500" placeholder="请填写备注" clearable></el-input>
</el-form-item> </el-form-item>
</template> </template>
</el-table-column> </el-table-column>
@@ -326,23 +327,37 @@ layout("/layouts/platform.html"){
applyCount: '1', applyCount: '1',
}, },
formRules: { formRules: {
proxyUserName: [{required: true, message: '必填', trigger: ['blur', 'change']}], proxyUserName: this.textRules('填写人姓名', 100),
proxyLoginName: [{required: true, message: '必填', trigger: ['blur', 'change']}], proxyLoginName: this.textRules('填写人工号', 100),
sex: [{required: true, message: '请填写性别', trigger: ['blur', 'change']}], mode: [{required: true, type: 'enum', enum: ['1', '2'], message: '请选择申请模式', trigger: ['blur', 'change']}],
userName: [{required: true, message: '请填写受补助人', trigger: ['blur', 'change']}], sex: [{required: true, type: 'enum', enum: ['男', '女'], message: '请选择性别', trigger: ['blur', 'change']}],
unitName: [{required: true, message: '请填写所属单位', trigger: ['blur', 'change']}], userId: [{required: true, message: '请选择受补助人', trigger: ['blur', 'change']}],
unitName: this.textRules('所属单位', 500),
unionName: [{required: true, message: '请填写所属工会', trigger: ['blur', 'change']}], unionName: [{required: true, message: '请填写所属工会', trigger: ['blur', 'change']}],
homeAddress: [{required: true, message: '请填写家庭住址', trigger: ['blur', 'change']}], homeAddress: this.textRules('家庭住址', 200),
mobile: [{required: true, message: '请填写手机号码', trigger: ['blur', 'change']}], mobile: [
idCard: [{required: true, message: '请填写身份证号', trigger: ['blur', 'change']}], {required: true, message: '请填写手机号码', trigger: ['blur', 'change']},
bankCardNum: [{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']}], bankUserName: [{required: true, message: '请填写户名', trigger: ['blur', 'change']}],
bankOfDeposit: [{required: true, message: '请填写开户行', trigger: ['blur', 'change']}], bankOfDeposit: this.textRules('开户行', 30),
position: [{required: true, message: '请填写职务', trigger: ['blur', 'change']}], position: this.textRules('职务', 20),
officialCapacity: [{required: true, message: '请填写职称', trigger: ['blur', 'change']}], officialCapacity: this.textRules('职称', 20),
subsidyStandards: [{required: true, message: '请选择困难类型', trigger: ['blur', 'change']}], homeIncome: [{validator: this.validateIncome, required: true, trigger: ['blur', 'change']}],
reason: [{required: true, message: '请填写申请原因', trigger: ['blur', 'change']}], homeNumber: [{required: true, type: 'integer', min: 1, message: '家庭人数须为大于零的整数', trigger: ['blur', 'change']}],
sign: [{required: true, 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: { 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("您确定保存吗?", "提示", { this.$confirm("您确定保存吗?", "提示", {
confirmButtonText: "确定", confirmButtonText: "确定",
cancelButtonText: "取消", cancelButtonText: "取消",
@@ -368,24 +396,13 @@ layout("/layouts/platform.html"){
// 提交 // 提交
async onSubmit() { async onSubmit() {
this.$refs.formRef.validate().then((valid) => { this.$refs.formRef.validate().catch(() => false).then((valid) => {
if (valid) { if (valid) {
this.$confirm("您确定要提交吗?", "提示", { this.$confirm("您确定要提交吗?", "提示", {
confirmButtonText: "确定", confirmButtonText: "确定",
cancelButtonText: "取消", cancelButtonText: "取消",
type: "warning" type: "warning"
}).then(() => { }).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', { this.$axios.post('/platform/difficultHelp/apply/submit', {
data: JSON.stringify(this.formData) data: JSON.stringify(this.formData)
}).then(res => { }).then(res => {
@@ -400,7 +417,7 @@ layout("/layouts/platform.html"){
}, },
async onFinishTask() { async onFinishTask() {
this.$refs.formRef.validate().then((valid) => { this.$refs.formRef.validate().catch(() => false).then((valid) => {
if (valid) { if (valid) {
this.$confirm("您确定要提交吗?", "提示", { this.$confirm("您确定要提交吗?", "提示", {
confirmButtonText: "确定", confirmButtonText: "确定",
@@ -408,17 +425,6 @@ layout("/layouts/platform.html"){
type: "warning" type: "warning"
}).then(() => { }).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', { this.$axios.post('/platform/difficultHelp/apply/submitAgain', {
data: JSON.stringify(this.formData), data: JSON.stringify(this.formData),
taskId: GetQueryString("taskId") taskId: GetQueryString("taskId")
@@ -88,7 +88,8 @@ layout("/layouts/platform.html"){
</template> </template>
<template #edit> <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-form-item label="沿用往期福利" v-if="!formData.id">
<el-select clearable filterable placeholder="请选择往期福利" style="width: 100%" <el-select clearable filterable placeholder="请选择往期福利" style="width: 100%"
v-model="reuseProjectId" @change="reuseHistoryProject"> v-model="reuseProjectId" @change="reuseHistoryProject">
@@ -294,10 +295,12 @@ layout("/layouts/platform.html"){
welfareFormRules() { welfareFormRules() {
// 慰问金只要求项目名称和单个发放时间,其他发放形式沿用原有校验。 // 慰问金只要求项目名称和单个发放时间,其他发放形式沿用原有校验。
const requiredForOtherMode = this.formData.provideMode !== 99 const requiredForOtherMode = this.formData.provideMode !== 99
// 慰问金在提交时统一校验,输入项目名称或切换表单项时不提前提示。
const requiredTrigger = requiredForOtherMode ? ["blur", "change"] : "submit"
return { return {
name: [{ required: true, message: "必填", trigger: ["blur", "change"] }], name: [{ required: true, message: "必填", trigger: requiredTrigger }],
festival: [{ required: requiredForOtherMode, message: "必选", trigger: ["blur", "change"] }], 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"] }], choiceTime: [{ required: requiredForOtherMode, message: "必选", trigger: ["blur", "change"] }],
provideAddress: [{ required: requiredForOtherMode, message: "必选", trigger: ["blur", "change"] }], provideAddress: [{ required: requiredForOtherMode, message: "必选", trigger: ["blur", "change"] }],
provideMode: [{ required: true, message: "必选", trigger: ["blur", "change"] }], provideMode: [{ required: true, message: "必选", trigger: ["blur", "change"] }],
@@ -410,6 +413,9 @@ layout("/layouts/platform.html"){
this.$set(this, "reuseProjectId", null) this.$set(this, "reuseProjectId", null)
if (this.$refs.form) this.$refs.form.resetFields() if (this.$refs.form) this.$refs.form.resetFields()
this.$set(this, "formData", { this.$set(this, "formData", {
name: "",
choiceTime: [],
provideTime: null,
provideMode: 99, provideMode: 99,
isPushHome: false, isPushHome: false,
signMode: 4, signMode: 4,
@@ -77,7 +77,7 @@ layout("/layouts/platform_h5.html"){
<van-cell title="会长">{{ clubDetail.clubLeader || '--' }}</van-cell> <van-cell title="会长">{{ clubDetail.clubLeader || '--' }}</van-cell>
<van-cell title="秘书长">{{ clubDetail.clubSecretary || '--' }}</van-cell> <van-cell title="秘书长">{{ clubDetail.clubSecretary || '--' }}</van-cell>
<van-cell title="当前人数">{{ clubDetail.currentPeopleNum || 0 }} 人</van-cell> <van-cell title="当前人数">{{ clubDetail.currentPeopleNum || 0 }} 人</van-cell>
<van-cell title="会费标准">{{ clubDetail.due || '--' }}</van-cell> <van-cell title="会费标准">{{ clubDetail.due === null || clubDetail.due === undefined ? '--' : clubDetail.due + ' 元/年' }}</van-cell>
<van-cell title="社团介绍" class="club-user-join-info-long-cell"><div v-if="hasClubContent(clubDetail.introduce)" v-html="clubDetail.introduce"></div><span v-else>暂无</span></van-cell> <van-cell title="社团介绍" class="club-user-join-info-long-cell"><div v-if="hasClubContent(clubDetail.introduce)" v-html="clubDetail.introduce"></div><span v-else>暂无</span></van-cell>
<van-cell title="社团宗旨" class="club-user-join-info-long-cell"><div v-if="hasClubContent(clubDetail.purpose)" v-html="clubDetail.purpose"></div><span v-else>暂无</span></van-cell> <van-cell title="社团宗旨" class="club-user-join-info-long-cell"><div v-if="hasClubContent(clubDetail.purpose)" v-html="clubDetail.purpose"></div><span v-else>暂无</span></van-cell>
<van-cell title="社团章程" class="club-user-join-info-long-cell"><file-preview v-if="clubDetail.rulesFile && clubDetail.rulesFile.length" :files="clubDetail.rulesFile" complete_result></file-preview><span v-else>暂无</span></van-cell> <van-cell title="社团章程" class="club-user-join-info-long-cell"><file-preview v-if="clubDetail.rulesFile && clubDetail.rulesFile.length" :files="clubDetail.rulesFile" complete_result></file-preview><span v-else>暂无</span></van-cell>
@@ -255,7 +255,7 @@ layout("/layouts/platform_h5.html"){
<van-empty description="暂无数据" v-else></van-empty> <van-empty description="暂无数据" v-else></van-empty>
</van-action-sheet>--> </van-action-sheet>-->
<van-field <!-- <van-field
v-model="formData.certifierUserName" v-model="formData.certifierUserName"
name="certifierUserName" name="certifierUserName"
label="证明人" label="证明人"
@@ -288,7 +288,7 @@ layout("/layouts/platform_h5.html"){
</template> </template>
</div> </div>
<van-empty description="暂无数据" v-else></van-empty> <van-empty description="暂无数据" v-else></van-empty>
</van-action-sheet> </van-action-sheet> -->
<van-field <van-field
v-model="formData.typeName" v-model="formData.typeName"
@@ -322,7 +322,7 @@ layout("/layouts/platform_h5.html"){
name="money" name="money"
label="慰问金额" label="慰问金额"
required required
:rules="[{ required: true }]" :rules="[{ required: true, message: '请填写慰问金额' }, { validator: validateMoney, message: '慰问金额须大于0且不超过10000元,最多两位小数' }]"
placeholder="选择慰问类型自动匹配金额" placeholder="选择慰问类型自动匹配金额"
clickable clickable
type="number" type="number"
@@ -353,17 +353,19 @@ layout("/layouts/platform_h5.html"){
</van-popup> </van-popup>
<van-field <van-field
v-if="!['A', 'D'].includes(formData.typeCode)"
v-model="formData.bankCardNumber" v-model="formData.bankCardNumber"
label="收款账户" label="收款账户"
maxlength="20" maxlength="30"
clearable clearable
name="bankCardNumber" name="bankCardNumber"
placeholder="请填写收款账户" placeholder="请填写银行卡号或对公账户号"
:rules="[{ required: true }]" :rules="[{ required: true, message: '请填写银行卡号或对公账户号' }, { pattern: /^[0-9]{6,30}$/, message: '收款账户须为6至30位数字(银行卡号或对公账户号)' }]"
required required
></van-field> ></van-field>
<van-field <van-field
v-if="!['A', 'D'].includes(formData.typeCode)"
v-model="formData.bankUserName" v-model="formData.bankUserName"
label="户名" label="户名"
placeholder="请填写户名" placeholder="请填写户名"
@@ -373,9 +375,10 @@ layout("/layouts/platform_h5.html"){
name="bankUserName" name="bankUserName"
></van-field> ></van-field>
<van-field <van-field
v-if="!['A', 'D'].includes(formData.typeCode)"
v-model="formData.bankOfDeposit" v-model="formData.bankOfDeposit"
label="开户行" label="开户行"
maxlength="30" maxlength="20"
type="textarea" type="textarea"
rows="4" rows="4"
autosize autosize
@@ -433,8 +436,8 @@ layout("/layouts/platform_h5.html"){
></van-datetime-picker> ></van-datetime-picker>
</van-popup> </van-popup>
<van-field type="digit" label="当年第几次住院" <van-field type="digit" label="当年第几次住院" name="thisYearHospitalizationNum"
:rules="[{ required: true }]" :rules="[{ required: true }, { validator: value => /^[0-9]+$/.test(String(value)) && Number(value) <= 10000, message: '住院次数须为0至10000的整数' }]"
required required
placeholder="请输入当年第几次住院" placeholder="请输入当年第几次住院"
v-model="formData.thisYearHospitalizationNum"></van-field> v-model="formData.thisYearHospitalizationNum"></van-field>
@@ -443,6 +446,7 @@ layout("/layouts/platform_h5.html"){
<van-field <van-field
v-model="formData.remark" v-model="formData.remark"
name="remark" name="remark"
maxlength="500"
label="申请事由" label="申请事由"
type="textarea" type="textarea"
rows="4" rows="4"
@@ -460,15 +464,18 @@ layout("/layouts/platform_h5.html"){
{{ '附件说明:' + chooseType.uploadFileDesc }} {{ '附件说明:' + chooseType.uploadFileDesc }}
</div> </div>
<van-cell-group :border="false"> <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> <template #input>
<h5-file-upload <h5-file-upload
slot="input" slot="input"
:value.sync="formData.files" :value.sync="formData.files"
:upload_number="5" :upload_number="5"
upload_text="上传附件"
upload_result_category="array" upload_result_category="array"
complete_result complete_result
></h5-file-upload> ></h5-file-upload>
<div class="condolence-card__desc">最多上传 5 个文件</div>
</template> </template>
</van-field> </van-field>
</van-cell-group> </van-cell-group>
@@ -477,7 +484,8 @@ layout("/layouts/platform_h5.html"){
<section class="form-section"> <section class="form-section">
<h2 class="condolence-card__title">签字确认</h2> <h2 class="condolence-card__title">签字确认</h2>
<van-cell-group :border="false"> <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> <template #input>
<h5-signature v-model="formData.signature" slot="input"></h5-signature> <h5-signature v-model="formData.signature" slot="input"></h5-signature>
</template> </template>
@@ -532,6 +540,9 @@ layout("/layouts/platform_h5.html"){
} }
}, },
methods: { methods: {
validateMoney(value) {
return /^(0|[1-9][0-9]*)(\.[0-9]{1,2})?$/.test(String(value)) && Number(value) > 0 && Number(value) <= 10000
},
// 选择出院时间 // 选择出院时间
showLeaveHospitalTimePickerClick() { showLeaveHospitalTimePickerClick() {
if (this.formData.leaveHospitalTime) { if (this.formData.leaveHospitalTime) {
@@ -55,7 +55,7 @@ layout("/layouts/platform_h5.html"){
<div id="app"> <div id="app">
<div class="condolence-list-page"> <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"> <van-sticky offset-top="46px">
<div class="condolence-list-sticky"> <div class="condolence-list-sticky">
@@ -148,11 +148,17 @@ layout("/layouts/platform_h5.html"){
maxlength="100" maxlength="100"
show-word-limit show-word-limit
></van-field> ></van-field>
<h2 class="condolence-info-card__title">
审批签字<span class="condolence-audit-card__required">*</span>
</h2>
<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> </van-form>
</section> </section>
<div class="condolence-popup-actions"> <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(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> <van-button class="condolence-action-primary" type="primary" block :loading="formLoading" @click="handleTaskAction(1)">同意</van-button>
</div> </div>
</div> </div>
@@ -227,6 +233,7 @@ layout("/layouts/platform_h5.html"){
this.$set(this, "formData", { this.$set(this, "formData", {
processTaskId: row.taskId, processTaskId: row.taskId,
taskName: row.curTaskName, taskName: row.curTaskName,
tf_sign: "",
tf_opinion: "" tf_opinion: ""
}) })
this.$refs.infoRef.onOpen(row, row.curTaskName || "慰问审核") this.$refs.infoRef.onOpen(row, row.curTaskName || "慰问审核")
@@ -252,10 +259,10 @@ layout("/layouts/platform_h5.html"){
this.submitTaskAction() this.submitTaskAction()
}) })
}, },
// 提交 processTaskId、taskName、tf_opinion 和 submitType,接口返回统一响应对象。 // 提交 processTaskId、taskName、tf_opinion、tf_sign 和 submitType,接口返回统一响应对象。
submitTaskAction() { submitTaskAction() {
this.$set(this, "formLoading", true) this.$set(this, "formLoading", true)
this.$axios.post("/flow/common/executeTask", { this.$axios.post("/platform/condolence/approval/executeTask", {
data: JSON.stringify({ data: JSON.stringify({
...this.formData, ...this.formData,
submitType: this.pendingSubmitType submitType: this.pendingSubmitType
@@ -158,19 +158,44 @@
} }
.condolence-info-long-cell { .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; display: block;
} width: 100%;
.condolence-info-long-cell .van-cell__title {
width: auto;
max-width: 100%; max-width: 100%;
height: 80px;
} }
.condolence-info-long-cell .van-cell__value { .condolence-signature-image .van-image__img,
margin-top: 7px; .condolence-approval-signature-image {
object-fit: contain;
object-position: left center;
} }
.condolence-signature-image { .condolence-info-opinion img {
display: block;
max-width: 100%; max-width: 100%;
height: auto;
} }
@@ -25,13 +25,13 @@ const condolenceInfo = {
<van-cell title="申请人单位">{{ viewData.applyUnitName || '--' }}</van-cell> <van-cell title="申请人单位">{{ viewData.applyUnitName || '--' }}</van-cell>
<van-cell title="申请人工会">{{ viewData.applyUnionName || '--' }}</van-cell> <van-cell title="申请人工会">{{ viewData.applyUnionName || '--' }}</van-cell>
<van-cell title="慰问对象">{{ viewData.helpUserName || '--' }}</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.typeName || '--' }}</van-cell>
<van-cell title="慰问金额">{{ viewData.money || '--' }}</van-cell> <van-cell title="慰问金额">{{ viewData.money || '--' }}</van-cell>
<van-cell title="慰问时间">{{ viewData.occurTime || '--' }}</van-cell> <van-cell title="慰问时间">{{ viewData.occurTime || '--' }}</van-cell>
<van-cell title="收款账户">{{ viewData.bankCardNumber || '--' }}</van-cell> <van-cell title="收款账户">{{ viewData.bankCardNumber || '--' }}</van-cell>
<van-cell title="户名">{{ viewData.bankUserName || '--' }}</van-cell> <van-cell title="户名">{{ viewData.bankUserName || '--' }}</van-cell>
<van-cell class="condolence-info-long-cell" title="开户行"> <van-cell title="开户行">
{{ viewData.bankOfDeposit || '暂无' }} {{ viewData.bankOfDeposit || '暂无' }}
</van-cell> </van-cell>
<template v-if="['2'].includes(viewData.typeCode)"> <template v-if="['2'].includes(viewData.typeCode)">
@@ -39,17 +39,18 @@ const condolenceInfo = {
<van-cell title="出院时间">{{ viewData.leaveHospitalTime || '--' }}</van-cell> <van-cell title="出院时间">{{ viewData.leaveHospitalTime || '--' }}</van-cell>
<van-cell title="当年第几次住院(次)">{{ viewData.thisYearHospitalizationNum || '--' }}</van-cell> <van-cell title="当年第几次住院(次)">{{ viewData.thisYearHospitalizationNum || '--' }}</van-cell>
</template> </template>
<van-cell class="condolence-info-long-cell" title="申请事由"> <van-cell title="申请事由">
{{ viewData.remark || '暂无' }} {{ viewData.remark || '暂无' }}
</van-cell> </van-cell>
<van-cell class="condolence-info-long-cell" title="附件"> <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>
<van-cell class="condolence-info-long-cell" title="签字"> <van-cell class="condolence-info-long-cell" title="签字">
<van-image <van-image
v-if="viewData.signature" v-if="viewData.signature"
:src="viewData.signature" :src="viewData.signature"
class="condolence-signature-image" class="condolence-signature-image"
fit="contain"
></van-image> ></van-image>
<span v-else>暂无签字</span> <span v-else>暂无签字</span>
</van-cell> </van-cell>
@@ -85,8 +86,11 @@ const condolenceInfo = {
:value="task.ext && task.ext.submitType" :value="task.ext && task.ext.submitType"
></dict-tag> ></dict-tag>
</van-cell> </van-cell>
<van-cell class="condolence-info-long-cell" title="办理意见"> <van-cell v-if="task.taskFormData && task.taskFormData.sign" title="审批签字">
<div v-html="task.taskFormData && task.taskFormData.tf_opinion || '暂无'"></div> <img :src="task.taskFormData.sign" class="condolence-approval-signature-image">
</van-cell>
<van-cell title="办理意见">
<div class="condolence-info-opinion" v-html="task.taskFormData && task.taskFormData.tf_opinion || '暂无'"></div>
</van-cell> </van-cell>
</van-cell-group> </van-cell-group>
</section> </section>
@@ -0,0 +1,367 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<style>
#app { --color-primary: #1989fa; }
.condolence-list-page { min-height: 100vh; padding-bottom: calc(24px + env(safe-area-inset-bottom)); box-sizing: border-box; background: #f3f7fd; }
.condolence-list-page .van-nav-bar, .condolence-list-page .van-sticky--fixed { background: #fff; }
.condolence-list-page .van-nav-bar__title { color: #172033; font-size: 16px; font-weight: 600; }
.condolence-list-page .van-nav-bar .van-icon, .condolence-list-page .van-nav-bar__text { color: #1989fa; }
.condolence-list-sticky { padding: 22px 12px 1px; box-sizing: border-box; background: #f3f7fd; }
.condolence-list-search { margin: 0 0 8px; padding: 0; overflow: hidden; border-radius: 12px; background: #fff; box-shadow: 0 5px 16px rgba(43, 73, 112, .1); }
.condolence-list-search .van-search__content { height: 42px; padding-left: 12px; align-items: center; border-radius: 12px; background: #fff; }
.condolence-list-search .van-field__control { color: #263548; font-size: 14px; }
.condolence-list-search .van-field__control::placeholder { color: #a7b0bf; }
.condolence-list-filter { margin-bottom: 8px; overflow: hidden; border-radius: 10px; box-shadow: 0 5px 16px rgba(43, 73, 112, .08); }
.condolence-list-filter .van-dropdown-menu__bar { height: 44px; box-shadow: none; }
.condolence-list-filter .van-dropdown-menu__item, .condolence-list-filter .van-dropdown-menu__title { height: 44px; }
.condolence-list-filter .van-dropdown-menu__item { align-items: center; justify-content: center; }
.condolence-list-filter .van-dropdown-menu__title { display: inline-flex; align-items: center; line-height: 20px; color: #66758a; font-size: 14px; }
.condolence-list-filter .van-dropdown-menu__title--active, .condolence-list-filter .van-dropdown-item__option--active, .condolence-list-filter .van-dropdown-item__option--active .van-dropdown-item__icon { color: #1989fa; }
.condolence-list-filter .van-dropdown-item__content { width: calc(100% - 24px); margin: 0 12px; overflow: hidden; box-sizing: border-box; border-radius: 0 0 12px 12px; box-shadow: 0 10px 24px rgba(31, 65, 108, .14); }
.condolence-list-filter .van-dropdown-item__option { display: flex; min-height: 48px; padding: 0 16px; align-items: center; color: #46566c; font-size: 14px; }
.condolence-list-filter .van-dropdown-item__option .van-cell__title { display: flex; min-height: 20px; align-items: center; line-height: 20px; }
.condolence-list-content { padding: 0 12px; }
.condolence-list-skeleton { padding-top: 2px; }
.condolence-list-skeleton__card { margin-bottom: 12px; padding: 18px 14px 14px; border: 1px solid #e9eff7; border-radius: 12px; background: #fff; box-shadow: 0 5px 16px rgba(43, 73, 112, .08); }
.condolence-list-skeleton__card .van-skeleton { padding: 0; }
.condolence-list-skeleton__card .van-skeleton__title { width: 42%; height: 18px; margin-bottom: 18px; }
.condolence-list-skeleton__card .van-skeleton__row { height: 14px; margin-top: 12px; }
.condolence-list-skeleton__card .van-skeleton__row:nth-child(2n) { width: 72% !important; }
.condolence-list-skeleton__card .van-skeleton__row:last-child { width: 34% !important; margin-top: 18px; margin-left: auto; }
.condolence-list-page .table-list-container { margin-top: 0; padding-bottom: 2px; }
.condolence-list-page .table-list-container .table-list-item { margin-bottom: 12px; padding: 14px; border: 1px solid #e9eff7; border-radius: 12px; box-shadow: 0 5px 16px rgba(43, 73, 112, .08); }
.condolence-card-header { display: flex; margin-bottom: 8px; align-items: flex-start; justify-content: space-between; }
.condolence-card-heading { min-width: 0; padding-right: 10px; flex: 1; }
.condolence-card-title { overflow: hidden; color: #253247; font-size: 15px; font-weight: 600; line-height: 22px; text-overflow: ellipsis; white-space: nowrap; }
.condolence-card-code { margin-top: 1px; color: #9ba6b6; font-size: 12px; line-height: 18px; }
.condolence-card-status { display: inline-flex; flex: none; }
.condolence-card-status .van-tag { min-height: 24px; padding: 3px 9px; border: 0; border-radius: 12px; box-sizing: border-box; font-size: 12px; line-height: 18px; }
.condolence-list-page .table-list-container .table-list-item .item-actions { margin-top: 10px; padding-top: 10px; border-top-color: #edf1f6; }
.condolence-list-page .table-list-container .table-list-item .action-btn { min-height: 28px; padding: 4px 11px; border-radius: 14px; color: #1989fa; background: #eaf4ff; font-size: 12px; line-height: 18px; }
.condolence-list-page .table-list-container .table-list-item .action-btn.delete { color: #f04444; background: #fff0f0; }
.condolence-list-page .table-list-container .table-list-item .action-btn i { margin-right: 5px; font-size: 13px; }
.condolence-list-page .empty-state { padding: 0; }
.condolence-list-empty { display: flex; min-height: 280px; padding: 46px 18px 36px; align-items: center; justify-content: center; box-sizing: border-box; flex-direction: column; text-align: center; }
.condolence-list-empty img { display: block; width: 86%; max-width: 290px; height: auto; object-fit: contain; }
.condolence-list-empty__title { margin-top: 14px; color: #50627a; font-size: 15px; font-weight: 600; line-height: 22px; }
.condolence-list-empty__hint { margin-top: 5px; color: #9aa7b8; font-size: 12px; line-height: 18px; }
</style>
<style id="style-condolence-info-h5">
<!--#include("../common/info.css"){}#-->
</style>
<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-sticky offset-top="46px">
<div class="condolence-list-sticky">
<van-search
v-model="pageForm.searchKeyword"
class="condolence-list-search"
placeholder="请输入姓名或者工号搜索"
shape="round"
clearable
@search="doSearch"
@clear="doSearch"
></van-search>
<van-dropdown-menu class="condolence-list-filter" :close-on-click-outside="false" :close-on-click-overlay="false">
<year-van-dropdown-item :num="5" @change="doSearch" v-model="pageForm.year"></year-van-dropdown-item>
<van-dropdown-item v-model="pageForm.type" :options="typeOptions" :multiple="false"
@change="doSearch"></van-dropdown-item>
<van-dropdown-item v-model="pageForm.approvalText" :options="approvalOptions" :multiple="false"
@change="onApprovalFilterChange"></van-dropdown-item>
</van-dropdown-menu>
</div>
</van-sticky>
<main class="condolence-list-content">
<div class="condolence-list-skeleton" v-if="skeletonLoading">
<div class="condolence-list-skeleton__card" v-for="index in 3" :key="'fourth-approval-skeleton-' + index">
<van-skeleton title :row="5" animated></van-skeleton>
</div>
</div>
<table-list api="/platform/condolence/fourthApproval/pageData" :page_form.sync="pageForm" ref="tableListRef"
v-show="!skeletonLoading" :show_loading_overlay="false" @ready="onReady"
@loading-change="handleListLoadingChange">
<template #header="{index,row}">
<div class="condolence-card-header">
<div class="condolence-card-heading">
<div class="condolence-card-title">{{row.helpUserName || '--'}}</div>
<div class="condolence-card-code">工号:{{row.helpLoginName || '--'}}</div>
</div>
<enum-tag class="condolence-card-status" v-if="row.instanceState" :value="row.instanceState"
name="ProcessInstanceStateEnum" label_key="message" size="small"></enum-tag>
</div>
</template>
<template v-slot="{index,row}">
<table-column label="慰问对象">{{row.helpUserName}}</table-column>
<table-column label="慰问对象工号">{{row.helpLoginName}}</table-column>
<table-column label="申请人">{{row.applyUserName}}</table-column>
<table-column label="所属工会">{{row.applyUnionName}}</table-column>
<table-column label="所属单位">{{row.applyUnitName}}</table-column>
<table-column label="慰问类型">{{row.typeName}}</table-column>
<table-column label="当前节点">{{row.curTaskName}}</table-column>
</template>
<template #actions="{index,row}">
<div class="action-btn" @click="onView(row)">
<van-icon name="eye-o"></van-icon>
<span>查看</span>
</div>
<div class="action-btn" v-if="row.taskState === 10" @click="onApproval(row)">
<van-icon name="edit"></van-icon>
<span>审核</span>
</div>
<div class="action-btn delete" v-if="row.canRevoke" @click="onRevoke(row)">
<van-icon name="revoke"></van-icon>
<span>撤回</span>
</div>
</template>
<template #empty>
<div class="condolence-list-empty">
<img src="/assets/mobile/img/condolence/condolence-empty.png" alt="暂无慰问审核记录插画">
<div class="condolence-list-empty__title">暂无慰问审核记录</div>
<div class="condolence-list-empty__hint">当前筛选条件下暂时没有慰问申请</div>
</div>
</template>
</table-list>
</main>
<info ref="infoRef" page-key="condolence-fourth-detail">
<div v-if="showApprovalForm">
<section class="condolence-info-card">
<h2 class="condolence-info-card__title">
审批意见<span class="condolence-audit-card__required">*</span>
</h2>
<van-form ref="formRef">
<van-field
v-model="formData.tf_opinion"
class="condolence-audit-field"
name="tf_opinion"
type="textarea"
rows="6"
placeholder="请输入审批意见"
:rules="[{ required: true, message: '请填写审批意见' }]"
maxlength="100"
show-word-limit
></van-field>
<h2 class="condolence-info-card__title">
审批签字<span class="condolence-audit-card__required">*</span>
</h2>
<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(2)">不同意</van-button>
<van-button class="condolence-action-primary" type="primary" block :loading="formLoading" @click="handleTaskAction(1)">同意</van-button>
</div>
</div>
</info>
<van-dialog
:value="confirmVisible"
title="提示"
:message="confirmActionType === 'revoke' ? '您确定要撤回吗?' : '您确定要提交吗?'"
show-cancel-button
confirm-button-color="#1989fa"
@confirm="confirmPendingAction"
@cancel="closeActionConfirm"
></van-dialog>
</div>
</div>
<script nonce="${cspNonce!}">
<!--#include('../common/info.js'){}#-->
new Vue({
el: "#app",
store,
components: {
info: condolenceInfo
},
data() {
return {
pageForm: {
pageNumber: 1,
pageSize: 10,
totalCount: 0,
searchKeyword: '',
approvalText: "0",
approval: false,
year: new Date().getFullYear(),
type: null,
},
typeOptions: [],
approvalOptions: [
{text: "未审核", value: "0"},
{text: "已审核", value: "1"}
],
skeletonLoading: true,
formData: {},
showApprovalForm: false,
formLoading: false,
confirmVisible: false,
confirmActionType: "task",
pendingSubmitType: null,
pendingRevokeRow: null,
historyLayerUnsubscribe: null
}
},
methods: {
// state.initial 表示当前查询前列表为空,仅此阶段使用整组卡片骨架屏。
handleListLoadingChange(state) {
if (state.initial) this.$set(this, "skeletonLoading", state.loading)
},
// 审核状态下拉值为字符串,接口参数 approval 继续使用布尔值。
onApprovalFilterChange(val) {
this.$set(this.pageForm, "approval", val === "1")
this.doSearch()
},
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)
this.$refs.infoRef.onOpen(row, "慰问详情")
},
onApproval(row) {
this.$set(this, "showApprovalForm", true)
this.$set(this, "formData", {
processTaskId: row.taskId,
taskName: row.curTaskName,
tf_sign: "",
tf_opinion: ""
})
this.$refs.infoRef.onOpen(row, row.curTaskName || "慰问审核")
},
// val 为流程提交类型:1 同意、20 不同意、6 退回;校验通过后打开历史栈确认框。
handleTaskAction(val) {
this.$refs.formRef.validate()
.then(() => {
this.$set(this, "confirmActionType", "task")
this.$set(this, "pendingSubmitType", val)
if (window.h5HistoryLayerManager && window.h5HistoryLayerManager.open("condolence-action-confirm")) return
this.$set(this, "confirmVisible", true)
})
.catch(() => {})
},
// 确认操作时先回退确认框历史,再根据操作类型调用办理或撤回接口。
confirmPendingAction() {
this.closeActionConfirm(() => {
if (this.confirmActionType === "revoke") {
this.revokeTask()
return
}
this.submitTaskAction()
})
},
// 提交 processTaskId、taskName、tf_opinion、tf_sign 和 submitType,接口返回统一响应对象。
submitTaskAction() {
this.$set(this, "formLoading", true)
this.$axios.post("/platform/condolence/approval/executeTask", {
data: JSON.stringify({
...this.formData,
submitType: this.pendingSubmitType
})
})
.then((res) => {
if (res && res.code === 0) {
const afterClose = () => {
this.$toast.success(res.msg)
this.doSearch()
}
if (window.h5HistoryLayerManager && window.h5HistoryLayerManager.closeAll(afterClose)) return
this.$refs.infoRef.onClose(afterClose)
}
})
.finally(() => {
this.$set(this, "formLoading", false)
})
},
closeActionConfirm(afterClose = null) {
if (window.h5HistoryLayerManager && window.h5HistoryLayerManager.close("condolence-action-confirm", afterClose)) return
this.$set(this, "confirmVisible", false)
if (typeof afterClose === "function") afterClose()
},
// row 提供待撤回任务 taskId,确认后保存当前行并由统一确认框执行撤回。
onRevoke(row){
this.$set(this, "confirmActionType", "revoke")
this.$set(this, "pendingRevokeRow", row)
if (window.h5HistoryLayerManager && window.h5HistoryLayerManager.open("condolence-action-confirm")) return
this.$set(this, "confirmVisible", true)
},
// 使用 pendingRevokeRow.taskId 撤回任务,接口返回统一响应对象。
revokeTask() {
if (!this.pendingRevokeRow) return
this.$set(this, "formLoading", true)
this.$axios.post("/flow/common/revokeTask", {taskId: this.pendingRevokeRow.taskId})
.then((res) => {
if (res.code === 0) {
this.$toast.success(res.msg)
this.doSearch()
}
})
.finally(() => {
this.$set(this, "formLoading", false)
})
},
queryCondolenceType() {
this.$axios.post('/platform/condolence/type/queryCondolenceType', {}, {h5SkeletonLoading: true})
.then((res) => {
const typeList = res.data || []
this.$set(this, "typeOptions", [
{
text: "全部慰问类型",
value: null
}
].concat(typeList.map((v) => ({ text: v.name, value: v.id }))))
if (this.typeOptions.length > 0) {
this.$set(this.pageForm, "type", this.typeOptions[0].value)
this.doSearch()
}
})
},
doSearch() {
this.$nextTick(() => {
this.pageForm.pageNumber = 1
this.pageForm.totalCount = 0
this.$refs.tableListRef.doSearch()
})
}
},
created() {
if (!window.h5HistoryLayerManager) return
this.$set(this, "historyLayerUnsubscribe", window.h5HistoryLayerManager.subscribe((stack) => {
this.$set(this, "confirmVisible", stack.includes("condolence-action-confirm"))
}))
},
beforeDestroy() {
if (typeof this.historyLayerUnsubscribe === "function") this.historyLayerUnsubscribe()
}
})
</script>
<!--#
}
#-->
@@ -55,7 +55,7 @@ layout("/layouts/platform_h5.html"){
<div id="app"> <div id="app">
<div class="condolence-list-page"> <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"> <van-sticky offset-top="46px">
<div class="condolence-list-sticky"> <div class="condolence-list-sticky">
@@ -148,11 +148,17 @@ layout("/layouts/platform_h5.html"){
maxlength="100" maxlength="100"
show-word-limit show-word-limit
></van-field> ></van-field>
<h2 class="condolence-info-card__title">
审批签字<span class="condolence-audit-card__required">*</span>
</h2>
<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> </van-form>
</section> </section>
<div class="condolence-popup-actions"> <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(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> <van-button class="condolence-action-primary" type="primary" block :loading="formLoading" @click="handleTaskAction(1)">同意</van-button>
</div> </div>
</div> </div>
@@ -227,6 +233,7 @@ layout("/layouts/platform_h5.html"){
this.$set(this, "formData", { this.$set(this, "formData", {
processTaskId: row.taskId, processTaskId: row.taskId,
taskName: row.curTaskName, taskName: row.curTaskName,
tf_sign: "",
tf_opinion: "" tf_opinion: ""
}) })
this.$refs.infoRef.onOpen(row, row.curTaskName || "慰问审核") this.$refs.infoRef.onOpen(row, row.curTaskName || "慰问审核")
@@ -252,10 +259,10 @@ layout("/layouts/platform_h5.html"){
this.submitTaskAction() this.submitTaskAction()
}) })
}, },
// 提交 processTaskId、taskName、tf_opinion 和 submitType,接口返回统一响应对象。 // 提交 processTaskId、taskName、tf_opinion、tf_sign 和 submitType,接口返回统一响应对象。
submitTaskAction() { submitTaskAction() {
this.$set(this, "formLoading", true) this.$set(this, "formLoading", true)
this.$axios.post("/flow/common/executeTask", { this.$axios.post("/platform/condolence/approval/executeTask", {
data: JSON.stringify({ data: JSON.stringify({
...this.formData, ...this.formData,
submitType: this.pendingSubmitType submitType: this.pendingSubmitType
@@ -55,7 +55,7 @@ layout("/layouts/platform_h5.html"){
<div id="app"> <div id="app">
<div class="condolence-list-page"> <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"> <van-sticky offset-top="46px">
<div class="condolence-list-sticky"> <div class="condolence-list-sticky">
@@ -148,11 +148,17 @@ layout("/layouts/platform_h5.html"){
maxlength="100" maxlength="100"
show-word-limit show-word-limit
></van-field> ></van-field>
<h2 class="condolence-info-card__title">
审批签字<span class="condolence-audit-card__required">*</span>
</h2>
<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> </van-form>
</section> </section>
<div class="condolence-popup-actions"> <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(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> <van-button class="condolence-action-primary" type="primary" block :loading="formLoading" @click="handleTaskAction(1)">同意</van-button>
</div> </div>
</div> </div>
@@ -227,6 +233,7 @@ layout("/layouts/platform_h5.html"){
this.$set(this, "formData", { this.$set(this, "formData", {
processTaskId: row.taskId, processTaskId: row.taskId,
taskName: row.curTaskName, taskName: row.curTaskName,
tf_sign: "",
tf_opinion: "" tf_opinion: ""
}) })
this.$refs.infoRef.onOpen(row, row.curTaskName || "慰问审核") this.$refs.infoRef.onOpen(row, row.curTaskName || "慰问审核")
@@ -252,10 +259,10 @@ layout("/layouts/platform_h5.html"){
this.submitTaskAction() this.submitTaskAction()
}) })
}, },
// 提交 processTaskId、taskName、tf_opinion 和 submitType,接口返回统一响应对象。 // 提交 processTaskId、taskName、tf_opinion、tf_sign 和 submitType,接口返回统一响应对象。
submitTaskAction() { submitTaskAction() {
this.$set(this, "formLoading", true) this.$set(this, "formLoading", true)
this.$axios.post("/flow/common/executeTask", { this.$axios.post("/platform/condolence/approval/executeTask", {
data: JSON.stringify({ data: JSON.stringify({
...this.formData, ...this.formData,
submitType: this.pendingSubmitType submitType: this.pendingSubmitType
@@ -9,7 +9,7 @@ layout("/layouts/platform_h5.html"){
</style> </style>
<div id="app"> <div id="app">
<div class="difficult-help-approval-page"> <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"> <van-sticky offset-top="46px">
<div class="difficult-help-approval-sticky"> <div class="difficult-help-approval-sticky">
<van-search <van-search
@@ -29,7 +29,7 @@ layout("/layouts/platform_h5.html"){
:close-on-click-overlay="false" :close-on-click-overlay="false"
> >
<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="unionList" :options="unionList"
@change="unionChange" @change="unionChange"
@open="onFilterOpen" @open="onFilterOpen"
@@ -37,7 +37,7 @@ layout("/layouts/platform_h5.html"){
v-model="pageForm.unionId"> v-model="pageForm.unionId">
</van-dropdown-item> </van-dropdown-item>
<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" :options="unitList"
@change="doSearch" @change="doSearch"
@open="onFilterOpen" @open="onFilterOpen"
@@ -228,6 +228,14 @@ layout("/layouts/platform_h5.html"){
components: { components: {
"info": INFO "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: { methods: {
historyBack, historyBack,
@@ -419,13 +427,22 @@ layout("/layouts/platform_h5.html"){
}, },
}, },
created() { created() {
this.initLevelDict() // 先订阅弹框状态,工会加载异常也不能阻断等级选择器与确认框的打开、返回。
this.initUnion().then(() => this.flushUnits()) if (window.h5HistoryLayerManager) {
if (!window.h5HistoryLayerManager) return
this.$set(this, "historyLayerUnsubscribe", window.h5HistoryLayerManager.subscribe((stack) => { this.$set(this, "historyLayerUnsubscribe", window.h5HistoryLayerManager.subscribe((stack) => {
this.$set(this, "confirmVisible", stack.includes("difficult-help-action-confirm")) this.$set(this, "confirmVisible", stack.includes("difficult-help-action-confirm"))
this.$set(this, "showLevelPicker", stack.includes("difficult-help-level-picker")) 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() { beforeDestroy() {
if (typeof this.historyLayerUnsubscribe === "function") this.historyLayerUnsubscribe() if (typeof this.historyLayerUnsubscribe === "function") this.historyLayerUnsubscribe()
@@ -9,7 +9,7 @@ layout("/layouts/platform_h5.html"){
</style> </style>
<div id="app"> <div id="app">
<div class="difficult-help-approval-page"> <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"> <van-sticky offset-top="46px">
<div class="difficult-help-approval-sticky"> <div class="difficult-help-approval-sticky">
<van-search <van-search
@@ -9,7 +9,7 @@ layout("/layouts/platform_h5.html"){
</style> </style>
<div id="app"> <div id="app">
<div class="difficult-help-approval-page"> <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"> <van-sticky offset-top="46px">
<div class="difficult-help-approval-sticky"> <div class="difficult-help-approval-sticky">
<van-search <van-search
@@ -1,4 +1,8 @@
const apps = { const apps = {
// 从“我的工具”进入时指定分类名称;空值表示恢复应用中心原有浏览状态。
props: {
initialCategoryName: {type: String, default: ""}
},
template: /*language=HTML*/ ` template: /*language=HTML*/ `
<div class="apps-container"> <div class="apps-container">
<van-nav-bar title="应用中心" placeholder fixed></van-nav-bar> <van-nav-bar title="应用中心" placeholder fixed></van-nav-bar>
@@ -201,7 +205,8 @@ const apps = {
}, },
mounted() { mounted() {
// 动态分类加载完成后再恢复分类,避免把缓存的动态分类误判为“全部应用” // 动态分类加载完成后再恢复分类,避免把缓存的动态分类误判为“全部应用”
this.restoreAppsState() // 指定入口不恢复旧搜索词、滚动位置及子菜单弹框,避免目标分类被旧状态遮挡。
if (!this.initialCategoryName) this.restoreAppsState()
this.loadCategories().finally(() => { this.loadCategories().finally(() => {
this.restoreActiveCategory() this.restoreActiveCategory()
this.loadApps() this.loadApps()
@@ -249,6 +254,13 @@ const apps = {
}, },
restoreActiveCategory() { 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)) const index = this.allCategories.findIndex(item => String(item.id) === String(this.cachedCategoryId))
this.activeTab = index >= 0 ? index : 0 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> <todo v-if="homeTabbarActive === 'todo'" @todo-count-change="updateTodoCount"></todo>
<work v-if="homeTabbarActive === 'work'"></work> <work v-if="homeTabbarActive === 'work'"></work>
<mine v-if="homeTabbarActive === 'mine'" @switch-tab="switchHomeTab"></mine> <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> <msg v-if="homeTabbarActive === 'msg'" @unread-count-change="updateUnreadMessageCount"></msg>
</div> </div>
@@ -111,19 +111,25 @@ layout("/layouts/platform_h5.html"){
data() { data() {
return { return {
homeTabbarActive: "home", homeTabbarActive: "home",
appsCategoryName: "",
todoCount: 0, todoCount: 0,
unreadMessageCount: 0 unreadMessageCount: 0
} }
}, },
methods: { methods: {
homeTabbarChange(val) { homeTabbarChange(val) {
// 普通页签切换沿用应用页缓存,不保留工具入口指定的分类。
this.$set(this, "appsCategoryName", "")
this.$store.commit("setActiveTarBar", val) this.$store.commit("setActiveTarBar", val)
}, },
switchHomeTab(val) { // categoryName 来自“我的工具”,先传分类再挂载应用页,避免读取上一次的分类缓存。
this.homeTabbarActive = val switchHomeTab(val, categoryName = "") {
this.$set(this, "appsCategoryName", val === "apps" ? categoryName : "")
this.$set(this, "homeTabbarActive", val)
this.$store.commit("setActiveTarBar", val) this.$store.commit("setActiveTarBar", val)
}, },
moduleClick(val) { moduleClick(val) {
this.$set(this, "appsCategoryName", "")
this.homeTabbarActive = "apps" this.homeTabbarActive = "apps"
this.$store.commit("setActiveTarBar", "apps") this.$store.commit("setActiveTarBar", "apps")
}, },
@@ -74,22 +74,23 @@ const mine = {
<van-icon name="edit"></van-icon> <van-icon name="edit"></van-icon>
<span>我的签名</span> <span>我的签名</span>
</button> </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> <van-icon name="star-o"></van-icon>
<span>我的收藏</span> <span>我的收藏</span>
</button> </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> <van-icon name="gift-o"></van-icon>
<span>福利中心</span> <span>福利中心</span>
</button> </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> <van-icon name="calendar-o"></van-icon>
<span>活动报名</span> <span>活动报名</span>
</button> </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> <van-icon name="completed-o"></van-icon>
<span>问卷调查</span> <span>问卷调查</span>
</button> </button>
<!-- 暂不开放设置中心和帮助中心,保留入口模板便于恢复。
<button type="button" class="mine-grid-item" @click="switchTab('apps')"> <button type="button" class="mine-grid-item" @click="switchTab('apps')">
<van-icon name="setting-o"></van-icon> <van-icon name="setting-o"></van-icon>
<span>设置中心</span> <span>设置中心</span>
@@ -98,6 +99,7 @@ const mine = {
<van-icon name="question-o"></van-icon> <van-icon name="question-o"></van-icon>
<span>帮助中心</span> <span>帮助中心</span>
</button> </button>
-->
</div> </div>
</section> </section>
@@ -150,9 +152,10 @@ const mine = {
} }
}, },
methods: { methods: {
switchTab(tab) { // tab 为底部页签,categoryName 为可选应用分类名称;通过事件交给首页切换并定位分类。
switchTab(tab, categoryName = "") {
this.$store.commit("setActiveTarBar", tab) this.$store.commit("setActiveTarBar", tab)
this.$emit("switch-tab", tab) this.$emit("switch-tab", tab, categoryName)
}, },
logout() { logout() {
this.$dialog this.$dialog
@@ -281,11 +281,13 @@ const todo = {
// 处理任务 // 处理任务
onView(task) { onView(task) {
const {taskId, taskKey, instanceId, businessNo, formKey, h5FormKey} = 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端页面审核!"); this.$toast("当前流程暂不支持手机端审核,请前往PC端页面审核!");
return; 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)
}, },
// 根据当前列表类型统一展示流程状态,发起列表保留流程实例的实际状态。 // 根据当前列表类型统一展示流程状态,发起列表保留流程实例的实际状态。