Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1ac3db4bf | ||
|
|
6c6caa2558 | ||
|
|
35877f4b5c | ||
|
|
001d456513 | ||
|
|
65f340e3b0 | ||
|
|
29260423d5 | ||
|
|
d8e7adfe9a | ||
|
|
bc1b3bf7fe | ||
|
|
59fd83ba24 | ||
|
|
3059603186 | ||
|
|
f17f6153b5 | ||
|
|
e87014fa72 |
@@ -6,6 +6,8 @@ import com.budwk.app.base.exception.BaseException;
|
||||
import com.budwk.app.sys.models.Sys_file;
|
||||
import com.budwk.app.sys.models.Sys_office_template;
|
||||
import com.budwk.app.sys.utils.SysFileMinIoUtil;
|
||||
import com.deepoove.poi.XWPFTemplate;
|
||||
import com.deepoove.poi.config.Configure;
|
||||
import com.deepoove.poi.data.ByteArrayPictureRenderData;
|
||||
import com.deepoove.poi.data.PictureRenderData;
|
||||
import com.deepoove.poi.data.PictureType;
|
||||
@@ -18,6 +20,12 @@ import org.nutz.ioc.loader.annotation.IocBean;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -66,6 +74,88 @@ public class SysOfficeTemplateUtil {
|
||||
return new ByteArrayInputStream(fileBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一渲染 DOC/DOCX 模板,保留调用方的循环表格等策略;模板输入流由调用方关闭。
|
||||
*/
|
||||
public byte[] renderTemplate(InputStream input, Object data, Configure config, boolean pdf, String context) throws Exception {
|
||||
if (input == null) throw new BaseException("模板文件不存在");
|
||||
byte[] templateBytes = input.readAllBytes();
|
||||
try (ByteArrayInputStream formatInput = new ByteArrayInputStream(templateBytes)) {
|
||||
if (com.aspose.words.FileFormatUtil.detectFileFormat(formatInput).getLoadFormat() != com.aspose.words.LoadFormat.DOCX) {
|
||||
try (ByteArrayInputStream source = new ByteArrayInputStream(templateBytes);
|
||||
ByteArrayOutputStream converted = new ByteArrayOutputStream()) {
|
||||
new com.aspose.words.Document(source).save(converted, com.aspose.words.SaveFormat.DOCX);
|
||||
templateBytes = converted.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
byte[] wordBytes;
|
||||
try (ByteArrayInputStream templateInput = new ByteArrayInputStream(templateBytes);
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
XWPFTemplate template = XWPFTemplate.compile(templateInput, config == null ? Configure.builder().build() : config)) {
|
||||
template.render(data).write(output);
|
||||
wordBytes = output.toByteArray();
|
||||
} catch (Exception e) {
|
||||
log.error("[Office模板渲染] Word模板渲染失败,context={}", context, e);
|
||||
throw e;
|
||||
}
|
||||
log.info("[Office模板渲染] Word渲染完成,context={}, templateBytes={}, outputBytes={}", context, templateBytes.length, wordBytes.length);
|
||||
if (!pdf) return wordBytes;
|
||||
try (ByteArrayInputStream wordInput = new ByteArrayInputStream(wordBytes);
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||
com.aspose.words.Document document = new com.aspose.words.Document(wordInput);
|
||||
configureDocumentFonts(document, context);
|
||||
com.aspose.words.PdfSaveOptions options = new com.aspose.words.PdfSaveOptions();
|
||||
options.setFontEmbeddingMode(com.aspose.words.PdfFontEmbeddingMode.EMBED_ALL);
|
||||
document.save(output, options);
|
||||
log.info("[Office模板渲染] PDF转换完成,context={}, outputBytes={}", context, output.size());
|
||||
return output.toByteArray();
|
||||
} catch (Exception e) {
|
||||
log.error("[Office模板渲染] PDF转换失败,context={}", context, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private void configureDocumentFonts(com.aspose.words.Document document, String context) throws Exception {
|
||||
com.aspose.words.FontSettings settings = new com.aspose.words.FontSettings();
|
||||
List<com.aspose.words.FontSourceBase> sources = new ArrayList<>(List.of(settings.getFontsSources()));
|
||||
Set<String> folders = new LinkedHashSet<>();
|
||||
String windowsDir = System.getenv("WINDIR");
|
||||
if (StrUtil.isNotBlank(windowsDir)) folders.add(new File(windowsDir, "Fonts").getPath());
|
||||
String localAppData = System.getenv("LOCALAPPDATA");
|
||||
if (StrUtil.isNotBlank(localAppData)) folders.add(new File(localAppData, "Microsoft/Windows/Fonts").getPath());
|
||||
folders.add(new File(System.getProperty("user.dir"), "fonts").getPath());
|
||||
for (String folder : folders) {
|
||||
File directory = new File(folder);
|
||||
log.info("[Office模板渲染] 字体目录,context={}, path={}, readable={}", context, folder, directory.isDirectory() && directory.canRead());
|
||||
if (directory.isDirectory() && directory.canRead()) sources.add(new com.aspose.words.FolderFontSource(folder, true));
|
||||
}
|
||||
settings.setFontsSources(sources.toArray(new com.aspose.words.FontSourceBase[0]));
|
||||
Set<String> availableFonts = new LinkedHashSet<>();
|
||||
for (com.aspose.words.FontSourceBase source : sources) {
|
||||
for (com.aspose.words.PhysicalFontInfo font : source.getAvailableFonts()) availableFonts.add(font.getFontFamilyName());
|
||||
}
|
||||
String chineseFont = null;
|
||||
for (String candidate : List.of("SimSun", "宋体", "Microsoft YaHei", "微软雅黑", "DengXian", "等线", "SimHei", "黑体", "FangSong", "仿宋", "Noto Serif CJK SC", "Noto Sans CJK SC")) {
|
||||
if (availableFonts.contains(candidate)) {
|
||||
chineseFont = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
log.info("[Office模板渲染] 中文字体选择,context={}, fontCount={}, chineseFont={}", context, availableFonts.size(), chineseFont);
|
||||
if (chineseFont != null) {
|
||||
settings.getSubstitutionSettings().getDefaultFontSubstitution().setDefaultFontName(chineseFont);
|
||||
for (String name : List.of("宋体", "SimSun", "新宋体", "NSimSun", "黑体", "SimHei", "仿宋", "FangSong", "楷体", "KaiTi", "微软雅黑", "Microsoft YaHei")) {
|
||||
if (!availableFonts.contains(name)) settings.getSubstitutionSettings().getTableSubstitution().setSubstitutes(name, chineseFont);
|
||||
}
|
||||
} else {
|
||||
log.warn("[Office模板渲染] 未发现常用中文字体,context={}, availableFonts={}", context, availableFonts);
|
||||
}
|
||||
settings.getFallbackSettings().loadMsOfficeFallbackSettings();
|
||||
document.setFontSettings(settings);
|
||||
document.setWarningCallback(warning -> log.warn("[Office模板渲染] 字体或排版警告,context={}, type={}, detail={}", context, warning.getWarningType(), warning.getDescription()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 签字url转换为poi的图片对象
|
||||
*
|
||||
@@ -77,13 +167,7 @@ public class SysOfficeTemplateUtil {
|
||||
public PictureRenderData createPictureRenderData(int width, int height, String url) {
|
||||
try {
|
||||
byte[] bytes = getFileBytesByUrl(url);
|
||||
// if (StrUtil.isBlank(url)) {
|
||||
// return null;
|
||||
// }
|
||||
// int startIndex = url.indexOf("id=") + "id=".length();
|
||||
// String id = url.substring(startIndex);
|
||||
// Sys_file sysFile = dao.fetch(Sys_file.class, id);
|
||||
// byte[] bytes = SysFileMinIoUtil.getFileBytes(sysFile.getBucket(), sysFile.getStoragePath());
|
||||
log.info("[Office图片读取] 创建图片渲染数据,referenceLength={}, byteCount={}, width={}, height={}", StrUtil.nullToEmpty(url).length(), bytes == null ? null : bytes.length, width, height);
|
||||
ByteArrayPictureRenderData pictureRenderData = new ByteArrayPictureRenderData(bytes, PictureType.PNG);
|
||||
PictureStyle pictureStyle = new PictureStyle();
|
||||
pictureStyle.setWidth(width);
|
||||
@@ -91,7 +175,7 @@ public class SysOfficeTemplateUtil {
|
||||
pictureRenderData.setPictureStyle(pictureStyle);
|
||||
return pictureRenderData;
|
||||
} catch (Exception e) {
|
||||
log.error("文件转换PictureRenderData错误,文件路径:{},{}", url, e.getMessage());
|
||||
log.error("[Office图片读取] 创建图片渲染数据失败,referenceLength={}", StrUtil.nullToEmpty(url).length(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -140,13 +224,20 @@ public class SysOfficeTemplateUtil {
|
||||
public byte[] getFileBytesByUrl(String url) {
|
||||
try {
|
||||
if (StrUtil.isBlank(url)) {
|
||||
log.warn("[Office图片读取] 图片引用为空");
|
||||
return null;
|
||||
}
|
||||
int startIndex = url.indexOf("id=") + "id=".length();
|
||||
String id = url.substring(startIndex);
|
||||
String logFileId = url.contains("id=") ? id : "未识别id参数";
|
||||
log.info("[Office图片读取] 解析文件引用,fileId={}, hasIdParameter={}", logFileId, url.contains("id="));
|
||||
Sys_file sysFile = dao.fetch(Sys_file.class, id);
|
||||
return SysFileMinIoUtil.getFileBytes(sysFile.getBucket(), sysFile.getStoragePath());
|
||||
log.info("[Office图片读取] 查询文件记录,fileId={}, found={}, bucket={}, storagePathPresent={}", logFileId, sysFile != null, sysFile == null ? null : sysFile.getBucket(), sysFile != null && StrUtil.isNotBlank(sysFile.getStoragePath()));
|
||||
byte[] bytes = SysFileMinIoUtil.getFileBytes(sysFile.getBucket(), sysFile.getStoragePath());
|
||||
log.info("[Office图片读取] 读取存储文件完成,fileId={}, byteCount={}", logFileId, bytes == null ? null : bytes.length);
|
||||
return bytes;
|
||||
} catch (Exception e) {
|
||||
log.error("[Office图片读取] 读取图片失败,referenceLength={}", StrUtil.nullToEmpty(url).length(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.budwk.app.base.wxwork;
|
||||
|
||||
import org.nutz.lang.util.NutMap;
|
||||
import java.util.List;
|
||||
|
||||
/** 企业微信自建应用消息服务,接收人的 loginname 必须对应学校企业微信 userid。 */
|
||||
public interface WxWorkMessageService {
|
||||
/**
|
||||
* 单人发送;link 为空发送文本,否则发送文本卡片,相对链接使用配置域名补全。
|
||||
* @param loginname 接收人的企业微信 userid,当前学校直接使用系统 loginname
|
||||
* @param title 消息标题,为空使用配置的默认标题
|
||||
* @param content 消息正文,可含富文本标签,发送前转为纯文本
|
||||
* @param link 业务详情链接,可为空、站内相对路径或完整 HTTP(S) 地址
|
||||
* @return 与 massSend 相同的结果结构;skipped 表示关闭开关而未发送
|
||||
*/
|
||||
NutMap send(String loginname, String title, String content, String link);
|
||||
|
||||
/**
|
||||
* 多人发送相同消息,去重并分批调用企业微信接口。
|
||||
* @param loginNames 接收人 loginname 列表,每项须为企业微信 userid,不接受 @all
|
||||
* @param title 消息标题,为空使用默认标题
|
||||
* @param content 消息正文,清理标签后不能为空
|
||||
* @param link 详情链接;空值为文本消息,非空为文本卡片
|
||||
* @return NutMap:success 为全部批次被接口接受且无无效接收人;skipped 为未发送;
|
||||
* batches 为批次结果列表,每项包含 errcode、errmsg、msgid、invaliduser 等接口字段。
|
||||
* 接口接受不代表用户已读;网络或配置错误抛出业务异常,不自动重发已接受批次。
|
||||
*/
|
||||
NutMap massSend(List<String> loginNames, String title, String content, String link);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.budwk.app.base.wxwork.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.HtmlUtil;
|
||||
import cn.hutool.http.HttpRequest;
|
||||
import cn.hutool.http.HttpResponse;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.budwk.app.base.exception.BaseException;
|
||||
import com.budwk.app.base.constant.RedisConstant;
|
||||
import com.budwk.app.base.wxwork.WxWorkMessageService;
|
||||
import com.budwk.app.web.commons.base.Globals;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.nutz.aop.interceptor.ioc.TransAop;
|
||||
import org.nutz.integration.jedis.RedisService;
|
||||
import org.nutz.ioc.aop.Aop;
|
||||
import org.nutz.ioc.impl.PropertiesProxy;
|
||||
import org.nutz.ioc.loader.annotation.Inject;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
import org.nutz.lang.util.NutMap;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** 企业微信官方消息接口;参考 ypi 的单发、群发及 token 缓存方式,配置与业务调用分离。 */
|
||||
@Slf4j
|
||||
@IocBean
|
||||
public class WxWorkMessageServiceImpl implements WxWorkMessageService {
|
||||
private static final String PREFIX = "wxwork.message.";
|
||||
private static final String API = "https://qyapi.weixin.qq.com/cgi-bin/";
|
||||
@Inject
|
||||
private PropertiesProxy conf;
|
||||
@Inject
|
||||
private RedisService redisService;
|
||||
|
||||
/** 参数和返回结构见接口;发送是外部操作,数据库事务无法撤回已发送的消息。 */
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public NutMap send(String loginname, String title, String content, String link) {
|
||||
return massSend(loginname == null ? null : List.of(loginname), title, content, link);
|
||||
}
|
||||
|
||||
/** 先判断独立消息开关,关闭时不读取凭据、不取 token,也不调用外部接口。 */
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public NutMap massSend(List<String> loginNames, String title, String content, String link) {
|
||||
Object switchValue = Globals.MyConfig.get("AppWxWorkMessage");
|
||||
boolean enabled = Boolean.parseBoolean(switchValue == null
|
||||
? conf.get(PREFIX + "enabled", "false") : switchValue.toString());
|
||||
if (!enabled) {
|
||||
return NutMap.NEW().addv("success", false).addv("skipped", true).addv("batches", List.of());
|
||||
}
|
||||
if (loginNames == null || loginNames.isEmpty()
|
||||
|| loginNames.stream().anyMatch(name -> StrUtil.isBlank(name) || name.contains("|") || name.trim().startsWith("@"))) {
|
||||
throw new BaseException("企业微信接收人须为非空 loginname,不允许分隔符或群发标记");
|
||||
}
|
||||
List<String> receivers = loginNames.stream().map(String::trim).distinct().toList();
|
||||
String body = plainText(content);
|
||||
String heading = plainText(StrUtil.blankToDefault(title, conf.get(PREFIX + "default-title", "智慧工会")));
|
||||
if (StrUtil.isBlank(body)) throw new BaseException("企业微信消息正文不能为空");
|
||||
String url = resolveLink(link);
|
||||
long agentId;
|
||||
try {
|
||||
agentId = Long.parseLong(required("wxwork.agentId"));
|
||||
if (agentId <= 0) throw new NumberFormatException();
|
||||
} catch (NumberFormatException e) {
|
||||
throw new BaseException("企业微信 agentId 必须为正整数");
|
||||
}
|
||||
JSONObject payload = JSONUtil.createObj().set("agentid", agentId);
|
||||
if (url == null) {
|
||||
String text = heading + "\n" + body;
|
||||
requireLength(text, 2048, "文本消息");
|
||||
payload.set("msgtype", "text").set("text", JSONUtil.createObj().set("content", text));
|
||||
} else {
|
||||
requireLength(heading, 128, "卡片标题");
|
||||
String description = HtmlUtil.escape(body).replace("\n", "<br>");
|
||||
requireLength(description, 512, "卡片正文");
|
||||
requireLength(url, 2048, "卡片链接");
|
||||
payload.set("msgtype", "textcard").set("textcard", JSONUtil.createObj()
|
||||
.set("title", heading).set("description", description).set("url", url).set("btntxt", "查看详情"));
|
||||
}
|
||||
List<JSONObject> batches = new ArrayList<>();
|
||||
boolean success = true;
|
||||
// 企业微信 touser 单次最多 1000 人;按去重后的接收人分批,禁止自动重复整个批次列表。
|
||||
for (int offset = 0; offset < receivers.size(); offset += 1000) {
|
||||
payload.set("touser", String.join("|", receivers.subList(offset, Math.min(offset + 1000, receivers.size()))));
|
||||
String token = getAccessToken(false);
|
||||
JSONObject result = sendPayload(payload, token);
|
||||
int code = result.getInt("errcode", -1);
|
||||
// 仅在明确的 token 无效/过期返回时刷新一次;超时的发送结果不确定,不能盲目重发。
|
||||
if (code == 40014 || code == 42001 || code == 40001) {
|
||||
result = sendPayload(payload, getAccessToken(true));
|
||||
}
|
||||
batches.add(result);
|
||||
boolean accepted = result.getInt("errcode", -1) == 0
|
||||
&& StrUtil.isBlank(result.getStr("invaliduser"))
|
||||
&& StrUtil.isBlank(result.getStr("unlicenseduser"));
|
||||
success &= accepted;
|
||||
log.info("企业微信消息批次完成:offset={},success={},errcode={},msgid={}",
|
||||
offset, accepted, result.getInt("errcode"), result.getStr("msgid"));
|
||||
}
|
||||
return NutMap.NEW().addv("success", success).addv("skipped", false).addv("batches", batches);
|
||||
}
|
||||
|
||||
/** 标题和富文本正文统一转换为可读纯文本,保留常用段落换行。 */
|
||||
private String plainText(String value) {
|
||||
return HtmlUtil.unescape(HtmlUtil.cleanHtmlTag(StrUtil.blankToDefault(value, "")
|
||||
.replaceAll("(?i)<br\\s*/?>|</p>|</div>", "\n"))).trim();
|
||||
}
|
||||
|
||||
/** 使用 UTF-8 字节数校验接口上限,避免中文按字符计数造成发送失败。 */
|
||||
private void requireLength(String value, int limit, String label) {
|
||||
if (value.getBytes(StandardCharsets.UTF_8).length > limit) {
|
||||
throw new BaseException(label + "超过 " + limit + " 字节,请精简内容");
|
||||
}
|
||||
}
|
||||
|
||||
/** 相对路径按配置的系统域名补全;限制 HTTP(S),避免生成不可打开的卡片链接。 */
|
||||
private String resolveLink(String link) {
|
||||
if (StrUtil.isBlank(link)) return null;
|
||||
try {
|
||||
URI uri = URI.create(link.trim());
|
||||
if (!uri.isAbsolute()) {
|
||||
if (uri.getRawAuthority() != null) throw new IllegalArgumentException();
|
||||
String base = required(PREFIX + "link-base-url").replaceAll("/+$", "") + "/";
|
||||
uri = URI.create(base).resolve(uri);
|
||||
}
|
||||
if (!("https".equalsIgnoreCase(uri.getScheme()) || "http".equalsIgnoreCase(uri.getScheme()))
|
||||
|| uri.getHost() == null || uri.getUserInfo() != null) throw new IllegalArgumentException();
|
||||
return uri.toASCIIString();
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new BaseException("企业微信详情链接或系统域名配置无效");
|
||||
}
|
||||
}
|
||||
|
||||
/** 按企业及应用隔离缓存,与登录 token 缓存分开;提前过期以避开有效期临界点。 */
|
||||
private synchronized String getAccessToken(boolean refresh) {
|
||||
String corpId = required("wxwork.corpId");
|
||||
String agentId = required("wxwork.agentId");
|
||||
String secret = required("wxwork.corpSecret");
|
||||
String key = RedisConstant.REDIS_KEY_WX_WORK_TOKEN + "message:" + corpId + ":" + agentId;
|
||||
String cached = refresh ? null : redisService.get(key);
|
||||
if (StrUtil.isNotBlank(cached)) return cached;
|
||||
JSONObject response = execute(HttpRequest.get(API + "gettoken?corpid=" + encode(corpId)
|
||||
+ "&corpsecret=" + encode(secret)));
|
||||
String token = response.getStr("access_token");
|
||||
if (response.getInt("errcode", -1) != 0 || StrUtil.isBlank(token)) {
|
||||
throw new BaseException("企业微信获取 token 失败,errcode=" + response.getInt("errcode", -1));
|
||||
}
|
||||
int expires = response.getInt("expires_in", 7200);
|
||||
redisService.setex(key, Math.max(1, expires - 120), token);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** 提交已校验的消息 JSON;返回企业微信原始业务字段供调用方判断部分失败。 */
|
||||
private JSONObject sendPayload(JSONObject payload, String token) {
|
||||
return execute(HttpRequest.post(API + "message/send?access_token=" + encode(token))
|
||||
.header("Content-Type", "application/json; charset=UTF-8").body(payload.toString()));
|
||||
}
|
||||
|
||||
/** 关闭响应资源并校验 HTTP/JSON;异常不输出带 Secret 或 token 的请求 URL。 */
|
||||
protected JSONObject execute(HttpRequest request) {
|
||||
request.setConnectionTimeout(conf.getInt(PREFIX + "connect-timeout", 5000));
|
||||
request.setReadTimeout(conf.getInt(PREFIX + "read-timeout", 10000));
|
||||
try (HttpResponse response = request.execute()) {
|
||||
if (!response.isOk()) throw new BaseException("企业微信接口 HTTP 状态异常:" + response.getStatus());
|
||||
if (!JSONUtil.isTypeJSONObject(response.body())) throw new BaseException("企业微信接口返回非 JSON 对象");
|
||||
return JSONUtil.parseObj(response.body());
|
||||
} catch (BaseException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new BaseException("企业微信请求失败,请检查网络及接口配置;异常类型:" + e.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
/** 必填参数不允许为空或使用原有占位符 1,防止错误配置请求外部接口。 */
|
||||
private String required(String key) {
|
||||
String value = conf.get(key, "").trim();
|
||||
if (StrUtil.isBlank(value) || "1".equals(value)) throw new BaseException("请配置有效的 " + key);
|
||||
return value;
|
||||
}
|
||||
|
||||
private String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.budwk.app.flow.engine.event.ProcessEvent;
|
||||
import com.budwk.app.flow.engine.event.ProcessEventListener;
|
||||
import com.budwk.app.flow.entity.ProcessTask;
|
||||
import com.budwk.app.flow.enums.ProcessEventTypeEnum;
|
||||
import com.budwk.app.flow.service.SchoolOaTodoService;
|
||||
import org.nutz.dao.Dao;
|
||||
import org.nutz.ioc.loader.annotation.Inject;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
@@ -17,11 +18,16 @@ public class ProcessTaskEndEventListener implements ProcessEventListener {
|
||||
@Inject
|
||||
private Dao dao;
|
||||
|
||||
@Inject
|
||||
private SchoolOaTodoService schoolOaTodoService;
|
||||
|
||||
@Override
|
||||
public void onEvent(ProcessEvent event) {
|
||||
if (event.getEventType() == ProcessEventTypeEnum.PROCESS_TASK_END) {
|
||||
Long sourceId = event.getSourceId();
|
||||
ProcessTask task = dao.fetch(ProcessTask.class, sourceId);
|
||||
// service 区分正常完成与废弃,只有正常完成才发送通达 status=2。
|
||||
schoolOaTodoService.completeTodo(task);
|
||||
System.out.println("流程任务完成事件:" + task.getDisplayName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.budwk.app.flow.engine.event.ProcessEventListener;
|
||||
import com.budwk.app.flow.entity.ProcessInstance;
|
||||
import com.budwk.app.flow.entity.ProcessTask;
|
||||
import com.budwk.app.flow.enums.ProcessEventTypeEnum;
|
||||
import com.budwk.app.flow.service.SchoolOaTodoService;
|
||||
import org.nutz.dao.Dao;
|
||||
import org.nutz.ioc.loader.annotation.Inject;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
@@ -22,11 +23,16 @@ public class ProcessTaskRevokeEventListener implements ProcessEventListener {
|
||||
@Inject
|
||||
private Dao dao;
|
||||
|
||||
@Inject
|
||||
private SchoolOaTodoService schoolOaTodoService;
|
||||
|
||||
@Override
|
||||
public void onEvent(ProcessEvent event) {
|
||||
if(event.getEventType()== ProcessEventTypeEnum.PROCESS_TASK_REVOKE){
|
||||
Long taskId = event.getSourceId();
|
||||
ProcessTask task = dao.fetch(ProcessTask.class, taskId);
|
||||
// 公开通达协议未定义取消,集中记录未同步事项,不把撤回冒充正常已办。
|
||||
schoolOaTodoService.cancelTodo(task);
|
||||
|
||||
String taskDisplayName = task.getDisplayName();
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.budwk.app.flow.entity.ProcessTask;
|
||||
import com.budwk.app.flow.entity.ProcessTaskActor;
|
||||
import com.budwk.app.flow.enums.ProcessEventTypeEnum;
|
||||
import com.budwk.app.zhgh.dayofficework.message.service.GlobalMessageSendService;
|
||||
import com.budwk.app.zhgh.staffbenefit.difficulthelp.service.DifficultHelpCommonService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.nutz.dao.Cnd;
|
||||
import org.nutz.dao.Dao;
|
||||
@@ -30,13 +31,22 @@ public class ProcessTaskStartEventListener implements ProcessEventListener {
|
||||
private Dao dao;
|
||||
@Inject
|
||||
private GlobalMessageSendService globalMessageSendService;
|
||||
@Inject
|
||||
private DifficultHelpCommonService difficultHelpCommonService;
|
||||
|
||||
@Override
|
||||
public void onEvent(ProcessEvent event) {
|
||||
if (event.getEventType() == ProcessEventTypeEnum.PROCESS_TASK_START) {
|
||||
Long sourceId = event.getSourceId();
|
||||
ProcessTask task = dao.fetch(ProcessTask.class, sourceId);
|
||||
if (task == null) return;
|
||||
sendMessage(task);
|
||||
// 节点及接收人判断集中在业务 service;通知失败不向流程提交接口抛出异常。
|
||||
try {
|
||||
difficultHelpCommonService.notifyOfficeApproval(task);
|
||||
} catch (Exception e) {
|
||||
log.error("困难帮扶企业微信待办通知异常,taskId={}", sourceId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.budwk.app.flow.service;
|
||||
|
||||
import com.budwk.app.flow.entity.ProcessTask;
|
||||
|
||||
/** 通达 OA 待办同步入口,WF 调用方无需处理鉴权、人员映射或接口参数。 */
|
||||
public interface SchoolOaTodoService {
|
||||
/**
|
||||
* 推送已创建且已分配参与人的 WF 任务;首次自动提交的 startTask 不发送。
|
||||
* @param task 包含任务 ID、实例 ID、状态和表单地址的完整任务
|
||||
* @return 无返回值;开关关闭时跳过,开启后请求失败抛出业务异常
|
||||
*/
|
||||
void createTodo(ProcessTask task);
|
||||
|
||||
/**
|
||||
* 将已完成任务对应的接收人待办同步为 status=2;非完成状态不冒充已办。
|
||||
* @param task 数据库中的完整任务,使用原发起人、接收人、标题和地址组装请求
|
||||
* @return 无返回值;errcode=0 表示接口接受请求,原待办匹配规则仍需学校联调确认
|
||||
*/
|
||||
void completeTodo(ProcessTask task);
|
||||
|
||||
/**
|
||||
* 记录需取消的 OA 待办。通达公开文档未提供取消接口,本方法不发送虚构的删除请求。
|
||||
* @param task 撤回、废弃或转办的原任务,用于记录任务 ID 和状态
|
||||
* @return 无返回值;仅在开关开启时输出待人工处理提示,不代表 OA 已取消
|
||||
*/
|
||||
void cancelTodo(ProcessTask task);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import com.budwk.app.flow.enums.*;
|
||||
import com.budwk.app.flow.service.ProcessDefineService;
|
||||
import com.budwk.app.flow.service.ProcessInstanceService;
|
||||
import com.budwk.app.flow.service.ProcessTaskService;
|
||||
import com.budwk.app.flow.service.SchoolOaTodoService;
|
||||
import com.budwk.app.flow.vo.ProcessTaskVO;
|
||||
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
|
||||
import org.nutz.aop.interceptor.ioc.TransAop;
|
||||
@@ -56,6 +57,9 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
|
||||
@Inject
|
||||
private ProcessDefineService processDefineService;
|
||||
|
||||
@Inject
|
||||
private SchoolOaTodoService schoolOaTodoService;
|
||||
|
||||
@Override
|
||||
public boolean save(ProcessTask param) {
|
||||
param.setId(null);
|
||||
@@ -213,6 +217,8 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
|
||||
System.out.println("创建任务:" + processTask.getTaskName() + "," + processTask.getDisplayName());
|
||||
processTaskList.add(processTask);
|
||||
addTaskActor(processTask.getId(), getTaskActors(taskModel, execution));
|
||||
// 参与人落库后统一推送通达待办,开始监听器不再重复推送。
|
||||
schoolOaTodoService.createTodo(processTask);
|
||||
|
||||
return processTaskList;
|
||||
}
|
||||
@@ -284,6 +290,8 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
|
||||
clone.setUpdatedBy(null);
|
||||
dao().insert(clone);
|
||||
addTaskActor(clone.getId(), CollectionUtil.newArrayList(actor));
|
||||
// 并行加签绕过普通节点创建入口,需要为新增的个人任务同步待办。
|
||||
schoolOaTodoService.createTodo(clone);
|
||||
});
|
||||
|
||||
}
|
||||
@@ -318,6 +326,7 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
|
||||
}
|
||||
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public ProcessTask rejectTask(ProcessModel model, ProcessTask currentTask) {
|
||||
Long taskParentId = currentTask.getTaskParentId();
|
||||
if (ObjectUtil.isEmpty(taskParentId) || taskParentId.equals(0L)) {
|
||||
@@ -351,6 +360,8 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
|
||||
}
|
||||
saveProcessTask(task);
|
||||
addTaskActor(task.getId(), CollectionUtil.newArrayList(task.getOperator()));
|
||||
// 驳回创建了新的任务 ID,按新任务推送,避免复用历史待办。
|
||||
schoolOaTodoService.createTodo(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
@@ -424,6 +435,8 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
|
||||
System.out.println("创建会签任务:" + processTask.getTaskName() + "," + processTask.getDisplayName());
|
||||
processTaskList.add(processTask);
|
||||
addTaskActor(processTask.getId(), CollectionUtil.newArrayList(taskActor));
|
||||
// 串行和并行会签均按实际生成的个人任务发送待办。
|
||||
schoolOaTodoService.createTodo(processTask);
|
||||
});
|
||||
ProcessInstanceService processInstanceService = ServiceContext.find(ProcessInstanceService.class);
|
||||
// 更新会签变量到流程实例参数中
|
||||
@@ -496,9 +509,12 @@ public class ProcessTaskServiceImpl extends BaseServiceImpl<ProcessTask> impleme
|
||||
|
||||
// 创建转办任务参与者
|
||||
addTaskActor(newTask.getId(), List.of(transferUserId));
|
||||
// 通达取消协议待学校确认;先明确记录原待办未清理,再向新办理人推送。
|
||||
schoolOaTodoService.cancelTodo(task);
|
||||
schoolOaTodoService.createTodo(newTask);
|
||||
|
||||
// 发布流程任务开始事件
|
||||
List<ProcessTask> processTaskList = List.of(task);
|
||||
List<ProcessTask> processTaskList = List.of(newTask);
|
||||
processTaskList.forEach(processTask -> {
|
||||
ProcessPublisher.notify(ProcessEvent.builder().eventType(ProcessEventTypeEnum.PROCESS_TASK_START).sourceId(processTask.getId()).build());
|
||||
});
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
package com.budwk.app.flow.service.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.HttpRequest;
|
||||
import cn.hutool.http.HttpResponse;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.budwk.app.base.exception.BaseException;
|
||||
import com.budwk.app.flow.constant.FlowConst;
|
||||
import com.budwk.app.flow.entity.ProcessInstance;
|
||||
import com.budwk.app.flow.entity.ProcessTask;
|
||||
import com.budwk.app.flow.entity.ProcessTaskActor;
|
||||
import com.budwk.app.flow.enums.ProcessTaskStateEnum;
|
||||
import com.budwk.app.flow.service.SchoolOaTodoService;
|
||||
import com.budwk.app.sys.models.Sys_user;
|
||||
import com.budwk.app.web.commons.base.Globals;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.nutz.aop.interceptor.ioc.TransAop;
|
||||
import org.nutz.dao.Cnd;
|
||||
import org.nutz.dao.Dao;
|
||||
import org.nutz.ioc.aop.Aop;
|
||||
import org.nutz.ioc.impl.PropertiesProxy;
|
||||
import org.nutz.ioc.loader.annotation.Inject;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 通达 V13 流程中心接口适配。所有配置、token 和 HTTP 请求集中在本服务。
|
||||
* 接口依据:https://open.tongda2000.com/oa/v13/interface.html
|
||||
* 同步请求失败向调用方抛出异常;本地事务不能撤销已经成功的远端请求。
|
||||
*/
|
||||
@Slf4j
|
||||
@IocBean
|
||||
public class SchoolOaTodoServiceImpl implements SchoolOaTodoService {
|
||||
private static final String PREFIX = "school-oa.todo.";
|
||||
@Inject
|
||||
private Dao dao;
|
||||
@Inject
|
||||
private PropertiesProxy conf;
|
||||
|
||||
// token 由同步方法保护;实例内缓存有效期,避免每位接收人重复申请凭据。
|
||||
private String accessToken;
|
||||
private long tokenExpiresAt;
|
||||
private String tokenConfig;
|
||||
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public void createTodo(ProcessTask task) {
|
||||
if (!isEnabled()) return;
|
||||
requireTask(task);
|
||||
if (isInitialTask(task) || !ProcessTaskStateEnum.DOING.getCode().equals(task.getTaskState())) return;
|
||||
sendTask(task, 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public void completeTodo(ProcessTask task) {
|
||||
if (!isEnabled()) return;
|
||||
requireTask(task);
|
||||
if (isInitialTask(task)) return;
|
||||
// 废弃任务也会发布结束事件;尤其提案附议仍可能可办理,不能直接改为 OA 已办。
|
||||
if (!ProcessTaskStateEnum.FINISHED.getCode().equals(task.getTaskState())) {
|
||||
cancelTodo(task);
|
||||
return;
|
||||
}
|
||||
sendTask(task, 2);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancelTodo(ProcessTask task) {
|
||||
if (!isEnabled()) return;
|
||||
requireTask(task);
|
||||
if (isInitialTask(task)) return;
|
||||
log.warn("通达OA取消规则尚未提供,本次未发送取消请求,请核对OA原待办:taskId={},instanceId={},state={}",
|
||||
task.getId(), task.getProcessInstanceId(), task.getTaskState());
|
||||
}
|
||||
|
||||
/** 系统参数优先于文件配置,只有明确为 true 才启用;未配置不访问数据库或 OA。 */
|
||||
private boolean isEnabled() {
|
||||
Object enabled = Globals.MyConfig.get("AppSchoolOa");
|
||||
return Boolean.parseBoolean(enabled == null ? conf.get(PREFIX + "enabled", "false") : enabled.toString());
|
||||
}
|
||||
|
||||
private void requireTask(ProcessTask task) {
|
||||
if (task == null || task.getId() == null || task.getProcessInstanceId() == null) {
|
||||
throw new BaseException("通达OA同步失败:任务或流程实例ID为空");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isInitialTask(ProcessTask task) {
|
||||
return Long.valueOf(0L).equals(task.getTaskParentId()) && "startTask".equals(task.getTaskName());
|
||||
}
|
||||
|
||||
/**
|
||||
* task 为完整 WF 任务;status 只允许本服务传入 1(待办)或 2(已办)。
|
||||
* sender/recipient 使用学校绑定的第三方用户标识;content 为 JSON 字符串,url 为 UTF-8 Base64。
|
||||
* 每个接收人单独请求,响应 JSON 中必须有 errcode=0;不采用参考项目的 uniqueId/state 协议。
|
||||
*/
|
||||
private void sendTask(ProcessTask task, int status) {
|
||||
ProcessInstance instance = dao.fetch(ProcessInstance.class, task.getProcessInstanceId());
|
||||
if (instance == null) throw new BaseException("通达OA同步失败:流程实例不存在");
|
||||
Sys_user sender = dao.fetch(Sys_user.class, instance.getOperator());
|
||||
if (sender == null) throw new BaseException("通达OA同步失败:流程发起人不存在");
|
||||
List<ProcessTaskActor> actors = dao.query(ProcessTaskActor.class,
|
||||
Cnd.where(ProcessTaskActor::getProcessTaskId, "=", task.getId()));
|
||||
if (actors.isEmpty()) throw new BaseException("通达OA同步失败:任务参与人为空");
|
||||
|
||||
String senderId = userIdentifier(sender.getId(), sender.getLoginname());
|
||||
String title = StrUtil.blankToDefault(JSONUtil.parseObj(StrUtil.blankToDefault(instance.getVariable(), "{}"))
|
||||
.getStr(FlowConst.PROCESS_INSTANCE_NAME), "流程申请") + " - " + task.getDisplayName();
|
||||
String targetUrl = buildTaskUrl(task, instance);
|
||||
String systemParam = conf.get(PREFIX + "system-id-param", "clientid");
|
||||
if (!"clientid".equals(systemParam) && !"exsId".equals(systemParam)) {
|
||||
throw new BaseException("通达OA配置 system-id-param 仅支持 clientid 或 exsId");
|
||||
}
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put(systemParam, requireConfig("system-id"));
|
||||
params.put("status", status);
|
||||
params.put("sender", senderId);
|
||||
params.put("content", JSONUtil.quote(title));
|
||||
params.put("url", Base64.getEncoder().encodeToString(targetUrl.getBytes(StandardCharsets.UTF_8)));
|
||||
// 先校验全部人员,避免处理到中途才发现缺少账号;不假设本地用户 ID 就是 OA 用户 ID。
|
||||
List<String> recipients = actors.stream()
|
||||
.filter(actor -> !FlowConst.ADMIN_ID.equals(actor.getActorId())
|
||||
&& !"superadmin".equalsIgnoreCase(actor.getActorAccount()))
|
||||
.map(actor -> userIdentifier(actor.getActorId(), actor.getActorAccount())).distinct().toList();
|
||||
for (String recipient : recipients) {
|
||||
params.put("recipient", recipient);
|
||||
String endpoint = baseUrl() + "/restapi/1_0/appcenter/affairprocessing?" + encodeParams(params);
|
||||
JSONObject result = execute(HttpRequest.get(endpoint).header("Authorization", "Bearer " + getAccessToken()), "待办同步");
|
||||
Integer errorCode = result.getInt("errcode");
|
||||
if (errorCode == null || errorCode != 0) {
|
||||
String detail = responseError(result);
|
||||
log.error("通达OA待办业务失败,taskId={},instanceId={},status={},errcode={},detail={}",
|
||||
task.getId(), task.getProcessInstanceId(), status, errorCode, detail);
|
||||
throw new BaseException("通达OA待办同步失败:errcode={},taskId={},{}", errorCode, task.getId(), detail);
|
||||
}
|
||||
log.info("通达OA接受待办同步请求,taskId={},status={}", task.getId(), status);
|
||||
}
|
||||
}
|
||||
|
||||
/** 用户映射按学校绑定选择 loginname 或 id,发送人与接收人使用同一规则。 */
|
||||
private String userIdentifier(String id, String account) {
|
||||
String mode = conf.get(PREFIX + "user-id-mode", "loginname");
|
||||
if (!"loginname".equals(mode) && !"id".equals(mode)) {
|
||||
throw new BaseException("通达OA配置 user-id-mode 仅支持 loginname 或 id");
|
||||
}
|
||||
String value = "id".equals(mode) ? id : account;
|
||||
if (StrUtil.isBlank(value) || value.contains(",")) {
|
||||
throw new BaseException("通达OA同步失败:绑定用户标识为空或包含逗号");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 通达仅有一个 url 字段,沿用当前 PC 审核页并携带任务、实例及业务参数。 */
|
||||
private String buildTaskUrl(ProcessTask task, ProcessInstance instance) {
|
||||
String form = task.getFormKey();
|
||||
if (StrUtil.isBlank(form)) throw new BaseException("通达OA同步失败:任务PC表单地址为空");
|
||||
String url = form.startsWith("http://") || form.startsWith("https://") ? form
|
||||
: StrUtil.removeSuffix(Globals.AppDomain, "/") + "/" + StrUtil.removePrefix(form, "/");
|
||||
// 原表单带锚点时,查询参数必须放在 # 前,否则服务端收不到 taskId。
|
||||
int fragmentIndex = url.indexOf('#');
|
||||
String fragment = fragmentIndex < 0 ? "" : url.substring(fragmentIndex);
|
||||
if (fragmentIndex >= 0) url = url.substring(0, fragmentIndex);
|
||||
Map<String, Object> query = new LinkedHashMap<>();
|
||||
query.put("taskId", task.getId());
|
||||
query.put("instanceId", instance.getId());
|
||||
query.put("bizId", StrUtil.nullToEmpty(instance.getBusinessNo()));
|
||||
query.put("taskKey", StrUtil.nullToEmpty(task.getTaskName()));
|
||||
return url + (url.contains("?") ? "&" : "?") + encodeParams(query) + fragment;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /oauth2/token:client_secret 必须是学校提供的 AES 加密结果,不是明文密钥。
|
||||
* 返回 access_token 和 expires_in(秒);缓存提前过期,配置变化立即重新申请。
|
||||
*/
|
||||
private synchronized String getAccessToken() {
|
||||
String base = baseUrl();
|
||||
String clientId = requireConfig("client-id");
|
||||
String secret = requireConfig("client-secret-encrypted");
|
||||
String username = conf.get(PREFIX + "token-username", "username");
|
||||
String password = conf.get(PREFIX + "token-password", "password");
|
||||
String currentConfig = JSONUtil.toJsonStr(List.of(base, clientId, secret, username, password));
|
||||
long now = System.currentTimeMillis();
|
||||
if (currentConfig.equals(tokenConfig) && StrUtil.isNotBlank(accessToken) && now < tokenExpiresAt) {
|
||||
// 只记录缓存剩余有效期,便于区分复用与重新获取,不输出凭据。
|
||||
log.info("通达OA复用token缓存,remainingSeconds={}", (tokenExpiresAt - now) / 1000L);
|
||||
return accessToken;
|
||||
}
|
||||
Map<String, Object> form = new LinkedHashMap<>();
|
||||
form.put("grant_type", "password");
|
||||
form.put("client_id", clientId);
|
||||
form.put("client_secret", secret);
|
||||
form.put("username", username);
|
||||
form.put("password", password);
|
||||
long startedAt = System.nanoTime();
|
||||
JSONObject result = execute(HttpRequest.post(base + "/oauth2/token")
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.body(encodeParams(form)), "获取凭据");
|
||||
String token = result.getStr("access_token");
|
||||
Long expires = result.getLong("expires_in");
|
||||
if (StrUtil.isBlank(token) || expires == null || expires <= 0 || expires > Integer.MAX_VALUE) {
|
||||
String detail = responseError(result);
|
||||
log.error("通达OA获取凭据业务失败,缺少有效的 access_token 或 expires_in,detail={}", detail);
|
||||
throw new BaseException("通达OA获取凭据失败:缺少有效的 access_token 或 expires_in,{}", detail);
|
||||
}
|
||||
accessToken = token;
|
||||
tokenConfig = currentConfig;
|
||||
tokenExpiresAt = now + (expires - Math.min(60, expires / 10)) * 1000L;
|
||||
log.info("通达OA获取token成功,elapsedMs={},expiresInSeconds={}",
|
||||
(System.nanoTime() - startedAt) / 1_000_000, expires);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** HTTP 成功不代表业务成功;本方法只验证状态码和 JSON,业务码由调用处校验。 */
|
||||
private JSONObject execute(HttpRequest request, String action) {
|
||||
long startedAt = System.nanoTime();
|
||||
int timeout = conf.getInt(PREFIX + "timeout-ms", 10000);
|
||||
// 只记录接口地址,不记录 GET 查询参数、请求头或 token 请求正文。
|
||||
String endpoint = request.getUrl().split("[?#]", 2)[0];
|
||||
try (HttpResponse response = request.timeout(timeout).execute()) {
|
||||
String body = response.body();
|
||||
boolean jsonObject = JSONUtil.isTypeJSONObject(body);
|
||||
// 非 JSON 错误常为 OA 或网关的 HTML 页面;仅在日志保留脱敏摘要,不把页面返回给前端。
|
||||
if (!jsonObject) {
|
||||
log.error("通达OA非JSON响应,action={},method={},endpoint={},httpStatus={},contentType={},bodySummary={}",
|
||||
action, request.getMethod(), endpoint, response.getStatus(),
|
||||
redact(response.header("Content-Type")), responseBodySummary(body));
|
||||
}
|
||||
if (!response.isOk()) {
|
||||
if (response.getStatus() == 401) invalidateToken();
|
||||
String detail = jsonObject
|
||||
? responseError(JSONUtil.parseObj(body)) : "响应不是JSON对象,脱敏响应摘要请查看服务端日志";
|
||||
throw new BaseException("通达OA{}失败:HTTP {},{}", action, response.getStatus(), detail);
|
||||
}
|
||||
if (!jsonObject) throw new BaseException("通达OA{}失败:响应不是JSON对象,脱敏响应摘要请查看服务端日志", action);
|
||||
return JSONUtil.parseObj(body);
|
||||
} catch (Exception e) {
|
||||
long elapsed = (System.nanoTime() - startedAt) / 1_000_000;
|
||||
Throwable root = e;
|
||||
Map<Throwable, Boolean> visited = new IdentityHashMap<>();
|
||||
while (root.getCause() != null && visited.put(root, Boolean.TRUE) == null
|
||||
&& !visited.containsKey(root.getCause())) root = root.getCause();
|
||||
String detail = root.getClass().getName() + ": " + redact(root.getMessage());
|
||||
// 保留各层原因、原始栈帧及 suppressed 异常;先复制脱敏,避免全局异常处理器泄露凭证。
|
||||
Throwable safeCause = diagnosticCause(e, new IdentityHashMap<>());
|
||||
log.error("通达OA请求失败,action={},method={},endpoint={},elapsedMs={},timeoutMs={},rootCause={}",
|
||||
action, request.getMethod(), endpoint, elapsed, timeout, detail, safeCause);
|
||||
// 不自动重放写请求,超时可能发生在远端已成功之后;避免重复待办。
|
||||
BaseException failure = new BaseException("通达OA{}失败,耗时{}ms:{};请核对远端结果后再重试", action, elapsed, detail);
|
||||
failure.initCause(safeCause);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
/** body 为远端非 JSON 正文;先脱敏再截取最多 2000 字符,避免截断凭据后无法匹配脱敏。 */
|
||||
private String responseBodySummary(String body) {
|
||||
if (StrUtil.isBlank(body)) return "[空响应正文]";
|
||||
String safe = redact(body);
|
||||
return safe.length() > 2000 ? safe.substring(0, 2000) + "[已截断]" : safe;
|
||||
}
|
||||
|
||||
/** 只提取接口错误字段,不输出含 access_token、refresh_token 等内容的整个响应。 */
|
||||
private String responseError(JSONObject response) {
|
||||
Map<String, Object> errors = new LinkedHashMap<>();
|
||||
for (String key : List.of("error", "error_description", "errcode", "errmsg", "message")) {
|
||||
if (response.containsKey(key)) errors.put(key, redact(String.valueOf(response.get(key))));
|
||||
}
|
||||
return errors.isEmpty() ? "OA未返回错误说明" : JSONUtil.toJsonStr(errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制异常诊断信息,message 标注原异常类型,保留原始栈帧和原因链。
|
||||
* visited 防止第三方异常存在循环引用;原异常不修改,也不直接写入日志。
|
||||
*/
|
||||
private Throwable diagnosticCause(Throwable source, Map<Throwable, Throwable> visited) {
|
||||
if (visited.containsKey(source)) return visited.get(source);
|
||||
RuntimeException copy = new RuntimeException(source.getClass().getName() + ": " + redact(source.getMessage()));
|
||||
visited.put(source, copy);
|
||||
copy.setStackTrace(source.getStackTrace());
|
||||
if (source.getCause() != null && !visited.containsKey(source.getCause())) {
|
||||
copy.initCause(diagnosticCause(source.getCause(), visited));
|
||||
}
|
||||
for (Throwable suppressed : source.getSuppressed()) {
|
||||
if (!visited.containsKey(suppressed)) copy.addSuppressed(diagnosticCause(suppressed, visited));
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** 清除已知凭证及可能被远端回显的认证字段、URL查询参数,并移除日志换行。 */
|
||||
private String redact(String message) {
|
||||
if (message == null) return "无异常说明";
|
||||
String text = message;
|
||||
for (String key : List.of("client-secret-encrypted", "token-password", "client-id")) {
|
||||
String value = conf.get(PREFIX + key);
|
||||
if (StrUtil.isNotBlank(value)) {
|
||||
text = text.replace(value, "[REDACTED]")
|
||||
.replace(URLEncoder.encode(value, StandardCharsets.UTF_8), "[REDACTED]");
|
||||
}
|
||||
}
|
||||
if (StrUtil.isNotBlank(accessToken)) text = text.replace(accessToken, "[REDACTED]");
|
||||
text = text.replaceAll("(?i)Bearer\\s+[^\\s,;\\\"']+", "Bearer [REDACTED]");
|
||||
text = text.replaceAll("(?i)((?:client_secret|access_token|refresh_token|password|authorization)[\\\"']?\\s*[:=]\\s*)[\\\"']?[^\\s&,;}\\\"']+[\\\"']?", "$1[REDACTED]");
|
||||
text = text.replaceAll("(https?://[^\\s?#]+)\\?[^\\s]+", "$1?[REDACTED]");
|
||||
return text.replace('\r', ' ').replace('\n', ' ');
|
||||
}
|
||||
|
||||
private synchronized void invalidateToken() {
|
||||
accessToken = null;
|
||||
tokenExpiresAt = 0;
|
||||
}
|
||||
|
||||
private String baseUrl() {
|
||||
String value = requireConfig("base-url");
|
||||
URI uri = URI.create(value);
|
||||
if ((!"http".equals(uri.getScheme()) && !"https".equals(uri.getScheme()))
|
||||
|| uri.getHost() == null || uri.getQuery() != null || uri.getFragment() != null || uri.getUserInfo() != null) {
|
||||
throw new BaseException("通达OA base-url 必须为不含账号、查询参数和锚点的HTTP(S)地址");
|
||||
}
|
||||
return StrUtil.removeSuffix(value, "/");
|
||||
}
|
||||
|
||||
private String requireConfig(String key) {
|
||||
String value = conf.get(PREFIX + key);
|
||||
if (StrUtil.isBlank(value)) throw new BaseException("通达OA配置 {} 未填写", PREFIX + key);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
/** 每个参数单独编码,保留标题中的中文、引号及 Base64 中的 +、/、=。 */
|
||||
private String encodeParams(Map<String, Object> params) {
|
||||
return params.entrySet().stream().map(entry -> URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)
|
||||
+ "=" + URLEncoder.encode(String.valueOf(entry.getValue()), StandardCharsets.UTF_8))
|
||||
.collect(Collectors.joining("&"));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import cn.hutool.http.HtmlUtil;
|
||||
import com.budwk.app.base.constant.RedisConstant;
|
||||
import com.budwk.app.base.service.impl.BaseServiceImpl;
|
||||
import com.budwk.app.base.sms.SmsService;
|
||||
import com.budwk.app.base.wxwork.WxWorkMessageService;
|
||||
import com.budwk.app.base.sms.model.MsgPlatformWechatRequest;
|
||||
import com.budwk.app.base.utils.ManyAddOrRenewUtil;
|
||||
import com.budwk.app.sys.models.Sys_msg;
|
||||
@@ -62,6 +63,8 @@ public class SysMsgServiceImpl extends BaseServiceImpl<Sys_msg> implements SysMs
|
||||
private ManyAddOrRenewUtil manyAddOrRenewUtil;
|
||||
@Inject
|
||||
private SmsService smsService;
|
||||
@Inject
|
||||
private WxWorkMessageService wxWorkMessageService;
|
||||
|
||||
private static NutMap typeMap = NutMap.NEW().addv("system", "系统消息").addv("user", "用户消息");
|
||||
|
||||
@@ -102,14 +105,29 @@ public class SysMsgServiceImpl extends BaseServiceImpl<Sys_msg> implements SysMs
|
||||
}
|
||||
});
|
||||
|
||||
//发送学校平台消息
|
||||
ThreadUtil.execute(() -> {
|
||||
for (String loginName : loginNames) {
|
||||
smsService.sendSmsByAccount(loginName,sysMsg.getNote());
|
||||
smsService.sendWechatTextByAccountOrMobile(loginName,sysMsg.getNote());
|
||||
}
|
||||
|
||||
});
|
||||
// isExternal=false 为仅站内通知;外部通道分别处理异常,避免短信失败阻断企业微信。
|
||||
if (isExternal) {
|
||||
ThreadUtil.execute(() -> {
|
||||
for (String loginName : loginNames) {
|
||||
try {
|
||||
smsService.sendSmsByAccount(loginName, sysMsg.getNote());
|
||||
} catch (Exception e) {
|
||||
log.error("消息短信发送失败,msgId=" + sysMsg.getId(), e);
|
||||
}
|
||||
}
|
||||
});
|
||||
ThreadUtil.execute(() -> {
|
||||
try {
|
||||
// 学校企业微信 userid 直接使用 loginname;批量发送标题、正文及业务链接。
|
||||
NutMap result = wxWorkMessageService.massSend(loginNames, sysMsg.getTitle(), sysMsg.getNote(), sysMsg.getUrl());
|
||||
if (!result.getBoolean("skipped", false) && !result.getBoolean("success", false)) {
|
||||
log.warn("企业微信消息存在失败接收人,msgId=" + sysMsg.getId() + ",result=" + result);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("企业微信消息发送失败,msgId=" + sysMsg.getId(), e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return sysMsg;
|
||||
}
|
||||
@@ -292,11 +310,13 @@ public class SysMsgServiceImpl extends BaseServiceImpl<Sys_msg> implements SysMs
|
||||
}
|
||||
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public void sendMsg(String loginname, String title, String body, String sender) {
|
||||
sendMsg(List.of(loginname), title, body, SecurityUtil.getUserId());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public void sendMsg(List<String> loginNames, String title, String body, String sender) {
|
||||
Sys_msg sysMsg = new Sys_msg();
|
||||
sysMsg.setTitle(title);
|
||||
@@ -309,6 +329,7 @@ public class SysMsgServiceImpl extends BaseServiceImpl<Sys_msg> implements SysMs
|
||||
}
|
||||
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public void sendMsgInSys(List<String> loginNames, String title, String body, String sender) {
|
||||
Sys_msg sysMsg = new Sys_msg();
|
||||
sysMsg.setTitle(title);
|
||||
|
||||
@@ -189,25 +189,16 @@ public class WkFailProcessor extends ViewProcessor {
|
||||
if (Globals.sso) {
|
||||
//1.判断是否为企业微信浏览器并且对接了企业微信
|
||||
if (browserPlatform == BrowserPlatform.QI_YE_WEI_XIN && propertiesProxy.getBoolean("wxwork.enable", false)) {
|
||||
//进行企业微信授权
|
||||
// String token = getWxWorkToken();
|
||||
// if (StrUtil.isNotBlank(token)) {
|
||||
// //跳转到企业微信授权地址
|
||||
// new ServerRedirectView(buildWxWorkOauth2Url()).render(request, response, null);
|
||||
// } else {
|
||||
// //跳转到错误页 别问我为啥错了 我也不知道 首先去检查配置文件里的参数有没有写对
|
||||
//// new ServerRedirectView(Globals.AppDomain + errorUri).render(request, response, null);
|
||||
// //跳转到cas登录页
|
||||
// String callBackUrl = propertiesProxy.get("cas.client-host-url") + propertiesProxy.get("cas.client-call-back-url") + "?redirect=" + redirect;
|
||||
// String authUrl = propertiesProxy.get("cas.qi-wx-server-login-url") + "?service=" + URLEncoder.encode(callBackUrl, StandardCharsets.UTF_8);
|
||||
// new ServerRedirectView(authUrl).render(request, response, null);
|
||||
// }
|
||||
|
||||
// 武汉城市职业学院 跳转到企业微信cas登录页
|
||||
String callBackUrl = propertiesProxy.get("wecom.wecom-client-host-url") + propertiesProxy.get("wecom.wecom-client-call-back-url") + "?redirect=" + redirect;
|
||||
String authUrl = propertiesProxy.get("wecom.wecom-server-login-url") + "?service=" + URLEncoder.encode(callBackUrl, StandardCharsets.UTF_8);
|
||||
log.info("auth:"+authUrl);
|
||||
new ServerRedirectView(authUrl).render(request, response, null);
|
||||
// 专用企业微信认证配置缺失时整组回退到已接通的 CAS,保留卡片原始页面。
|
||||
try {
|
||||
String authUrl = buildEnterpriseCasUrl(redirect);
|
||||
log.info("auth:" + authUrl);
|
||||
new ServerRedirectView(authUrl).render(request, response, null);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.error("企业微信登录跳转配置无效:" + e.getMessage());
|
||||
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
|
||||
"统一身份认证配置不完整或无效,请联系管理员检查认证地址及回调地址");
|
||||
}
|
||||
} else if (browserPlatform == BrowserPlatform.WEI_XIN && propertiesProxy.getBoolean("wx.enable", false)) {
|
||||
//进行微信公众号授权
|
||||
//跳转到微信授权页
|
||||
@@ -225,6 +216,53 @@ public class WkFailProcessor extends ViewProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造企业微信内的 CAS 登录地址。redirect 为已 URL 编码的原始站内路径及查询参数;
|
||||
* 返回携带 service 参数的完整认证 URL,认证回调通过 redirect 返回卡片页面。
|
||||
* 三个 wecom 配置必须同时存在,否则整体使用 cas 配置,避免混用两套认证平台。
|
||||
*/
|
||||
private String buildEnterpriseCasUrl(String redirect) {
|
||||
String loginUrl = propertiesProxy.get("wecom.wecom-server-login-url");
|
||||
String clientHost = propertiesProxy.get("wecom.wecom-client-host-url");
|
||||
String callbackPath = propertiesProxy.get("wecom.wecom-client-call-back-url");
|
||||
if (missingAuthConfig(loginUrl) || missingAuthConfig(clientHost) || missingAuthConfig(callbackPath)) {
|
||||
loginUrl = propertiesProxy.get("cas.server-login-url");
|
||||
clientHost = propertiesProxy.get("cas.client-host-url");
|
||||
callbackPath = propertiesProxy.get("cas.client-call-back-url");
|
||||
}
|
||||
if (missingAuthConfig(loginUrl) || missingAuthConfig(clientHost) || missingAuthConfig(callbackPath)) {
|
||||
throw new IllegalArgumentException("CAS 登录地址、系统域名或回调路径为空");
|
||||
}
|
||||
loginUrl = loginUrl.trim();
|
||||
clientHost = clientHost.trim();
|
||||
callbackPath = callbackPath.trim();
|
||||
validateAuthUrl(loginUrl);
|
||||
validateAuthUrl(clientHost);
|
||||
// 回调必须是本系统路径,不能被绝对 URL 或双斜线路径覆盖到其他主机。
|
||||
if (!callbackPath.startsWith("/") || callbackPath.startsWith("//") || callbackPath.contains("#")) {
|
||||
throw new IllegalArgumentException("认证回调必须是以单斜线开头的站内路径");
|
||||
}
|
||||
String callback = clientHost.replaceAll("/+$", "") + callbackPath
|
||||
+ (callbackPath.contains("?") ? "&" : "?") + "redirect=" + redirect;
|
||||
// 保留部署配置中的 HTTP/HTTPS 协议;工会 HTTPS 回调不会被改为 HTTP。
|
||||
return loginUrl + (loginUrl.contains("?") ? "&" : "?") + "service="
|
||||
+ URLEncoder.encode(callback, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/** 空值和字面量 null 都视为未配置,防止拼接出浏览器相对地址 null?service=...。 */
|
||||
private boolean missingAuthConfig(String value) {
|
||||
return StrUtil.isBlank(value) || "null".equalsIgnoreCase(value.trim());
|
||||
}
|
||||
|
||||
/** 认证地址和回调域名必须为完整 HTTP(S) URL,拒绝无主机名、片段或用户信息的地址。 */
|
||||
private void validateAuthUrl(String value) {
|
||||
java.net.URI uri = java.net.URI.create(value);
|
||||
if (!("https".equalsIgnoreCase(uri.getScheme()) || "http".equalsIgnoreCase(uri.getScheme()))
|
||||
|| uri.getHost() == null || uri.getFragment() != null || uri.getUserInfo() != null) {
|
||||
throw new IllegalArgumentException("认证配置不是有效的 HTTP(S) 地址");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断浏览器平台
|
||||
*
|
||||
|
||||
+3
@@ -606,6 +606,9 @@ public class TourMySignupController {
|
||||
if (StrUtil.isBlank(ledger.getMobile())) {
|
||||
return Result.error("手机号不能为空");
|
||||
}
|
||||
if (!ledger.getMobile().matches("^1[3-9]\\d{9}$")) {
|
||||
return Result.error("请输入正确的11位手机号");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -841,6 +841,9 @@ public class TourSignupController {
|
||||
if (StrUtil.isBlank(ledger.getMobile())) {
|
||||
return Result.error("手机号不能为空");
|
||||
}
|
||||
if (!ledger.getMobile().matches("^1[3-9]\\d{9}$")) {
|
||||
return Result.error("请输入正确的11位手机号");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+32
-26
@@ -28,10 +28,10 @@ import com.budwk.app.web.commons.auth.utils.SecurityUtil;
|
||||
import com.budwk.app.zhgh.staffbenefit.condolence.model.Condolence;
|
||||
import com.budwk.app.zhgh.staffbenefit.condolence.model.CondolenceType;
|
||||
import com.budwk.app.zhgh.staffbenefit.condolence.service.CondolenceService;
|
||||
import com.deepoove.poi.XWPFTemplate;
|
||||
import com.deepoove.poi.data.PictureRenderData;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.nutz.aop.interceptor.ioc.TransAop;
|
||||
import org.nutz.dao.Cnd;
|
||||
import org.nutz.dao.Sqls;
|
||||
@@ -47,8 +47,6 @@ import org.nutz.mvc.annotation.Ok;
|
||||
import org.nutz.mvc.annotation.Param;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
|
||||
@@ -59,6 +57,7 @@ import java.util.List;
|
||||
* @Version 1.0
|
||||
* @Description TODO
|
||||
*/
|
||||
@Slf4j
|
||||
@IocBean
|
||||
@At("/platform/condolence/mine")
|
||||
@Api("职工慰问我的")
|
||||
@@ -157,6 +156,7 @@ public class CondolenceMineController {
|
||||
@ApiOperation("导出职工慰问申请表")
|
||||
@SaCheckPermission("condolence.mine")
|
||||
public void doExportApply(String id, HttpServletResponse response, Boolean print) throws Exception {
|
||||
log.info("[职工慰问导出] 开始,applyId={}, userId={}, format={}", id, SecurityUtil.getUserId(), Boolean.TRUE.equals(print) ? "PDF" : "Word");
|
||||
Condolence condolence = condolenceService.fetch(id);
|
||||
if (condolence == null) throw new BaseException("该条记录不存在");
|
||||
if (!SecurityUtil.getUserId().equals(condolence.getCreatedBy())) {
|
||||
@@ -167,36 +167,32 @@ public class CondolenceMineController {
|
||||
if (instance == null || !ProcessInstanceStateEnum.FINISHED.getCode().equals(instance.getState())) {
|
||||
throw new BaseException("只能导出已完成的申请");
|
||||
}
|
||||
log.info("[职工慰问导出] 流程已完成,applyId={}, instanceId={}, state={}, applicantSignPresent={}", id, instance.getId(), instance.getState(), StrUtil.isNotBlank(condolence.getSignature()));
|
||||
NutMap docData = Lang.obj2nutmap(condolence);
|
||||
docData.put("payUserName", StrUtil.nullToEmpty(condolence.getBankUserName()));
|
||||
CondolenceType type = condolenceService.dao().fetch(CondolenceType.class, condolence.getType());
|
||||
docData.put("typeName", type == null ? "" : type.getName());
|
||||
docData.put("signature", "");
|
||||
docData.put("signature", null);
|
||||
if (StrUtil.isNotBlank(condolence.getSignature())) {
|
||||
PictureRenderData signature = sysOfficeTemplateUtil.createPictureRenderData(90, 40, condolence.getSignature());
|
||||
if (signature == null) throw new BaseException("申请人签字图片读取失败");
|
||||
docData.put("signature", signature);
|
||||
}
|
||||
fillApplyApprovals(docData, instance);
|
||||
for (String field : List.of("signature", "firstApprovalSign", "secondApprovalSign", "thirdApprovalSign", "fourthApprovalSign")) {
|
||||
Object value = docData.get(field);
|
||||
log.info("[职工慰问导出] 渲染前图片字段,applyId={}, instanceId={}, field={}, valueType={}", id, instance.getId(), field, value == null ? "null" : value.getClass().getName());
|
||||
}
|
||||
String fileName = "重庆工贸职业技术学院职工慰问申请表_" + StrUtil.nullToEmpty(condolence.getHelpUserName());
|
||||
try (InputStream input = getClass().getResourceAsStream("/templates/condolence/重庆工贸职业技术学院职工慰问申请表.doc");
|
||||
ByteArrayOutputStream templateOutput = new ByteArrayOutputStream();
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||
try (InputStream input = getClass().getResourceAsStream("/templates/condolence/重庆工贸职业技术学院职工慰问申请表.doc")) {
|
||||
if (input == null) throw new BaseException("职工慰问申请表模板不存在");
|
||||
com.aspose.words.Document document = new com.aspose.words.Document(input);
|
||||
document.save(templateOutput, com.aspose.words.SaveFormat.DOCX);
|
||||
try (ByteArrayInputStream templateInput = new ByteArrayInputStream(templateOutput.toByteArray())) {
|
||||
XWPFTemplate.compile(templateInput).render(docData).writeAndClose(output);
|
||||
}
|
||||
if (Boolean.TRUE.equals(print)) {
|
||||
try (ByteArrayInputStream wordInput = new ByteArrayInputStream(output.toByteArray());
|
||||
ByteArrayOutputStream pdfOutput = new ByteArrayOutputStream()) {
|
||||
new com.aspose.words.Document(wordInput).save(pdfOutput, com.aspose.words.SaveFormat.PDF);
|
||||
CommonDownloadUtil.download(fileName + ".pdf", pdfOutput.toByteArray(), response);
|
||||
}
|
||||
} else {
|
||||
CommonDownloadUtil.download(fileName + ".docx", output.toByteArray(), response);
|
||||
}
|
||||
boolean pdf = Boolean.TRUE.equals(print);
|
||||
byte[] bytes = sysOfficeTemplateUtil.renderTemplate(input, docData, null, pdf, "condolence:" + id);
|
||||
log.info("[职工慰问导出] 渲染完成,applyId={}, format={}, outputBytes={}", id, pdf ? "PDF" : "Word", bytes.length);
|
||||
CommonDownloadUtil.download(fileName + (pdf ? ".pdf" : ".docx"), bytes, response);
|
||||
} catch (Exception e) {
|
||||
log.error("[职工慰问导出] 模板渲染或文件输出失败,applyId={}, instanceId={}, format={}", id, instance.getId(), Boolean.TRUE.equals(print) ? "PDF" : "Word", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,29 +202,39 @@ public class CondolenceMineController {
|
||||
String[] fields = {"firstApproval", "secondApproval", "thirdApproval", "fourthApproval"};
|
||||
List<String> suffixes = List.of("Opinion", "Sign", "Year", "Month", "Day");
|
||||
for (String field : fields) {
|
||||
for (String suffix : suffixes) docData.put(field + suffix, "");
|
||||
for (String suffix : suffixes) docData.put(field + suffix, "Sign".equals(suffix) ? null : "");
|
||||
}
|
||||
List<ProcessTask> tasks = condolenceService.dao().query(ProcessTask.class,
|
||||
Cnd.where("processInstanceId", "=", instance.getId()).asc("id"));
|
||||
log.info("[职工慰问导出] 查询审批记录,applyId={}, instanceId={}, taskCount={}, expectedTaskKeys={}", instance.getBusinessNo(), instance.getId(), tasks.size(), List.of(taskKeys));
|
||||
for (ProcessTask task : tasks) {
|
||||
ProcessTaskVO taskVO = flowEngine.processTaskService().findById(task.getId());
|
||||
if ("startTask".equals(task.getTaskName()) || (taskVO.getExt() != null && Boolean.TRUE.equals(taskVO.getExt().getBool("isFirstTaskNode")))) {
|
||||
log.info("[职工慰问导出] 审批记录,instanceId={}, taskId={}, taskKey={}, displayName={}, state={}, finishTime={}, isFirstTaskNode={}, matchedApproval={}", instance.getId(), task.getId(), task.getTaskName(), task.getDisplayName(), task.getTaskState(), task.getFinishTime(), taskVO.getExt() == null ? null : taskVO.getExt().get("isFirstTaskNode"), List.of(taskKeys).contains(task.getTaskName()));
|
||||
if ("startTask".equals(task.getTaskName()) || (!List.of(taskKeys).contains(task.getTaskName())
|
||||
&& taskVO.getExt() != null && Boolean.TRUE.equals(taskVO.getExt().getBool("isFirstTaskNode")))) {
|
||||
log.info("[职工慰问导出] 申请节点重置本轮审批字段,instanceId={}, taskId={}, taskKey={}", instance.getId(), task.getId(), task.getTaskName());
|
||||
// 重新申请后,仅导出本轮审批记录。
|
||||
for (String field : fields) {
|
||||
for (String suffix : suffixes) docData.put(field + suffix, "");
|
||||
for (String suffix : suffixes) docData.put(field + suffix, "Sign".equals(suffix) ? null : "");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (int i = 0; i < taskKeys.length; i++) {
|
||||
if (!taskKeys[i].equals(task.getTaskName())) continue;
|
||||
for (String suffix : suffixes) docData.put(fields[i] + suffix, "");
|
||||
if (!ProcessTaskStateEnum.FINISHED.getCode().equals(task.getTaskState())) continue;
|
||||
for (String suffix : suffixes) docData.put(fields[i] + suffix, "Sign".equals(suffix) ? null : "");
|
||||
if (!ProcessTaskStateEnum.FINISHED.getCode().equals(task.getTaskState())) {
|
||||
log.info("[职工慰问导出] 跳过未完成审批,instanceId={}, taskId={}, field={}, state={}", instance.getId(), task.getId(), fields[i], task.getTaskState());
|
||||
continue;
|
||||
}
|
||||
Dict data = taskVO.getExt() == null ? Dict.create() : taskVO.getTaskFormData();
|
||||
log.info("[职工慰问导出] 读取审批表单,instanceId={}, taskId={}, field={}, formKeys={}, signPresent={}, signReferenceLength={}", instance.getId(), task.getId(), fields[i], data.keySet(), StrUtil.isNotBlank(data.getStr("sign")), StrUtil.nullToEmpty(data.getStr("sign")).length());
|
||||
if (StrUtil.isBlank(data.getStr("sign"))) log.warn("[职工慰问导出] 已完成审批缺少签名字段,instanceId={}, taskId={}, field={}", instance.getId(), task.getId(), fields[i]);
|
||||
docData.put(fields[i] + "Opinion", StrUtil.nullToEmpty(data.getStr("opinion")));
|
||||
if (StrUtil.isNotBlank(data.getStr("sign"))) {
|
||||
PictureRenderData signature = sysOfficeTemplateUtil.createPictureRenderData(90, 40, data.getStr("sign"));
|
||||
if (signature == null) throw new BaseException("审批签字图片读取失败");
|
||||
docData.put(fields[i] + "Sign", signature);
|
||||
log.info("[职工慰问导出] 审批签名填充完成,instanceId={}, taskId={}, field={}", instance.getId(), task.getId(), fields[i] + "Sign");
|
||||
}
|
||||
docData.put(fields[i] + "Year", task.getFinishTime() == null ? "" : DateUtil.format(task.getFinishTime(), "yyyy"));
|
||||
docData.put(fields[i] + "Month", task.getFinishTime() == null ? "" : DateUtil.format(task.getFinishTime(), "M"));
|
||||
|
||||
+16
-39
@@ -9,7 +9,6 @@ import cn.dev33.satoken.annotation.SaCheckLogin;
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.io.IoUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.alibaba.excel.EasyExcel;
|
||||
@@ -18,7 +17,6 @@ import com.budwk.app.base.page.Pagination;
|
||||
import com.budwk.app.base.result.Result;
|
||||
import com.budwk.app.base.utils.CommonDownloadUtil;
|
||||
import com.budwk.app.base.utils.MoneyUtil;
|
||||
import com.budwk.app.base.utils.OfficePlusUtil;
|
||||
import com.budwk.app.base.utils.SysOfficeTemplateUtil;
|
||||
import com.budwk.app.flow.engine.FlowEngine;
|
||||
import com.budwk.app.flow.entity.ProcessInstance;
|
||||
@@ -39,7 +37,6 @@ import com.budwk.app.zhgh.staffbenefit.difficulthelp.service.DifficultHelpCommon
|
||||
import com.budwk.app.zhgh.staffbenefit.difficulthelp.vo.DifficultHelpPageParam;
|
||||
import com.budwk.app.zhgh.staffbenefit.difficulthelp.vo.moneyImportVo;
|
||||
import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.aidFund.param.AidFundPageForm;
|
||||
import com.deepoove.poi.XWPFTemplate;
|
||||
import com.deepoove.poi.config.Configure;
|
||||
import com.deepoove.poi.data.PictureRenderData;
|
||||
import com.deepoove.poi.plugin.table.LoopRowTableRenderPolicy;
|
||||
@@ -74,8 +71,6 @@ import javax.servlet.http.HttpServletResponse;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.*;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
@@ -319,9 +314,9 @@ public class DifficultHelpReadingController {
|
||||
LoopRowTableRenderPolicy policy = new LoopRowTableRenderPolicy();
|
||||
Configure config = Configure.builder().bind("familyList", policy).build();
|
||||
|
||||
XWPFTemplate.compile(sysOfficeTemplateUtil.getTemplate("difficulty_apply"), config)
|
||||
.render(docData)
|
||||
.write(zipOutputStream);
|
||||
try (InputStream input = sysOfficeTemplateUtil.getTemplate("difficulty_apply")) {
|
||||
zipOutputStream.write(sysOfficeTemplateUtil.renderTemplate(input, docData, config, false, "difficulthelp:batch:" + record.getString("id")));
|
||||
}
|
||||
zipOutputStream.closeEntry();
|
||||
}
|
||||
zipOutputStream.flush();
|
||||
@@ -379,17 +374,18 @@ public class DifficultHelpReadingController {
|
||||
String[] taskKeys = {"36ebafda-44c2-4bd3-bd7f-10839a575a0d", "715ece82-4ae3-433c-ad18-ed875f45d1e3", "e405f96c-5fc0-4f63-b323-e69dfe853253"};
|
||||
String[] fields = {"firstApproval", "secondApproval", "thirdApproval"};
|
||||
for (String field : fields) {
|
||||
for (String suffix : List.of("", "Opinion", "User", "Date", "Year", "Month", "Day", "Sign")) docData.put(field + suffix, "");
|
||||
for (String suffix : List.of("", "Opinion", "User", "Date", "Year", "Month", "Day", "Sign")) docData.put(field + suffix, "Sign".equals(suffix) ? null : "");
|
||||
}
|
||||
ProcessInstance instance = dao.fetch(ProcessInstance.class, Cnd.where("businessNo", "=", businessId).desc("id"));
|
||||
if (instance == null) return;
|
||||
List<ProcessTask> tasks = dao.query(ProcessTask.class, Cnd.where("processInstanceId", "=", instance.getId()).asc("id"));
|
||||
for (ProcessTask task : tasks) {
|
||||
ProcessTaskVO taskVO = flowEngine.processTaskService().findById(task.getId());
|
||||
if ("startTask".equals(task.getTaskName()) || (taskVO.getExt() != null && Boolean.TRUE.equals(taskVO.getExt().getBool("isFirstTaskNode")))) {
|
||||
if ("startTask".equals(task.getTaskName()) || (!List.of(taskKeys).contains(task.getTaskName())
|
||||
&& taskVO.getExt() != null && Boolean.TRUE.equals(taskVO.getExt().getBool("isFirstTaskNode")))) {
|
||||
// 重新申请后,仅导出本轮审批,避免混入上一次退回或撤销前的意见。
|
||||
for (String field : fields) {
|
||||
for (String suffix : List.of("", "Opinion", "User", "Date", "Year", "Month", "Day", "Sign")) docData.put(field + suffix, "");
|
||||
for (String suffix : List.of("", "Opinion", "User", "Date", "Year", "Month", "Day", "Sign")) docData.put(field + suffix, "Sign".equals(suffix) ? null : "");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -453,22 +449,9 @@ public class DifficultHelpReadingController {
|
||||
String fileName = FileUtil.mainName(sysOfficeTemplateUtil.getTemplateFileName(templateName)) + "_" + difficultHelp.getUserName() + "_" +
|
||||
DateUtil.format(difficultHelp.getApplyTime(), "yyyyMMdd") + ".pdf";
|
||||
|
||||
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
|
||||
XWPFTemplate.compile(getApplyTemplate(templateName),config)
|
||||
.render(docData)
|
||||
.writeAndClose(byteArrayOutputStream);
|
||||
byte[] bytes = byteArrayOutputStream.toByteArray();
|
||||
File sourceFile = File.createTempFile("file_convert_origin", ".docx");
|
||||
Files.write(sourceFile.toPath(), bytes, StandardOpenOption.WRITE);
|
||||
|
||||
File pdfFile = File.createTempFile("file_convert", ".pdf");
|
||||
OfficePlusUtil.convert(sourceFile.getPath(), pdfFile.getPath());
|
||||
CommonDownloadUtil.download(fileName, IoUtil.readBytes(FileUtil.getInputStream(pdfFile)), response);
|
||||
// 删除临时文件
|
||||
FileUtil.del(sourceFile);
|
||||
FileUtil.del(pdfFile.toPath());
|
||||
} catch (IOException e) {
|
||||
log.error("困难慰问申请表导出PDF失败,id:{},错误信息:{}", difficultHelp.getId(), e.getMessage());
|
||||
try (InputStream input = getApplyTemplate(templateName)) {
|
||||
byte[] bytes = sysOfficeTemplateUtil.renderTemplate(input, docData, config, true, "difficulthelp:" + difficultHelp.getId());
|
||||
CommonDownloadUtil.download(fileName, bytes, response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,13 +461,9 @@ public class DifficultHelpReadingController {
|
||||
String fileName = FileUtil.mainName(sysOfficeTemplateUtil.getTemplateFileName(templateName)) + "_" + difficultHelp.getUserName() + "_" +
|
||||
DateUtil.format(difficultHelp.getApplyTime(), "yyyyMMdd") + ".docx";
|
||||
|
||||
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
|
||||
XWPFTemplate.compile(getApplyTemplate(templateName),config)
|
||||
.render(docData)
|
||||
.writeAndClose(byteArrayOutputStream);
|
||||
CommonDownloadUtil.download(fileName, byteArrayOutputStream.toByteArray(), response);
|
||||
} catch (IOException e) {
|
||||
log.error("困难慰问申请表导出Word失败,id:{},错误信息:{}", difficultHelp.getId(), e.getMessage());
|
||||
try (InputStream input = getApplyTemplate(templateName)) {
|
||||
byte[] bytes = sysOfficeTemplateUtil.renderTemplate(input, docData, config, false, "difficulthelp:" + difficultHelp.getId());
|
||||
CommonDownloadUtil.download(fileName, bytes, response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,15 +541,13 @@ public class DifficultHelpReadingController {
|
||||
throw new RuntimeException("导出Excel汇总表失败:" + e.getMessage());
|
||||
}
|
||||
} else if ("docx".equals(flag)) {
|
||||
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
try (InputStream input = sysOfficeTemplateUtil.getTemplate(resource)) {
|
||||
Configure config = Configure.builder()
|
||||
.bind("da", new LoopRowTableRenderPolicy())
|
||||
.build();
|
||||
|
||||
XWPFTemplate.compile(sysOfficeTemplateUtil.getTemplate(resource), config)
|
||||
.render(result)
|
||||
.writeAndClose(out);
|
||||
CommonDownloadUtil.download(fileName + ".docx", out.toByteArray(), response);
|
||||
byte[] bytes = sysOfficeTemplateUtil.renderTemplate(input, result, config, false, "difficulthelp:summary:" + resource);
|
||||
CommonDownloadUtil.download(fileName + ".docx", bytes, response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -1,6 +1,7 @@
|
||||
package com.budwk.app.zhgh.staffbenefit.difficulthelp.service;
|
||||
|
||||
import com.budwk.app.base.service.BaseService;
|
||||
import com.budwk.app.flow.entity.ProcessTask;
|
||||
import com.budwk.app.zhgh.staffbenefit.difficulthelp.models.DifficultHelpInfo;
|
||||
import com.budwk.app.zhgh.staffbenefit.difficulthelp.vo.DifficultHelpPageParam;
|
||||
import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.aidFund.param.AidFundPageForm;
|
||||
@@ -21,6 +22,14 @@ public interface DifficultHelpCommonService extends BaseService<DifficultHelpInf
|
||||
|
||||
void validateApplyUser(DifficultHelpInfo difficultHelpInfo);
|
||||
|
||||
/**
|
||||
* 困难帮扶进入校工会办公室主任审批时,通知本次任务的实际参与人。
|
||||
* @param task WF 已创建且参与人已落库的任务;仅 KNBF 办公室审批待办触发,
|
||||
* 接收人使用任务参与人的 actorAccount(系统 loginname)
|
||||
* @return 无返回值;开关关闭或非目标节点不发送,发送失败记录日志而不阻断流程
|
||||
*/
|
||||
void notifyOfficeApproval(ProcessTask task);
|
||||
|
||||
/**
|
||||
* 导入实际金额数据处理
|
||||
* @param file 文件
|
||||
|
||||
+59
@@ -6,6 +6,12 @@ import cn.hutool.json.JSONUtil;
|
||||
import com.budwk.app.base.service.impl.BaseServiceImpl;
|
||||
import com.budwk.app.base.constant.RoleConstant;
|
||||
import com.budwk.app.base.exception.BaseException;
|
||||
import com.budwk.app.base.wxwork.WxWorkMessageService;
|
||||
import com.budwk.app.flow.entity.ProcessDefine;
|
||||
import com.budwk.app.flow.entity.ProcessInstance;
|
||||
import com.budwk.app.flow.entity.ProcessTask;
|
||||
import com.budwk.app.flow.entity.ProcessTaskActor;
|
||||
import com.budwk.app.flow.enums.ProcessTaskStateEnum;
|
||||
import com.budwk.app.web.commons.auth.utils.AuthUtil;
|
||||
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
|
||||
import com.budwk.app.base.utils.ManyAddOrRenewUtil;
|
||||
@@ -31,6 +37,8 @@ import org.nutz.ioc.loader.annotation.IocBean;
|
||||
import org.nutz.lang.Lang;
|
||||
import org.nutz.lang.random.R;
|
||||
import org.nutz.lang.util.NutMap;
|
||||
import org.nutz.log.Log;
|
||||
import org.nutz.log.Logs;
|
||||
import org.nutz.mvc.upload.TempFile;
|
||||
|
||||
import java.util.*;
|
||||
@@ -46,6 +54,8 @@ import java.util.stream.IntStream;
|
||||
*/
|
||||
@IocBean(args = {"refer:dao"})
|
||||
public class DifficultHelpCommonServiceImpl extends BaseServiceImpl<DifficultHelpInfo> implements DifficultHelpCommonService {
|
||||
private static final Log log = Logs.get();
|
||||
private static final String OFFICE_APPROVAL_TASK = "36ebafda-44c2-4bd3-bd7f-10839a575a0d";
|
||||
|
||||
public DifficultHelpCommonServiceImpl(Dao dao) {
|
||||
super(dao);
|
||||
@@ -53,6 +63,55 @@ public class DifficultHelpCommonServiceImpl extends BaseServiceImpl<DifficultHel
|
||||
|
||||
@Inject
|
||||
private ManyAddOrRenewUtil manyAddOrRenewUtil;
|
||||
@Inject
|
||||
private WxWorkMessageService wxWorkMessageService;
|
||||
|
||||
/**
|
||||
* 只通知 KNBF 办公室待办的实际审核人;重新提交生成的新任务沿用同一入口。
|
||||
* task 为任务开始事件对应的 WF 任务,actorAccount 为学校企业微信 userid。
|
||||
* 无返回值;服务返回 skipped 表示开关关闭,success=false 表示接口失败或部分接收人无效。
|
||||
*/
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public void notifyOfficeApproval(ProcessTask task) {
|
||||
if (task == null || task.getId() == null || task.getProcessInstanceId() == null
|
||||
|| !OFFICE_APPROVAL_TASK.equals(task.getTaskName())
|
||||
|| !ProcessTaskStateEnum.DOING.getCode().equals(task.getTaskState())) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ProcessInstance instance = dao().fetch(ProcessInstance.class, task.getProcessInstanceId());
|
||||
if (instance == null || instance.getProcessDefineId() == null) return;
|
||||
ProcessDefine define = dao().fetch(ProcessDefine.class, instance.getProcessDefineId());
|
||||
// 使用实例所属定义核验流程 KEY,避免其他流程复用同一个节点 ID 时误发。
|
||||
if (define == null || !"KNBF".equals(define.getName())) return;
|
||||
DifficultHelpInfo info = StrUtil.isBlank(instance.getBusinessNo()) ? null : fetch(instance.getBusinessNo());
|
||||
if (info == null) {
|
||||
log.warnf("困难帮扶企业微信通知跳过:申请不存在,taskId=%s", task.getId());
|
||||
return;
|
||||
}
|
||||
List<ProcessTaskActor> actors = dao().query(ProcessTaskActor.class,
|
||||
Cnd.where(ProcessTaskActor::getProcessTaskId, "=", task.getId()));
|
||||
List<String> loginNames = actors.stream().map(ProcessTaskActor::getActorAccount)
|
||||
.filter(StrUtil::isNotBlank).map(String::trim).distinct().toList();
|
||||
// 不按角色扩大接收范围,未绑定工号时不以内部用户 ID 代替企业微信 userid。
|
||||
if (loginNames.isEmpty()) {
|
||||
log.warnf("困难帮扶企业微信通知跳过:审核人 loginname 为空,taskId=%s", task.getId());
|
||||
return;
|
||||
}
|
||||
String content = "您有一条困难帮扶申请待审核,受助人:"
|
||||
+ StrUtil.blankToDefault(info.getUserName(), "未填写") + ",请进入校工会办公室审批页面办理。";
|
||||
// 流程定义中仍保留旧的 unionLeaderApproval 链接,本节点实际办理入口为 branchUnionApproval。
|
||||
NutMap result = wxWorkMessageService.massSend(loginNames, "困难帮扶待办提醒", content,
|
||||
"/platform/difficultHelp/branchUnionApproval/h5");
|
||||
if (!result.getBoolean("skipped", false) && !result.getBoolean("success", false)) {
|
||||
log.warnf("困难帮扶企业微信通知发送失败或部分失败,taskId=%s,result=%s", task.getId(), result);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 外部消息不改变审批结果;日志保留任务 ID,便于定位接口、账号或配置问题。
|
||||
log.error("困难帮扶企业微信通知异常,taskId=" + task.getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validateApplyUser(DifficultHelpInfo difficultHelpInfo) {
|
||||
|
||||
@@ -735,7 +735,6 @@ public class WelfareListServiceImpl extends BaseServiceImpl<WelfareList> impleme
|
||||
entities.add(new ExcelExportEntity("在职状态", "userState", 20));
|
||||
entities.add(new ExcelExportEntity("所属工会", "welfareUnionName", 20));
|
||||
entities.add(new ExcelExportEntity("所属单位", "welfareUnitName", 20));
|
||||
entities.add(new ExcelExportEntity("三级单位", "threeUnitName", 20));
|
||||
entities.add(new ExcelExportEntity("备注", "remark", 20));
|
||||
|
||||
// 设置导出参数
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
7pKr23u2tZHcvXud
|
||||
@@ -35,22 +35,58 @@ module.exports = {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
//前端唯一标识 当然后端还有用户id作为唯一标识
|
||||
// 二维码携带本次组件实例的标识,与服务端当前登录用户共同确定签字记录。
|
||||
id: new Date().getTime() + Math.floor(Math.random() * 10000) + 1,
|
||||
signatureAddress: null,
|
||||
interval: null,
|
||||
connectionWaitResolve: null,
|
||||
unsubscribeSignature: null,
|
||||
signatureContent: null,
|
||||
loading: true,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async init() {
|
||||
const {code, data} = await this.$axios.post("/platform/signature/get")
|
||||
if (code === 0) {
|
||||
this.signatureContent = data && data.signature
|
||||
this.$emit("input", this.signatureContent)
|
||||
}
|
||||
/**
|
||||
* 无请求参数,返回 Promise;响应 code 为0表示成功,data.signature 为已有签字图片地址。
|
||||
* 请求结束后由 mounted 的 finally 统一关闭 loading。
|
||||
*/
|
||||
init() {
|
||||
this.signatureAddress = origin + "/platform/signature/scanPcCode?id=" + this.id
|
||||
return this.$axios.post("/platform/signature/get")
|
||||
.then((res) => {
|
||||
if (this._isBeingDestroyed || this._isDestroyed) {
|
||||
return
|
||||
}
|
||||
if (res.code !== 0) {
|
||||
throw new Error("签字信息加载失败")
|
||||
}
|
||||
this.signatureContent = res.data && res.data.signature
|
||||
this.$emit("input", this.signatureContent)
|
||||
})
|
||||
},
|
||||
// 返回等待完成的 Promise;连接成功、等待30秒或组件销毁时结束等待。
|
||||
waitForConnection() {
|
||||
return new Promise((resolve) => {
|
||||
const startedAt = Date.now()
|
||||
this.connectionWaitResolve = resolve
|
||||
const checkReady = () => {
|
||||
this.interval = null
|
||||
if (this._isBeingDestroyed || this._isDestroyed ||
|
||||
(webSocketPubSub.ws && webSocketPubSub.ws.readyState === WebSocket.OPEN)) {
|
||||
this.connectionWaitResolve = null
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
if (Date.now() - startedAt >= 30000) {
|
||||
this.connectionWaitResolve = null
|
||||
this.$message.warning("签字消息连接超时,请刷新页面重试")
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
this.interval = setTimeout(checkReady, 100)
|
||||
}
|
||||
checkReady()
|
||||
})
|
||||
},
|
||||
signatureAgain() {
|
||||
const val = sessionStorage.getItem("h5-scan-code-signature-" + this.id)
|
||||
@@ -58,25 +94,42 @@ module.exports = {
|
||||
this.$emit("input", val)
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.init()
|
||||
webSocketPubSub.subscribe("h5-scan-code-signature", (data) => {
|
||||
console.info("ws:收到签字成功消息:", data)
|
||||
mounted() {
|
||||
this.loading = true
|
||||
// 先登记消息回调,连接成功后由公共连接加入房间,避免请求期间遗漏订阅。
|
||||
this.unsubscribeSignature = webSocketPubSub.subscribe("h5-scan-code-signature", (data) => {
|
||||
this.signatureContent = data.value
|
||||
this.$emit("input", data.value)
|
||||
})
|
||||
|
||||
// 可选:轮询或 watch ready 状态
|
||||
const checkReady = () => {
|
||||
if (webSocketPubSub.ws && webSocketPubSub.ws.readyState === WebSocket.OPEN) {
|
||||
console.info("ws.readyState is open");
|
||||
this.loading = false;
|
||||
console.log(this.signatureAddress)
|
||||
} else {
|
||||
setTimeout(checkReady, 100);
|
||||
}
|
||||
};
|
||||
checkReady();
|
||||
this.init()
|
||||
.then(() => {
|
||||
if (!this._isBeingDestroyed && !this._isDestroyed) {
|
||||
return this.waitForConnection()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!this._isBeingDestroyed && !this._isDestroyed) {
|
||||
this.$message.error("签字信息加载失败,请刷新页面重试")
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!this._isBeingDestroyed && !this._isDestroyed) {
|
||||
this.loading = false
|
||||
}
|
||||
})
|
||||
},
|
||||
beforeDestroy() {
|
||||
// pjax 切换页面时取消轮询和订阅,旧组件不得继续接收签字消息。
|
||||
clearTimeout(this.interval)
|
||||
this.interval = null
|
||||
if (this.connectionWaitResolve) {
|
||||
this.connectionWaitResolve()
|
||||
this.connectionWaitResolve = null
|
||||
}
|
||||
if (this.unsubscribeSignature) {
|
||||
this.unsubscribeSignature()
|
||||
this.unsubscribeSignature = null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
progress
|
||||
:accept="accept"
|
||||
:max-count="upload_number"
|
||||
:upload-text="upload_text"
|
||||
title=""
|
||||
description=""
|
||||
tip=""
|
||||
@@ -50,6 +51,11 @@ module.exports = {
|
||||
default: 1,
|
||||
required: false
|
||||
},
|
||||
upload_text: {
|
||||
type: String,
|
||||
default: "",
|
||||
required: false
|
||||
},
|
||||
// 上传返回id或url
|
||||
upload_result_type: {
|
||||
type: String,
|
||||
@@ -101,6 +107,7 @@ module.exports = {
|
||||
this.fileList = val.map((v) => {
|
||||
return {
|
||||
...v,
|
||||
file: v.file || { name: v.name },
|
||||
status: null,
|
||||
isImage: ["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(v.name.split(".").pop().toLowerCase())
|
||||
}
|
||||
@@ -118,6 +125,7 @@ module.exports = {
|
||||
this.fileList = val.map((v) => {
|
||||
return {
|
||||
...v,
|
||||
file: v.file || { name: v.name },
|
||||
url: v.url ? v.url : v.response?.data,
|
||||
isImage: ["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(v.name.split(".").pop().toLowerCase()),
|
||||
status: null
|
||||
@@ -171,8 +179,8 @@ module.exports = {
|
||||
f.url = resp.data
|
||||
f.response = resp
|
||||
f.percentage = 100
|
||||
f.isImage = true
|
||||
delete f.file
|
||||
f.isImage = ["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(f.name.split(".").pop().toLowerCase())
|
||||
f.file = { name: f.name }
|
||||
delete f.content
|
||||
} else {
|
||||
f.status = "fail"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -443,50 +443,76 @@
|
||||
constructor() {
|
||||
this.ws = null
|
||||
this.subscribers = new Map()
|
||||
this.isSubscribed = false // 添加订阅状态标记
|
||||
this.isSubscribed = false
|
||||
this.connectTimer = null
|
||||
this.reconnectTimer = null
|
||||
this.pingTimer = null
|
||||
this.isDisposed = false
|
||||
this.init()
|
||||
}
|
||||
|
||||
// 页面只创建一个心跳任务;每次连接自行绑定事件及握手超时。
|
||||
init() {
|
||||
this.setupWindowEvents()
|
||||
this.setupPing()
|
||||
this.connect()
|
||||
}
|
||||
|
||||
// 正在连接或关闭时等待对应事件,避免同时创建多个连接。
|
||||
connect() {
|
||||
if (this.isDisposed || this.reconnectTimer !== null ||
|
||||
(this.ws && this.ws.readyState !== WebSocket.CLOSED)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.connect()
|
||||
const WS_URL = window.location.host + "${base}/websocket"
|
||||
const protocol = window.location.protocol === "http:" ? "ws://" : "wss://"
|
||||
this.ws = new WebSocket(protocol + WS_URL)
|
||||
this.isSubscribed = false
|
||||
this.setupEventListeners()
|
||||
this.setupPing()
|
||||
this.setupWindowEvents()
|
||||
} catch (ex) {
|
||||
console.info(ex)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
connect() {
|
||||
const WS_URL = window.location.host + "${base}/websocket"
|
||||
const protocol = window.location.protocol === "http:" ? "ws://" : "wss://"
|
||||
this.ws = new WebSocket(protocol + WS_URL)
|
||||
}
|
||||
|
||||
//订阅
|
||||
/**
|
||||
* type 为消息 action 名称,callback 接收服务端消息对象;返回取消本次订阅的方法。
|
||||
* 连接尚未建立时先保留回调,在 onopen 中统一加入当前用户的消息房间。
|
||||
*/
|
||||
subscribe(type, callback) {
|
||||
if (!this.subscribers.has(type)) {
|
||||
this.subscribers.set(type, new Set())
|
||||
}
|
||||
this.subscribers.get(type).add(callback)
|
||||
|
||||
setTimeout(() => {
|
||||
// 所有订阅完成后,发送 join 消息
|
||||
if (!this.isSubscribed && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.isSubscribed = true
|
||||
this.sendJoinMessage()
|
||||
const callbacks = this.subscribers.get(type)
|
||||
callbacks.add(callback)
|
||||
this.sendJoinMessage()
|
||||
return () => {
|
||||
callbacks.delete(callback)
|
||||
if (callbacks.size === 0 && this.subscribers.get(type) === callbacks) {
|
||||
this.subscribers.delete(type)
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
// 事件处理
|
||||
// 事件绑定到本次连接,旧连接的延迟事件不得影响新连接。
|
||||
setupEventListeners() {
|
||||
// 消息处理
|
||||
this.ws.onmessage = (event) => {
|
||||
const socket = this.ws
|
||||
socket.onmessage = (event) => {
|
||||
if (this.isDisposed || this.ws !== socket) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
// 触发订阅者的回调
|
||||
if (this.subscribers.has(data.action)) {
|
||||
this.subscribers.get(data.action).forEach((callback) => {
|
||||
try {
|
||||
@@ -500,82 +526,112 @@
|
||||
console.error("消息解析错误:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// 连接建立
|
||||
this.ws.onopen = () => {
|
||||
// this.ws.send(
|
||||
// JSON.stringify({
|
||||
// room: store.state.room,
|
||||
// action: "join"
|
||||
// })
|
||||
// )
|
||||
// 连接建立时不立即发送 join,等待订阅完成
|
||||
this.ping()
|
||||
}
|
||||
}
|
||||
|
||||
// 新增发送 join 消息的方法
|
||||
sendJoinMessage() {
|
||||
this.send({
|
||||
room: store.state.room,
|
||||
action: "join"
|
||||
})
|
||||
}
|
||||
|
||||
// 心跳检测
|
||||
setupPing() {
|
||||
setInterval(() => this.ping(), (11624317 / 1000) * 2)
|
||||
}
|
||||
|
||||
ping() {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.ws.send("{}")
|
||||
} catch (e) {
|
||||
this.reconnect()
|
||||
socket.onopen = () => {
|
||||
if (this.isDisposed || this.ws !== socket) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
clearTimeout(this.connectTimer)
|
||||
this.connectTimer = null
|
||||
this.sendJoinMessage()
|
||||
this.ping()
|
||||
console.info("WebSocket 连接已建立", socket.url)
|
||||
}
|
||||
socket.onerror = () => {
|
||||
if (!this.isDisposed && this.ws === socket) {
|
||||
console.warn("WebSocket 连接异常,等待关闭后重连", socket.url)
|
||||
}
|
||||
}
|
||||
socket.onclose = (event) => {
|
||||
if (this.isDisposed || this.ws !== socket) {
|
||||
return
|
||||
}
|
||||
clearTimeout(this.connectTimer)
|
||||
this.connectTimer = null
|
||||
this.isSubscribed = false
|
||||
console.warn("WebSocket 已关闭", event.code, event.reason)
|
||||
this.reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// 每个已建立的连接只加入一次房间,首次连接及断线恢复都补发已有订阅。
|
||||
sendJoinMessage() {
|
||||
if (this.isDisposed || this.isSubscribed || this.subscribers.size === 0 ||
|
||||
!window.store || !store.state.room || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
this.send({
|
||||
room: store.state.room,
|
||||
action: "join"
|
||||
})
|
||||
this.isSubscribed = true
|
||||
}
|
||||
|
||||
// 沿用原心跳间隔,仅对已建立的连接发送心跳。
|
||||
setupPing() {
|
||||
this.pingTimer = setInterval(() => this.ping(), (11624317 / 1000) * 2)
|
||||
}
|
||||
|
||||
// CONNECTING/CLOSING 状态分别由握手超时和关闭事件处理。
|
||||
ping() {
|
||||
if (this.isDisposed) {
|
||||
return
|
||||
}
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.ws.send("{}")
|
||||
} catch (error) {
|
||||
console.warn("WebSocket 心跳发送失败,等待关闭后重连", error)
|
||||
this.ws.close()
|
||||
}
|
||||
} else if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
|
||||
this.reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭事件和心跳共用一个重连任务,避免重复定时器创建并行连接。
|
||||
reconnect() {
|
||||
if (this.ws) {
|
||||
this.ws.close() // 确保旧连接已关闭
|
||||
if (this.isDisposed || this.reconnectTimer !== null ||
|
||||
(this.ws && this.ws.readyState !== WebSocket.CLOSED)) {
|
||||
return
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.connect()
|
||||
this.setupEventListeners()
|
||||
// 如果之前已订阅,重新发送join消息
|
||||
if (this.isSubscribed) {
|
||||
setTimeout(() => {
|
||||
if (this.ws.readyState === WebSocket.OPEN) {
|
||||
this.sendJoinMessage()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
}, 3000) // 延迟3秒重连
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// 窗口事件处理
|
||||
// 真正离开页面时释放连接和定时器;进入往返缓存时保留页面状态。
|
||||
setupWindowEvents() {
|
||||
let _beforeUnload_time = 0
|
||||
window.onbeforeunload = () => {
|
||||
_beforeUnload_time = new Date().getTime()
|
||||
}
|
||||
window.onunload = () => {
|
||||
const _gap_time = new Date().getTime() - _beforeUnload_time
|
||||
if (_gap_time <= 5) {
|
||||
this.send({
|
||||
room: "${@auth.getPrincipalProperty('loginname')}:${@auth.getSessionId()}",
|
||||
action: "left"
|
||||
})
|
||||
window.addEventListener("pagehide", (event) => {
|
||||
if (event.persisted) {
|
||||
return
|
||||
}
|
||||
}
|
||||
this.isDisposed = true
|
||||
clearTimeout(this.connectTimer)
|
||||
clearTimeout(this.reconnectTimer)
|
||||
clearInterval(this.pingTimer)
|
||||
this.connectTimer = null
|
||||
this.reconnectTimer = null
|
||||
this.pingTimer = null
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.send({
|
||||
room: store.state.room,
|
||||
action: "left"
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn("WebSocket 离开房间消息发送失败", error)
|
||||
}
|
||||
}
|
||||
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
|
||||
this.ws.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// message 为消息对象或 JSON 字符串;连接未建立时不发送,返回值为 undefined。
|
||||
send(message) {
|
||||
if (this.ws) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
const messageStr = typeof message === "string" ? message : JSON.stringify(message)
|
||||
this.ws.send(messageStr)
|
||||
}
|
||||
|
||||
@@ -68,9 +68,10 @@
|
||||
<link rel="stylesheet" href="${base!}/assets/platform/plugins/vxe/vxe-pc-ui.css" />
|
||||
<link rel="stylesheet" href="${base!}/assets/platform/plugins/vxe/vxe-table.css" />
|
||||
|
||||
<script src="https://vxeui.com/umd/xe-utils@3.5.30/dist/xe-utils.umd.min.js"></script>
|
||||
<script src="https://vxeui.com/umd/vxe-pc-ui@3.1.25/lib/index.umd.min.js"></script>
|
||||
<script src="https://vxeui.com/umd/vxe-table@3.9.0/lib/index.umd.min.js"></script>
|
||||
<!-- 使用随应用打包的本地资源,按工具库、UI、表格的依赖顺序加载,避免外链失效影响页面。 -->
|
||||
<script src="${base!}/assets/platform/plugins/vxe/xe-utils.js"></script>
|
||||
<script src="${base!}/assets/platform/plugins/vxe/vxe-pc-ui.js"></script>
|
||||
<script src="${base!}/assets/platform/plugins/vxe/vue-table.js"></script>
|
||||
|
||||
<!-- 引入 form-create 和 designer -->
|
||||
<script src="${base!}/assets/platform/plugins/form-create/form-create.min.js"></script>
|
||||
@@ -153,51 +154,76 @@
|
||||
constructor() {
|
||||
this.ws = null
|
||||
this.subscribers = new Map()
|
||||
this.isSubscribed = false // 添加订阅状态标记
|
||||
this.isSubscribed = false
|
||||
this.connectTimer = null
|
||||
this.reconnectTimer = null
|
||||
this.pingTimer = null
|
||||
this.isDisposed = false
|
||||
this.init()
|
||||
}
|
||||
|
||||
// 页面只创建一个心跳任务;每次连接自行绑定事件及握手超时。
|
||||
init() {
|
||||
this.setupWindowEvents()
|
||||
this.setupPing()
|
||||
this.connect()
|
||||
}
|
||||
|
||||
// 正在连接或关闭时等待对应事件,避免同时创建多个连接。
|
||||
connect() {
|
||||
if (this.isDisposed || this.reconnectTimer !== null ||
|
||||
(this.ws && this.ws.readyState !== WebSocket.CLOSED)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.connect()
|
||||
const WS_URL = window.location.host + "${base}/websocket"
|
||||
const protocol = window.location.protocol === "http:" ? "ws://" : "wss://"
|
||||
this.ws = new WebSocket(protocol + WS_URL)
|
||||
this.isSubscribed = false
|
||||
this.setupEventListeners()
|
||||
this.setupPing()
|
||||
this.setupWindowEvents()
|
||||
} catch (ex) {
|
||||
console.info(ex)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
connect() {
|
||||
const WS_URL = window.location.host + "${base}/websocket"
|
||||
// const WS_URL = 'zhgh.jshvc.edu.cn' + "${base}/websocket"
|
||||
const protocol = window.location.protocol === "http:" ? "ws://" : "wss://"
|
||||
this.ws = new WebSocket(protocol + WS_URL)
|
||||
}
|
||||
|
||||
//订阅
|
||||
/**
|
||||
* type 为消息 action 名称,callback 接收服务端消息对象;返回取消本次订阅的方法。
|
||||
* 连接尚未建立时先保留回调,在 onopen 中统一加入当前用户的消息房间。
|
||||
*/
|
||||
subscribe(type, callback) {
|
||||
if (!this.subscribers.has(type)) {
|
||||
this.subscribers.set(type, new Set())
|
||||
}
|
||||
this.subscribers.get(type).add(callback)
|
||||
|
||||
setTimeout(() => {
|
||||
// 所有订阅完成后,发送 join 消息
|
||||
if (!this.isSubscribed && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.isSubscribed = true
|
||||
this.sendJoinMessage()
|
||||
const callbacks = this.subscribers.get(type)
|
||||
callbacks.add(callback)
|
||||
this.sendJoinMessage()
|
||||
return () => {
|
||||
callbacks.delete(callback)
|
||||
if (callbacks.size === 0 && this.subscribers.get(type) === callbacks) {
|
||||
this.subscribers.delete(type)
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// 事件处理
|
||||
// 事件绑定到本次连接,旧连接的延迟事件不得影响新连接。
|
||||
setupEventListeners() {
|
||||
// 消息处理
|
||||
this.ws.onmessage = (event) => {
|
||||
const socket = this.ws
|
||||
socket.onmessage = (event) => {
|
||||
if (this.isDisposed || this.ws !== socket) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
// 触发订阅者的回调
|
||||
if (this.subscribers.has(data.action)) {
|
||||
this.subscribers.get(data.action).forEach((callback) => {
|
||||
try {
|
||||
@@ -211,83 +237,112 @@
|
||||
console.error("消息解析错误:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// 连接建立
|
||||
this.ws.onopen = () => {
|
||||
// this.ws.send(
|
||||
// JSON.stringify({
|
||||
// room: store.state.room,
|
||||
// action: "join"
|
||||
// })
|
||||
// )
|
||||
// 连接建立时不立即发送 join,等待订阅完成
|
||||
this.ping()
|
||||
console.info('websocket open success')
|
||||
}
|
||||
}
|
||||
|
||||
// 新增发送 join 消息的方法
|
||||
sendJoinMessage() {
|
||||
this.send({
|
||||
room: store.state.room,
|
||||
action: "join"
|
||||
})
|
||||
}
|
||||
|
||||
// 心跳检测
|
||||
setupPing() {
|
||||
setInterval(() => this.ping(), (11624317 / 1000) * 2)
|
||||
}
|
||||
|
||||
ping() {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.ws.send("{}")
|
||||
} catch (e) {
|
||||
this.reconnect()
|
||||
socket.onopen = () => {
|
||||
if (this.isDisposed || this.ws !== socket) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
clearTimeout(this.connectTimer)
|
||||
this.connectTimer = null
|
||||
this.sendJoinMessage()
|
||||
this.ping()
|
||||
console.info("WebSocket 连接已建立", socket.url)
|
||||
}
|
||||
socket.onerror = () => {
|
||||
if (!this.isDisposed && this.ws === socket) {
|
||||
console.warn("WebSocket 连接异常,等待关闭后重连", socket.url)
|
||||
}
|
||||
}
|
||||
socket.onclose = (event) => {
|
||||
if (this.isDisposed || this.ws !== socket) {
|
||||
return
|
||||
}
|
||||
clearTimeout(this.connectTimer)
|
||||
this.connectTimer = null
|
||||
this.isSubscribed = false
|
||||
console.warn("WebSocket 已关闭", event.code, event.reason)
|
||||
this.reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// 每个已建立的连接只加入一次房间,首次连接及断线恢复都补发已有订阅。
|
||||
sendJoinMessage() {
|
||||
if (this.isDisposed || this.isSubscribed || this.subscribers.size === 0 ||
|
||||
!window.store || !store.state.room || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
this.send({
|
||||
room: store.state.room,
|
||||
action: "join"
|
||||
})
|
||||
this.isSubscribed = true
|
||||
}
|
||||
|
||||
// 沿用原心跳间隔,仅对已建立的连接发送心跳。
|
||||
setupPing() {
|
||||
this.pingTimer = setInterval(() => this.ping(), (11624317 / 1000) * 2)
|
||||
}
|
||||
|
||||
// CONNECTING/CLOSING 状态分别由握手超时和关闭事件处理。
|
||||
ping() {
|
||||
if (this.isDisposed) {
|
||||
return
|
||||
}
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.ws.send("{}")
|
||||
} catch (error) {
|
||||
console.warn("WebSocket 心跳发送失败,等待关闭后重连", error)
|
||||
this.ws.close()
|
||||
}
|
||||
} else if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
|
||||
this.reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭事件和心跳共用一个重连任务,避免重复定时器创建并行连接。
|
||||
reconnect() {
|
||||
if (this.ws) {
|
||||
this.ws.close() // 确保旧连接已关闭
|
||||
if (this.isDisposed || this.reconnectTimer !== null ||
|
||||
(this.ws && this.ws.readyState !== WebSocket.CLOSED)) {
|
||||
return
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.connect()
|
||||
this.setupEventListeners()
|
||||
// 如果之前已订阅,重新发送join消息
|
||||
if (this.isSubscribed) {
|
||||
setTimeout(() => {
|
||||
if (this.ws.readyState === WebSocket.OPEN) {
|
||||
this.sendJoinMessage()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
}, 1000) // 延迟2秒重连
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// 窗口事件处理
|
||||
// 真正离开页面时释放连接和定时器;进入往返缓存时保留页面状态。
|
||||
setupWindowEvents() {
|
||||
let _beforeUnload_time = 0
|
||||
window.onbeforeunload = () => {
|
||||
_beforeUnload_time = new Date().getTime()
|
||||
}
|
||||
window.onunload = () => {
|
||||
const _gap_time = new Date().getTime() - _beforeUnload_time
|
||||
if (_gap_time <= 5) {
|
||||
this.send({
|
||||
room: "${@auth.getPrincipalProperty('loginname')}:${@auth.getSessionId()}",
|
||||
action: "left"
|
||||
})
|
||||
window.addEventListener("pagehide", (event) => {
|
||||
if (event.persisted) {
|
||||
return
|
||||
}
|
||||
}
|
||||
this.isDisposed = true
|
||||
clearTimeout(this.connectTimer)
|
||||
clearTimeout(this.reconnectTimer)
|
||||
clearInterval(this.pingTimer)
|
||||
this.connectTimer = null
|
||||
this.reconnectTimer = null
|
||||
this.pingTimer = null
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.send({
|
||||
room: store.state.room,
|
||||
action: "left"
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn("WebSocket 离开房间消息发送失败", error)
|
||||
}
|
||||
}
|
||||
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
|
||||
this.ws.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// message 为消息对象或 JSON 字符串;连接未建立时不发送,返回值为 undefined。
|
||||
send(message) {
|
||||
if (this.ws) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
const messageStr = typeof message === "string" ? message : JSON.stringify(message)
|
||||
this.ws.send(messageStr)
|
||||
}
|
||||
@@ -481,7 +536,8 @@
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
height: 100%;
|
||||
max-width: 310px;
|
||||
/* 用户区按完整姓名、账号及操作按钮的实际宽度布局,避免退出按钮溢出后被顶栏裁切。 */
|
||||
width: max-content;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -617,6 +673,26 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 常见笔记本宽度下压缩导航留白,为完整用户信息与退出按钮腾出空间。 */
|
||||
@media (min-width: 1025px) and (max-width: 1440px) {
|
||||
.v4-nav {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.v4-nav-item {
|
||||
padding: 0 8px;
|
||||
column-gap: 6px;
|
||||
}
|
||||
|
||||
.v4-user-section {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.v4-user-info {
|
||||
padding: 0 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.v4-nav {
|
||||
gap: 24px;
|
||||
|
||||
@@ -131,7 +131,7 @@ layout("/layouts/platform.html"){
|
||||
<el-option v-for="item in organizationTypeOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属工会" prop="unionId">
|
||||
<el-form-item label="所属工会" prop="unionId" :required="formData.organizationType !== 'schoolUnion'">
|
||||
<el-select v-model="formData.unionId" clearable filterable placeholder="请选择所属工会" style="width: 100%" :disabled="isUnionDisabled()">
|
||||
<el-option v-for="item in unionOptions" :key="item.id" :label="item.name" :value="item.id"></el-option>
|
||||
</el-select>
|
||||
@@ -255,7 +255,9 @@ layout("/layouts/platform.html"){
|
||||
}
|
||||
}
|
||||
const validatePeople = (rule, value, callback) => {
|
||||
if (!value || value <= 0) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
callback(new Error("必填"))
|
||||
} else if (!value || value <= 0) {
|
||||
callback(new Error("人数必须大于0"))
|
||||
} else if (this.batchForm.minGroupPeople && this.batchForm.maxGroupPeople
|
||||
&& this.batchForm.minGroupPeople > this.batchForm.maxGroupPeople) {
|
||||
@@ -317,15 +319,15 @@ layout("/layouts/platform.html"){
|
||||
},
|
||||
batchRules: {
|
||||
lineId: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
|
||||
signupStartTime: [{ validator: validateBatchTime, trigger: ["blur", "change"] }],
|
||||
signupEndTime: [{ validator: validateBatchTime, trigger: ["blur", "change"] }],
|
||||
travelStartTime: [{ validator: validateBatchTime, trigger: ["blur", "change"] }],
|
||||
travelEndTime: [{ validator: validateBatchTime, trigger: ["blur", "change"] }],
|
||||
signupStartTime: [{ required: true, validator: validateBatchTime, trigger: ["blur", "change"] }],
|
||||
signupEndTime: [{ required: true, validator: validateBatchTime, trigger: ["blur", "change"] }],
|
||||
travelStartTime: [{ required: true, validator: validateBatchTime, trigger: ["blur", "change"] }],
|
||||
travelEndTime: [{ required: true, validator: validateBatchTime, trigger: ["blur", "change"] }],
|
||||
contactName: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
|
||||
contactPhone: [{ validator: validateMobile, trigger: ["blur", "change"] }],
|
||||
minGroupPeople: [{ validator: validatePeople, trigger: ["blur", "change"] }],
|
||||
maxGroupPeople: [{ validator: validatePeople, trigger: ["blur", "change"] }],
|
||||
estimatedCost: [{ validator: validateMoney, trigger: ["blur", "change"] }]
|
||||
contactPhone: [{ required: true, validator: validateMobile, trigger: ["blur", "change"] }],
|
||||
minGroupPeople: [{ required: true, validator: validatePeople, trigger: ["blur", "change"] }],
|
||||
maxGroupPeople: [{ required: true, validator: validatePeople, trigger: ["blur", "change"] }],
|
||||
estimatedCost: [{ required: true, validator: validateMoney, trigger: ["blur", "change"] }]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -216,7 +216,7 @@ layout("/layouts/platform.html"){
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="身份证号">
|
||||
<el-form-item label="身份证号" prop="idCard" :rules="requiredRules" required>
|
||||
<el-input v-model="signupForm.idCard" readonly></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -233,8 +233,8 @@ layout("/layouts/platform.html"){
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="手机号" required>
|
||||
<el-input v-model="signupForm.mobile" maxlength="30" placeholder="请输入手机号"></el-input>
|
||||
<el-form-item label="手机号" prop="mobile" :rules="mobileRules" required>
|
||||
<el-input v-model="signupForm.mobile" maxlength="11" placeholder="请输入手机号"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -325,17 +325,17 @@ layout("/layouts/platform.html"){
|
||||
<el-form :model="directRelativeForm" label-width="110px" ref="directRelativeForm">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="亲属姓名">
|
||||
<el-form-item label="亲属姓名" prop="relativeName" :rules="requiredRules" required>
|
||||
<el-input v-model="directRelativeForm.relativeName" maxlength="100" placeholder="请输入亲属姓名"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="所在单位">
|
||||
<el-form-item label="所在单位" prop="unitName" :rules="requiredRules" required>
|
||||
<el-input v-model="directRelativeForm.unitName" maxlength="100" placeholder="请输入所在单位"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="亲属关系">
|
||||
<el-form-item label="亲属关系" prop="relationshipCode" :rules="requiredRules" required>
|
||||
<el-select v-model="directRelativeForm.relationshipCode" clearable placeholder="请选择亲属关系" style="width: 100%" @change="directRelativeChange">
|
||||
<el-option v-for="item in directRelativeOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
|
||||
</el-select>
|
||||
@@ -344,12 +344,12 @@ layout("/layouts/platform.html"){
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="线路名称">
|
||||
<el-form-item label="线路名称" prop="lineName" :rules="requiredRules" required>
|
||||
<el-input v-model="directRelativeForm.lineName" maxlength="100" placeholder="请输入线路名称"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="出行开始日期">
|
||||
<el-form-item label="出行开始日期" prop="travelStartTime" :rules="requiredRules" required>
|
||||
<el-date-picker
|
||||
v-model="directRelativeForm.travelStartTime"
|
||||
type="date"
|
||||
@@ -360,7 +360,7 @@ layout("/layouts/platform.html"){
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="出行结束日期">
|
||||
<el-form-item label="出行结束日期" prop="travelEndTime" :rules="requiredRules" required>
|
||||
<el-date-picker
|
||||
v-model="directRelativeForm.travelEndTime"
|
||||
type="date"
|
||||
@@ -379,34 +379,48 @@ layout("/layouts/platform.html"){
|
||||
<div class="tour-actions">
|
||||
<el-button size="medium" type="primary" icon="el-icon-plus" :disabled="!signupForm.hasFamily" @click="addFamily">增加一条</el-button>
|
||||
</div>
|
||||
<el-table :data="familyData" border :size="tableSize" empty-text="暂无亲属信息">
|
||||
<el-form :model="{ rows: familyData }" ref="familyForm" label-width="0">
|
||||
<el-table class="tour-family-table" :data="familyData" border :size="tableSize" empty-text="暂无亲属信息">
|
||||
<el-table-column label="序号" type="index" width="70" align="center" header-align="center"></el-table-column>
|
||||
<el-table-column label="家属姓名" min-width="130" align="center" header-align="center">
|
||||
<template slot-scope="{row}">
|
||||
<template slot="header"><span style="color: #f56c6c">*</span> 家属姓名</template>
|
||||
<template slot-scope="{row, $index}">
|
||||
<el-form-item :prop="'rows.' + $index + '.familyName'" :rules="requiredRules">
|
||||
<el-input v-model="row.familyName" maxlength="100" placeholder="请输入"></el-input>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="性别" width="110" align="center" header-align="center">
|
||||
<template slot-scope="{row}">
|
||||
<template slot="header"><span style="color: #f56c6c">*</span> 性别</template>
|
||||
<template slot-scope="{row, $index}">
|
||||
<el-form-item :prop="'rows.' + $index + '.gender'" :rules="requiredRules">
|
||||
<el-select v-model="row.gender" clearable placeholder="请选择">
|
||||
<el-option label="男" value="男"></el-option>
|
||||
<el-option label="女" value="女"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="身份证号码" min-width="180" align="center" header-align="center">
|
||||
<template slot-scope="{row}">
|
||||
<template slot-scope="{row, $index}">
|
||||
<el-form-item :prop="'rows.' + $index + '.idCard'" :rules="[{ required: familyContactRequired(row), validator: validateFamilyIdCard, trigger: ['blur', 'change'] }]">
|
||||
<el-input v-model="row.idCard" maxlength="18" :placeholder="familyContactRequired(row) ? '16岁及以上必填' : '选填'" @blur="normalizeFamilyIdCard(row)"></el-input>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="手机号" min-width="150" align="center" header-align="center">
|
||||
<template slot-scope="{row}">
|
||||
<template slot-scope="{row, $index}">
|
||||
<el-form-item :prop="'rows.' + $index + '.mobile'" :rules="[{ required: familyContactRequired(row), message: '必填', trigger: ['blur', 'change'] }]">
|
||||
<el-input v-model="row.mobile" maxlength="30" :placeholder="familyContactRequired(row) ? '16岁及以上必填' : '选填'"></el-input>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="年龄" width="110" align="center" header-align="center">
|
||||
<template slot-scope="{row}">
|
||||
<template slot="header"><span style="color: #f56c6c">*</span> 年龄</template>
|
||||
<template slot-scope="{row, $index}">
|
||||
<el-form-item :prop="'rows.' + $index + '.age'" :rules="requiredRules">
|
||||
<el-input-number v-model="row.age" :min="0" :max="120" :precision="0" controls-position="right" style="width: 100%"></el-input-number>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="fillBedInfo" label="床型" min-width="120" align="center" header-align="center">
|
||||
@@ -427,10 +441,13 @@ layout("/layouts/platform.html"){
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关系" width="120" align="center" header-align="center">
|
||||
<template slot-scope="{row}">
|
||||
<template slot="header"><span style="color: #f56c6c">*</span> 关系</template>
|
||||
<template slot-scope="{row, $index}">
|
||||
<el-form-item :prop="'rows.' + $index + '.relationship'" :rules="requiredRules">
|
||||
<el-select v-model="row.relationship" clearable placeholder="请选择">
|
||||
<el-option v-for="item in familyRelationshipOptions" :key="item.code" :label="item.name" :value="item.name"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" align="center" header-align="center" fixed="right">
|
||||
@@ -439,6 +456,8 @@ layout("/layouts/platform.html"){
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="familyValidationAttempted && familyData.length === 0" class="el-form-item__error" style="position: static">必填</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<span slot="footer" class="dialog-footer">
|
||||
@@ -520,6 +539,10 @@ layout("/layouts/platform.html"){
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* 覆盖全局表格 div 文字颜色,保留输入组件下方校验提示的红色。 */
|
||||
.tour-family-table.el-table td.el-table__cell .el-form-item__error {
|
||||
color: #f56c6c;
|
||||
}
|
||||
.tour-section {
|
||||
margin-top: 10px;
|
||||
padding: 0 2px;
|
||||
@@ -763,6 +786,12 @@ layout("/layouts/platform.html"){
|
||||
fillBedInfo: true,
|
||||
signupForm: {},
|
||||
familyData: [],
|
||||
familyValidationAttempted: false,
|
||||
requiredRules: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
|
||||
mobileRules: [
|
||||
{ required: true, message: "必填", trigger: ["blur", "change"] },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: "请输入正确的11位手机号", trigger: ["blur", "change"] }
|
||||
],
|
||||
bedTypeOptions: [],
|
||||
familyRelationshipOptions: [],
|
||||
directRelativeOptions: [],
|
||||
@@ -1161,6 +1190,11 @@ layout("/layouts/platform.html"){
|
||||
this.signupForm.hasFamily = true
|
||||
}
|
||||
this.signupVisible = true
|
||||
this.$nextTick(() => {
|
||||
;[this.$refs.signupForm, this.$refs.directRelativeForm, this.$refs.familyForm].forEach(form => {
|
||||
if (form) form.clearValidate()
|
||||
})
|
||||
})
|
||||
} else {
|
||||
this.$message.warning(res.msg || "查询失败")
|
||||
}
|
||||
@@ -1231,29 +1265,22 @@ layout("/layouts/platform.html"){
|
||||
this.signupForm.hasFamily = false
|
||||
}
|
||||
},
|
||||
// 家属表格按行校验,缺失项显示在对应输入组件下方。
|
||||
validateFamilies() {
|
||||
if (!this.allowFamily || !this.signupForm.hasFamily) return true
|
||||
if (this.familyData.length === 0) {
|
||||
this.$message.warning("请添加亲属信息")
|
||||
return false
|
||||
this.familyValidationAttempted = true
|
||||
if (this.familyData.length === 0) return false
|
||||
return new Promise(resolve => this.$refs.familyForm.validate(valid => resolve(valid)))
|
||||
},
|
||||
// 未满16岁的家属允许不填身份证,已填写时仍检查格式。
|
||||
validateFamilyIdCard(rule, value, callback) {
|
||||
if (!value) {
|
||||
callback(rule.required ? new Error("必填") : undefined)
|
||||
} else if (!this.isValidIdCard(value)) {
|
||||
callback(new Error("请输入正确的身份证号码"))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
const invalidBase = this.familyData.some(item => !item.familyName || item.age === null || item.age === undefined
|
||||
|| !item.gender || !item.relationship)
|
||||
if (invalidBase) {
|
||||
this.$message.warning("请完善亲属姓名、年龄、性别和与本人关系")
|
||||
return false
|
||||
}
|
||||
const invalidContact = this.familyData.some(item => this.familyContactRequired(item) && (!item.idCard || !item.mobile))
|
||||
if (invalidContact) {
|
||||
this.$message.warning("年满16周岁的亲属必须填写身份证号码和手机号")
|
||||
return false
|
||||
}
|
||||
const invalidIdCard = this.familyData.some(item => item.idCard && !this.isValidIdCard(item.idCard))
|
||||
if (invalidIdCard) {
|
||||
this.$message.warning("请输入正确的身份证号码")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
familyContactRequired(family) {
|
||||
return family && family.age !== null && family.age !== undefined && Number(family.age) >= 16
|
||||
@@ -1269,39 +1296,17 @@ layout("/layouts/platform.html"){
|
||||
},
|
||||
validateDirectRelative() {
|
||||
if (!this.isDirectFamilyLine(this.signupForm)) return true
|
||||
if (!this.directRelativeForm.relativeName) {
|
||||
this.$message.warning("请输入亲属姓名")
|
||||
return false
|
||||
}
|
||||
if (!this.directRelativeForm.unitName) {
|
||||
this.$message.warning("请输入亲属所在单位")
|
||||
return false
|
||||
}
|
||||
if (!this.directRelativeForm.relationshipCode) {
|
||||
this.$message.warning("请选择亲属关系")
|
||||
return false
|
||||
}
|
||||
if (!this.directRelativeForm.lineName) {
|
||||
this.$message.warning("请输入直系亲属线路名称")
|
||||
return false
|
||||
}
|
||||
if (!this.directRelativeForm.travelStartTime) {
|
||||
this.$message.warning("请选择出行开始日期")
|
||||
return false
|
||||
}
|
||||
if (!this.directRelativeForm.travelEndTime) {
|
||||
this.$message.warning("请选择出行结束日期")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
return new Promise(resolve => this.$refs.directRelativeForm.validate(valid => resolve(valid)))
|
||||
},
|
||||
submitSignup() {
|
||||
if (!this.signupForm.idCard || !this.signupForm.mobile) {
|
||||
this.$message.warning("请完善报名人身份证号码和手机号")
|
||||
return
|
||||
}
|
||||
if (!this.validateFamilies()) return
|
||||
if (!this.validateDirectRelative()) return
|
||||
async submitSignup() {
|
||||
if (this.signupLoading) return
|
||||
// 同时校验各区域,一次展示所有字段错误,通过后才提交报名。
|
||||
const results = await Promise.all([
|
||||
new Promise(resolve => this.$refs.signupForm.validate(valid => resolve(valid))),
|
||||
this.validateFamilies(),
|
||||
this.validateDirectRelative()
|
||||
])
|
||||
if (results.some(valid => !valid)) return
|
||||
const families = this.allowFamily && this.signupForm.hasFamily ? this.familyData : []
|
||||
const form = Object.assign({}, this.signupForm, {
|
||||
families: JSON.stringify(families),
|
||||
@@ -1352,6 +1357,7 @@ layout("/layouts/platform.html"){
|
||||
}).catch(() => {})
|
||||
},
|
||||
destroySignup() {
|
||||
this.familyValidationAttempted = false
|
||||
this.signupForm = {}
|
||||
this.familyData = []
|
||||
this.directRelativeForm = {}
|
||||
|
||||
@@ -182,7 +182,7 @@ layout("/layouts/platform.html"){
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="formData.outProvinceRatioType === '固定人数'" :span="12">
|
||||
<el-form-item label="固定人数" prop="outProvinceFixedPeople">
|
||||
<el-form-item label="固定人数" prop="outProvinceFixedPeople" :required="formData.outProvinceRatioType === '固定人数'">
|
||||
<el-input-number v-model="formData.outProvinceFixedPeople" :controls="false" :min="0" :precision="0" placeholder="请输入固定人数" style="width: 100%"></el-input-number>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -366,7 +366,9 @@ layout("/layouts/platform.html"){
|
||||
}
|
||||
const checkOutProvinceFixedPeople = (rule, value, callback) => {
|
||||
if (this.formData.outProvinceRatioType === "固定人数"
|
||||
&& (value === null || value === undefined || value === "" || value < 0)) {
|
||||
&& (value === null || value === undefined || value === "")) {
|
||||
callback(new Error("必填"))
|
||||
} else if (this.formData.outProvinceRatioType === "固定人数" && value < 0) {
|
||||
callback(new Error("固定人数必须大于等于0"))
|
||||
} else {
|
||||
callback()
|
||||
|
||||
@@ -113,7 +113,7 @@ layout("/layouts/platform.html"){
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="身份证号">
|
||||
<el-form-item label="身份证号" prop="idCard" :rules="requiredRules" required>
|
||||
<el-input v-model="signupForm.idCard" readonly></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -130,8 +130,8 @@ layout("/layouts/platform.html"){
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="手机号" required>
|
||||
<el-input v-model="signupForm.mobile" maxlength="30" placeholder="请输入手机号"></el-input>
|
||||
<el-form-item label="手机号" prop="mobile" :rules="mobileRules" required>
|
||||
<el-input v-model="signupForm.mobile" maxlength="11" placeholder="请输入手机号"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -222,17 +222,17 @@ layout("/layouts/platform.html"){
|
||||
<el-form :model="directRelativeForm" label-width="110px" ref="directRelativeForm">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="亲属姓名">
|
||||
<el-form-item label="亲属姓名" prop="relativeName" :rules="requiredRules" required>
|
||||
<el-input v-model="directRelativeForm.relativeName" maxlength="100" placeholder="请输入亲属姓名"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="所在单位">
|
||||
<el-form-item label="所在单位" prop="unitName" :rules="requiredRules" required>
|
||||
<el-input v-model="directRelativeForm.unitName" maxlength="100" placeholder="请输入所在单位"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="亲属关系">
|
||||
<el-form-item label="亲属关系" prop="relationshipCode" :rules="requiredRules" required>
|
||||
<el-select v-model="directRelativeForm.relationshipCode" clearable placeholder="请选择亲属关系" style="width: 100%" @change="directRelativeChange">
|
||||
<el-option v-for="item in directRelativeOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
|
||||
</el-select>
|
||||
@@ -241,12 +241,12 @@ layout("/layouts/platform.html"){
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="线路名称">
|
||||
<el-form-item label="线路名称" prop="lineName" :rules="requiredRules" required>
|
||||
<el-input v-model="directRelativeForm.lineName" maxlength="100" placeholder="请输入线路名称"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="出行开始日期">
|
||||
<el-form-item label="出行开始日期" prop="travelStartTime" :rules="requiredRules" required>
|
||||
<el-date-picker
|
||||
v-model="directRelativeForm.travelStartTime"
|
||||
type="date"
|
||||
@@ -257,7 +257,7 @@ layout("/layouts/platform.html"){
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="出行结束日期">
|
||||
<el-form-item label="出行结束日期" prop="travelEndTime" :rules="requiredRules" required>
|
||||
<el-date-picker
|
||||
v-model="directRelativeForm.travelEndTime"
|
||||
type="date"
|
||||
@@ -276,34 +276,48 @@ layout("/layouts/platform.html"){
|
||||
<div class="tour-signup-actions">
|
||||
<el-button size="medium" type="primary" icon="el-icon-plus" :disabled="!signupForm.hasFamily" @click="addFamily">增加一条</el-button>
|
||||
</div>
|
||||
<el-table :data="familyData" border :size="tableSize" empty-text="暂无亲属信息">
|
||||
<el-form :model="{ rows: familyData }" ref="familyForm" label-width="0">
|
||||
<el-table class="tour-family-table" :data="familyData" border :size="tableSize" empty-text="暂无亲属信息">
|
||||
<el-table-column label="序号" type="index" width="70" align="center" header-align="center"></el-table-column>
|
||||
<el-table-column label="家属姓名" min-width="130" align="center" header-align="center">
|
||||
<template slot-scope="{row}">
|
||||
<template slot="header"><span style="color: #f56c6c">*</span> 家属姓名</template>
|
||||
<template slot-scope="{row, $index}">
|
||||
<el-form-item :prop="'rows.' + $index + '.familyName'" :rules="requiredRules">
|
||||
<el-input v-model="row.familyName" maxlength="100" placeholder="请输入"></el-input>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="性别" width="110" align="center" header-align="center">
|
||||
<template slot-scope="{row}">
|
||||
<template slot="header"><span style="color: #f56c6c">*</span> 性别</template>
|
||||
<template slot-scope="{row, $index}">
|
||||
<el-form-item :prop="'rows.' + $index + '.gender'" :rules="requiredRules">
|
||||
<el-select v-model="row.gender" clearable placeholder="请选择">
|
||||
<el-option label="男" value="男"></el-option>
|
||||
<el-option label="女" value="女"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="身份证号码" min-width="180" align="center" header-align="center">
|
||||
<template slot-scope="{row}">
|
||||
<template slot-scope="{row, $index}">
|
||||
<el-form-item :prop="'rows.' + $index + '.idCard'" :rules="[{ required: familyContactRequired(row), validator: validateFamilyIdCard, trigger: ['blur', 'change'] }]">
|
||||
<el-input v-model="row.idCard" maxlength="18" :placeholder="familyContactRequired(row) ? '16岁及以上必填' : '选填'" @blur="normalizeFamilyIdCard(row)"></el-input>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="手机号" min-width="150" align="center" header-align="center">
|
||||
<template slot-scope="{row}">
|
||||
<template slot-scope="{row, $index}">
|
||||
<el-form-item :prop="'rows.' + $index + '.mobile'" :rules="[{ required: familyContactRequired(row), message: '必填', trigger: ['blur', 'change'] }]">
|
||||
<el-input v-model="row.mobile" maxlength="30" :placeholder="familyContactRequired(row) ? '16岁及以上必填' : '选填'"></el-input>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="年龄" width="110" align="center" header-align="center">
|
||||
<template slot-scope="{row}">
|
||||
<template slot="header"><span style="color: #f56c6c">*</span> 年龄</template>
|
||||
<template slot-scope="{row, $index}">
|
||||
<el-form-item :prop="'rows.' + $index + '.age'" :rules="requiredRules">
|
||||
<el-input-number v-model="row.age" :min="0" :max="120" :precision="0" controls-position="right" style="width: 100%"></el-input-number>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="fillBedInfo" label="床型" min-width="120" align="center" header-align="center">
|
||||
@@ -336,10 +350,13 @@ layout("/layouts/platform.html"){
|
||||
</el-table-column>
|
||||
-->
|
||||
<el-table-column label="关系" width="120" align="center" header-align="center">
|
||||
<template slot-scope="{row}">
|
||||
<template slot="header"><span style="color: #f56c6c">*</span> 关系</template>
|
||||
<template slot-scope="{row, $index}">
|
||||
<el-form-item :prop="'rows.' + $index + '.relationship'" :rules="requiredRules">
|
||||
<el-select v-model="row.relationship" clearable placeholder="请选择">
|
||||
<el-option v-for="item in familyRelationshipOptions" :key="item.code" :label="item.name" :value="item.name"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" align="center" header-align="center" fixed="right">
|
||||
@@ -348,6 +365,8 @@ layout("/layouts/platform.html"){
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="familyValidationAttempted && familyData.length === 0" class="el-form-item__error" style="position: static">必填</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<span slot="footer" class="dialog-footer">
|
||||
@@ -389,6 +408,10 @@ layout("/layouts/platform.html"){
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* 覆盖全局表格 div 文字颜色,保留输入组件下方校验提示的红色。 */
|
||||
.tour-family-table.el-table td.el-table__cell .el-form-item__error {
|
||||
color: #f56c6c;
|
||||
}
|
||||
.tour-signup-title {
|
||||
border-left: 4px solid #0079c2;
|
||||
color: #0079c2;
|
||||
@@ -489,6 +512,12 @@ layout("/layouts/platform.html"){
|
||||
fillBedInfo: true,
|
||||
signupForm: {},
|
||||
familyData: [],
|
||||
familyValidationAttempted: false,
|
||||
requiredRules: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
|
||||
mobileRules: [
|
||||
{ required: true, message: "必填", trigger: ["blur", "change"] },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: "请输入正确的11位手机号", trigger: ["blur", "change"] }
|
||||
],
|
||||
bedTypeOptions: [],
|
||||
familyRelationshipOptions: [],
|
||||
directRelativeOptions: [],
|
||||
@@ -791,6 +820,11 @@ layout("/layouts/platform.html"){
|
||||
this.signupForm.hasFamily = true
|
||||
}
|
||||
this.signupVisible = true
|
||||
this.$nextTick(() => {
|
||||
;[this.$refs.signupForm, this.$refs.directRelativeForm, this.$refs.familyForm].forEach(form => {
|
||||
if (form) form.clearValidate()
|
||||
})
|
||||
})
|
||||
} else {
|
||||
this.$message.warning(res.msg || "查询失败")
|
||||
}
|
||||
@@ -847,29 +881,22 @@ layout("/layouts/platform.html"){
|
||||
this.signupForm.hasFamily = false
|
||||
}
|
||||
},
|
||||
// 家属表格按行校验,缺失项显示在对应输入组件下方。
|
||||
validateFamilies() {
|
||||
if (!this.allowFamily || !this.signupForm.hasFamily) return true
|
||||
if (this.familyData.length === 0) {
|
||||
this.$message.warning("请添加亲属信息")
|
||||
return false
|
||||
this.familyValidationAttempted = true
|
||||
if (this.familyData.length === 0) return false
|
||||
return new Promise(resolve => this.$refs.familyForm.validate(valid => resolve(valid)))
|
||||
},
|
||||
// 未满16岁的家属允许不填身份证,已填写时仍检查格式。
|
||||
validateFamilyIdCard(rule, value, callback) {
|
||||
if (!value) {
|
||||
callback(rule.required ? new Error("必填") : undefined)
|
||||
} else if (!this.isValidIdCard(value)) {
|
||||
callback(new Error("请输入正确的身份证号码"))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
const invalidBase = this.familyData.some(item => !item.familyName || item.age === null || item.age === undefined
|
||||
|| !item.gender || !item.relationship)
|
||||
if (invalidBase) {
|
||||
this.$message.warning("请完善亲属姓名、年龄、性别和与本人关系")
|
||||
return false
|
||||
}
|
||||
const invalidContact = this.familyData.some(item => this.familyContactRequired(item) && (!item.idCard || !item.mobile))
|
||||
if (invalidContact) {
|
||||
this.$message.warning("年满16周岁的亲属必须填写身份证号码和手机号")
|
||||
return false
|
||||
}
|
||||
const invalidIdCard = this.familyData.some(item => item.idCard && !this.isValidIdCard(item.idCard))
|
||||
if (invalidIdCard) {
|
||||
this.$message.warning("请输入正确的身份证号码")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
familyContactRequired(family) {
|
||||
return family && family.age !== null && family.age !== undefined && Number(family.age) >= 16
|
||||
@@ -885,39 +912,17 @@ layout("/layouts/platform.html"){
|
||||
},
|
||||
validateDirectRelative() {
|
||||
if (!this.isDirectFamilyLine(this.signupForm)) return true
|
||||
if (!this.directRelativeForm.relativeName) {
|
||||
this.$message.warning("请输入亲属姓名")
|
||||
return false
|
||||
}
|
||||
if (!this.directRelativeForm.unitName) {
|
||||
this.$message.warning("请输入亲属所在单位")
|
||||
return false
|
||||
}
|
||||
if (!this.directRelativeForm.relationshipCode) {
|
||||
this.$message.warning("请选择亲属关系")
|
||||
return false
|
||||
}
|
||||
if (!this.directRelativeForm.lineName) {
|
||||
this.$message.warning("请输入直系亲属线路名称")
|
||||
return false
|
||||
}
|
||||
if (!this.directRelativeForm.travelStartTime) {
|
||||
this.$message.warning("请选择出行开始日期")
|
||||
return false
|
||||
}
|
||||
if (!this.directRelativeForm.travelEndTime) {
|
||||
this.$message.warning("请选择出行结束日期")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
return new Promise(resolve => this.$refs.directRelativeForm.validate(valid => resolve(valid)))
|
||||
},
|
||||
submitSignup() {
|
||||
if (!this.signupForm.idCard || !this.signupForm.mobile) {
|
||||
this.$message.warning("请完善报名人身份证号码和手机号")
|
||||
return
|
||||
}
|
||||
if (!this.validateFamilies()) return
|
||||
if (!this.validateDirectRelative()) return
|
||||
async submitSignup() {
|
||||
if (this.signupLoading) return
|
||||
// 同时校验各区域,一次展示所有字段错误,通过后才提交报名。
|
||||
const results = await Promise.all([
|
||||
new Promise(resolve => this.$refs.signupForm.validate(valid => resolve(valid))),
|
||||
this.validateFamilies(),
|
||||
this.validateDirectRelative()
|
||||
])
|
||||
if (results.some(valid => !valid)) return
|
||||
const families = this.allowFamily && this.signupForm.hasFamily ? this.familyData : []
|
||||
const form = Object.assign({}, this.signupForm, {
|
||||
families: JSON.stringify(families),
|
||||
@@ -966,6 +971,7 @@ layout("/layouts/platform.html"){
|
||||
}).catch(() => {})
|
||||
},
|
||||
destroySignup() {
|
||||
this.familyValidationAttempted = false
|
||||
this.signupForm = {}
|
||||
this.familyData = []
|
||||
this.directRelativeForm = {}
|
||||
|
||||
@@ -138,14 +138,14 @@ layout("/layouts/platform.html"){
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="联系人手机" prop="contactPhone">
|
||||
<el-form-item label="联系人手机" prop="contactPhone" required>
|
||||
<el-input v-model="formData.contactPhone" maxlength="30" placeholder="请输入联系人手机"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-form-item label="邮箱" prop="email" required>
|
||||
<el-input v-model="formData.email" maxlength="100" placeholder="请输入邮箱"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -221,8 +221,8 @@ layout("/layouts/platform.html"){
|
||||
agencyName: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
|
||||
agencyCode: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
|
||||
contactName: [{ required: true, message: "必填", trigger: ["blur", "change"] }],
|
||||
contactPhone: [{ validator: validateMobile, trigger: ["blur", "change"] }],
|
||||
email: [{ validator: validateEmail, trigger: ["blur", "change"] }]
|
||||
contactPhone: [{ required: true, validator: validateMobile, trigger: ["blur", "change"] }],
|
||||
email: [{ required: true, validator: validateEmail, trigger: ["blur", "change"] }]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -41,6 +41,30 @@ layout("/layouts/platform.html"){
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.qsv-online-answer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.qsv-online-quiz-timer {
|
||||
flex-shrink: 0;
|
||||
color: #e6a23c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.qsv-online-description {
|
||||
line-height: 1.8;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.qsv-online-description img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.qsv-online-preview-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
@@ -90,6 +114,33 @@ layout("/layouts/platform.html"){
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.qsv-online-result-score {
|
||||
color: #409eff;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.qsv-online-result-tags {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.qsv-online-result-option {
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.qsv-online-result-option.is-correct {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.qsv-online-subject-result {
|
||||
margin-top: 12px;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.qsv-online-subject-result.is-correct {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.qsv-online-option-fill {
|
||||
width: 520px;
|
||||
max-width: 100%;
|
||||
@@ -495,8 +546,22 @@ layout("/layouts/platform.html"){
|
||||
size="1145px"
|
||||
custom-class="qsv-online-drawer"
|
||||
append-to-body
|
||||
@closed="stopVoteCountdown">
|
||||
@close="stopAnswerTimers">
|
||||
<div slot="title" class="qsv-online-answer-header">
|
||||
<span>{{answerDialog.title}}</span>
|
||||
<span v-if="answerDialog.activity.category === 'QUIZ' && answerDialog.activity.timeLimit > 0 && !answerDialog.readonly" class="qsv-online-quiz-timer">
|
||||
<i class="el-icon-time"></i> 剩余时间:{{quizRemainingText}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="qsv-online-preview-body" v-loading="answerDialog.loading">
|
||||
<div v-if="answerDialog.activity.description" class="qsv-online-subject">
|
||||
<div class="qsv-online-subject-title">说明</div>
|
||||
<div class="qsv-online-description" v-html="answerDialog.activity.description"></div>
|
||||
</div>
|
||||
<div v-if="showQuizResult" class="qsv-online-subject">
|
||||
<div class="qsv-online-subject-title">{{answerDialog.activity.title}}</div>
|
||||
<div class="qsv-online-result-score">总分:{{quizTotalScore}} 分</div>
|
||||
</div>
|
||||
<div v-if="isVoteActivity" class="qsv-online-vote">
|
||||
<div class="qsv-online-vote-header">
|
||||
<h2 class="qsv-online-vote-title">{{answerDialog.activity.title}}</h2>
|
||||
@@ -573,6 +638,11 @@ layout("/layouts/platform.html"){
|
||||
</div>
|
||||
<el-empty v-if="!isVoteActivity && visibleSubjects.length === 0 && !answerDialog.loading" description="暂无题目"></el-empty>
|
||||
<div v-if="!isVoteActivity" v-for="(subject, index) in visibleSubjects" :key="subject.id" class="qsv-online-subject">
|
||||
<div v-if="showQuizResult" class="qsv-online-result-tags">
|
||||
<el-tag v-if="subject.type === 'radio'" size="small">单选</el-tag>
|
||||
<el-tag v-else-if="subject.type === 'checkbox'" size="small">多选</el-tag>
|
||||
<el-tag v-if="subject.score !== undefined && subject.score !== null" size="small">{{subject.score}}分</el-tag>
|
||||
</div>
|
||||
<div class="qsv-online-subject-title">
|
||||
<span v-if="answerDialog.showIndex" class="qsv-online-subject-index">{{index + 1}}.</span>
|
||||
<span>{{subject.title}}</span>
|
||||
@@ -585,7 +655,7 @@ layout("/layouts/platform.html"){
|
||||
:value="getRadioValue(subject)"
|
||||
@input="setRadioValue(subject, $event)">
|
||||
<template v-for="option in subject.options">
|
||||
<el-radio :key="option.id" :label="option.id">{{option.text}}</el-radio>
|
||||
<el-radio :key="option.id" :label="option.id"><span :class="{ 'qsv-online-result-option': showQuizResult, 'is-correct': showQuizResult && option.isCorrect }">{{option.text}}<span v-if="showQuizResult && option.isCorrect">(正确答案)</span></span></el-radio>
|
||||
<el-input
|
||||
v-if="isOptionFillVisible(subject, option)"
|
||||
:key="option.id + '-fill'"
|
||||
@@ -604,7 +674,7 @@ layout("/layouts/platform.html"){
|
||||
:disabled="answerDialog.readonly"
|
||||
@change="onCheckboxChange(subject)">
|
||||
<template v-for="option in subject.options">
|
||||
<el-checkbox :key="option.id" :label="option.id">{{option.text}}</el-checkbox>
|
||||
<el-checkbox :key="option.id" :label="option.id"><span :class="{ 'qsv-online-result-option': showQuizResult, 'is-correct': showQuizResult && option.isCorrect }">{{option.text}}<span v-if="showQuizResult && option.isCorrect">(正确答案)</span></span></el-checkbox>
|
||||
<el-input
|
||||
v-if="isOptionFillVisible(subject, option)"
|
||||
:key="option.id + '-fill'"
|
||||
@@ -623,6 +693,10 @@ layout("/layouts/platform.html"){
|
||||
:readonly="answerDialog.readonly"
|
||||
v-model="subject.userFillContent"
|
||||
:placeholder="'请输入' + subject.title"></el-input>
|
||||
<div v-if="showQuizResult && getSubjectResult(subject)" class="qsv-online-subject-result" :class="{ 'is-correct': getSubjectResult(subject).isCorrect }">
|
||||
<i :class="getSubjectResult(subject).isCorrect ? 'el-icon-circle-check' : 'el-icon-circle-close'"></i>
|
||||
{{getSubjectResult(subject).isCorrect ? '回答正确' : '回答错误'}},得分:{{getSubjectResult(subject).score || 0}} 分
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qsv-online-footer">
|
||||
@@ -709,6 +783,10 @@ layout("/layouts/platform.html"){
|
||||
minutes: 0,
|
||||
seconds: 0
|
||||
},
|
||||
quizTimer: null,
|
||||
quizStartedAt: 0,
|
||||
quizElapsedSeconds: 0,
|
||||
quizExpired: false,
|
||||
voteTimer: null,
|
||||
voteRankVisible: false,
|
||||
voteDetailVisible: false,
|
||||
@@ -717,6 +795,21 @@ layout("/layouts/platform.html"){
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// 仅已完成的计分答题显示成绩和正确答案,普通调查与投票保持原有展示。
|
||||
showQuizResult() {
|
||||
return this.answerDialog.activity.category === "QUIZ" && !!this.answerDialog.answerRecord.isFinish
|
||||
},
|
||||
quizRemainingSeconds() {
|
||||
return Math.max(0, Number(this.answerDialog.activity.timeLimit || 0) * 60 - this.answerDialog.quizElapsedSeconds)
|
||||
},
|
||||
quizRemainingText() {
|
||||
const seconds = this.quizRemainingSeconds
|
||||
return Math.floor(seconds / 60) + '分' + String(seconds % 60).padStart(2, '0') + '秒'
|
||||
},
|
||||
quizTotalScore() {
|
||||
const answers = this.answerDialog.answerRecord.extJson || {}
|
||||
return Object.keys(answers).reduce((total, id) => total + Number(answers[id].score || 0), 0)
|
||||
},
|
||||
visibleSubjects() {
|
||||
return this.answerDialog.subjects.filter((subject) => {
|
||||
return this.isSubjectVisible(subject)
|
||||
@@ -862,6 +955,10 @@ layout("/layouts/platform.html"){
|
||||
this.$message.warning(unavailableMessage)
|
||||
return
|
||||
}
|
||||
this.stopAnswerTimers()
|
||||
this.answerDialog.quizStartedAt = 0
|
||||
this.answerDialog.quizElapsedSeconds = 0
|
||||
this.answerDialog.quizExpired = false
|
||||
this.answerDialog.visible = true
|
||||
this.answerDialog.loading = true
|
||||
this.answerDialog.readonly = row.actionType === "view"
|
||||
@@ -908,6 +1005,7 @@ layout("/layouts/platform.html"){
|
||||
this.answerDialog.answerRecord = res.data || {}
|
||||
this.answerDialog.readonly = !!this.answerDialog.answerRecord.isFinish
|
||||
this.initAnswer()
|
||||
this.startQuizTimer()
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -928,6 +1026,10 @@ layout("/layouts/platform.html"){
|
||||
this.clearHiddenSubjectAnswers()
|
||||
}
|
||||
},
|
||||
getSubjectResult(subject) {
|
||||
const answers = this.answerDialog.answerRecord.extJson || {}
|
||||
return answers[subject.id]
|
||||
},
|
||||
getRadioValue(subject) {
|
||||
return subject.userSelectOptionIds && subject.userSelectOptionIds.length > 0
|
||||
? subject.userSelectOptionIds[0]
|
||||
@@ -985,6 +1087,43 @@ layout("/layouts/platform.html"){
|
||||
}
|
||||
})
|
||||
},
|
||||
// 使用实际经过时间计时,避免切换浏览器标签后定时器节流导致倒计时变慢。
|
||||
startQuizTimer() {
|
||||
this.stopQuizTimer()
|
||||
if (!this.answerDialog.visible || this.answerDialog.readonly || this.answerDialog.activity.category !== "QUIZ") return
|
||||
this.answerDialog.quizStartedAt = Date.now()
|
||||
this.answerDialog.quizElapsedSeconds = 0
|
||||
this.answerDialog.quizExpired = false
|
||||
this.answerDialog.quizTimer = setInterval(() => this.updateQuizTimer(), 1000)
|
||||
},
|
||||
updateQuizTimer() {
|
||||
if (!this.answerDialog.quizStartedAt || this.answerDialog.readonly || !this.answerDialog.visible) return
|
||||
this.answerDialog.quizElapsedSeconds = Math.floor((Date.now() - this.answerDialog.quizStartedAt) / 1000)
|
||||
if (Number(this.answerDialog.activity.timeLimit) > 0 && this.quizRemainingSeconds === 0
|
||||
&& !this.answerDialog.quizExpired && !this.answerDialog.submitLoading) {
|
||||
this.answerDialog.quizExpired = true
|
||||
this.stopQuizTimer()
|
||||
// 与手机端一致:超时提示重新答题,不自动提交未完成的答卷。
|
||||
this.$alert("答题时间到,请重新进入答题", "提示", {
|
||||
confirmButtonText: "确定",
|
||||
showClose: false,
|
||||
closeOnClickModal: false,
|
||||
closeOnPressEscape: false
|
||||
}).then(() => {
|
||||
this.answerDialog.visible = false
|
||||
})
|
||||
}
|
||||
},
|
||||
stopQuizTimer() {
|
||||
if (this.answerDialog.quizTimer) {
|
||||
clearInterval(this.answerDialog.quizTimer)
|
||||
this.answerDialog.quizTimer = null
|
||||
}
|
||||
},
|
||||
stopAnswerTimers() {
|
||||
this.stopQuizTimer()
|
||||
this.stopVoteCountdown()
|
||||
},
|
||||
startVoteCountdown() {
|
||||
this.stopVoteCountdown()
|
||||
this.updateVoteCountdown()
|
||||
@@ -1196,6 +1335,8 @@ layout("/layouts/platform.html"){
|
||||
return true
|
||||
},
|
||||
submitAnswer() {
|
||||
this.updateQuizTimer()
|
||||
if (this.answerDialog.readonly || this.answerDialog.quizExpired || this.answerDialog.submitLoading) return
|
||||
if (!this.validateAnswer()) {
|
||||
return
|
||||
}
|
||||
@@ -1204,11 +1345,14 @@ layout("/layouts/platform.html"){
|
||||
cancelButtonText: "取消",
|
||||
type: "warning"
|
||||
}).then(() => {
|
||||
this.updateQuizTimer()
|
||||
if (!this.answerDialog.visible || this.answerDialog.readonly || this.answerDialog.quizExpired || this.answerDialog.submitLoading) return
|
||||
this.answerDialog.submitLoading = true
|
||||
$.post("/platform/qsv/online/submitAnswer", {
|
||||
answer: JSON.stringify({
|
||||
activityId: this.answerDialog.activity.id,
|
||||
answerRecordId: this.answerDialog.answerRecordId,
|
||||
answerTime: this.answerDialog.quizElapsedSeconds,
|
||||
subjects: this.submitSubjects.map((subject) => {
|
||||
return {
|
||||
id: subject.id,
|
||||
@@ -1221,7 +1365,14 @@ layout("/layouts/platform.html"){
|
||||
}).then((res) => {
|
||||
if (res.code === 0) {
|
||||
this.$message.success(res.msg)
|
||||
this.answerDialog.visible = false
|
||||
if (this.answerDialog.activity.category === "QUIZ") {
|
||||
// 提交后读取服务端判题结果,直接展示成绩并防止重复提交。
|
||||
this.answerDialog.readonly = true
|
||||
this.stopQuizTimer()
|
||||
this.loadAnswerRecord()
|
||||
} else {
|
||||
this.answerDialog.visible = false
|
||||
}
|
||||
this.pageData()
|
||||
} else {
|
||||
this.$message.error(res.msg)
|
||||
@@ -1237,7 +1388,7 @@ layout("/layouts/platform.html"){
|
||||
this.openAnswerFromUrl()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.stopVoteCountdown()
|
||||
this.stopAnswerTimers()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -55,7 +55,7 @@ layout("/layouts/platform.html"){
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-descriptions-item>-->
|
||||
<el-descriptions-item label="证明人">
|
||||
<!-- <el-descriptions-item label="证明人">
|
||||
<el-form-item prop="certifierUserId" label="证明人">
|
||||
<el-select
|
||||
style="width: 100%"
|
||||
@@ -75,7 +75,7 @@ layout("/layouts/platform.html"){
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions-item> -->
|
||||
<el-descriptions-item label="慰问类型">
|
||||
<el-form-item prop="type" label="慰问类型">
|
||||
<el-select v-model="formData.type" @change="typeChange" style="width: 100%"
|
||||
@@ -112,14 +112,14 @@ layout("/layouts/platform.html"){
|
||||
</el-form-item>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="收款账户">
|
||||
<el-descriptions-item label="收款账户" v-if="!['A', 'D'].includes(formData.typeCode)">
|
||||
<el-form-item prop="bankCardNumber" label="收款账户">
|
||||
<el-input maxlength="20" v-model="formData.bankCardNumber" placeholder="请填写收款账户"
|
||||
<el-input maxlength="30" v-model="formData.bankCardNumber" placeholder="请填写银行卡号或对公账户号"
|
||||
></el-input>
|
||||
</el-form-item>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="户名">
|
||||
<el-descriptions-item label="户名" v-if="!['A', 'D'].includes(formData.typeCode)">
|
||||
<el-form-item prop="bankUserName" label="户名">
|
||||
<el-input maxlength="20" v-model="formData.bankUserName"
|
||||
placeholder="请填写户名"
|
||||
@@ -127,7 +127,7 @@ layout("/layouts/platform.html"){
|
||||
</el-form-item>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="开户行" >
|
||||
<el-descriptions-item label="开户行" v-if="!['A', 'D'].includes(formData.typeCode)">
|
||||
<el-form-item prop="bankOfDeposit" label="开户行">
|
||||
<el-input maxlength="20" v-model="formData.bankOfDeposit"
|
||||
placeholder="请填写开户行"
|
||||
@@ -173,10 +173,12 @@ layout("/layouts/platform.html"){
|
||||
</template>
|
||||
|
||||
|
||||
</el-descriptions>
|
||||
<el-descriptions :column="3" border>
|
||||
<el-descriptions-item label="申请事由" :span="3">
|
||||
<el-form-item prop="remark" label="申请事由">
|
||||
<el-input maxlength="500" v-model="formData.remark" placeholder="请填写申请事由"
|
||||
type="textarea" :autosize="{ minRows: 4, maxRows: 8}"></el-input>
|
||||
type="textarea" :rows="3" resize="none"></el-input>
|
||||
</el-form-item>
|
||||
</el-descriptions-item>
|
||||
|
||||
@@ -238,7 +240,10 @@ layout("/layouts/platform.html"){
|
||||
payUserId: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
|
||||
type: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
|
||||
way: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
|
||||
money: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
|
||||
money: [
|
||||
{ required: true, message: "请填写慰问金额", trigger: ["change", "blur"] },
|
||||
{ validator: (rule, value, callback) => callback(this.validateMoney(value) ? undefined : new Error("慰问金额须大于0且不超过10000元,最多两位小数")), trigger: ["change", "blur"] }
|
||||
],
|
||||
occurTime: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
|
||||
child: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
|
||||
hospital: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
|
||||
@@ -247,14 +252,20 @@ layout("/layouts/platform.html"){
|
||||
hospitalBy: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
|
||||
deadImmediateFamily: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
|
||||
remark: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
|
||||
files: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
|
||||
files: [{ required: true, type: "array", min: 1, max: 5, message: "请上传1至5个附件", trigger: ["change", "blur"] }],
|
||||
signature: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
|
||||
handlerUserId: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
|
||||
certifierUserId: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
|
||||
bankCardNumber: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
|
||||
// certifierUserId: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
|
||||
bankCardNumber: [
|
||||
{ required: true, message: "请填写银行卡号或对公账户号", trigger: ["change", "blur"] },
|
||||
{ pattern: /^[0-9]{6,30}$/, message: "收款账户须为6至30位数字(银行卡号或对公账户号)", trigger: ["change", "blur"] }
|
||||
],
|
||||
bankUserName: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
|
||||
bankOfDeposit: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
|
||||
thisYearHospitalizationNum: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
|
||||
thisYearHospitalizationNum: [
|
||||
{ required: true, message: "必填", trigger: ["change", "blur"] },
|
||||
{ type: "integer", min: 0, max: 10000, message: "住院次数须为0至10000的整数", trigger: ["change", "blur"] }
|
||||
],
|
||||
leaveHospitalTime: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
|
||||
hospitalizationTime: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
|
||||
},
|
||||
@@ -268,6 +279,9 @@ layout("/layouts/platform.html"){
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
validateMoney(value) {
|
||||
return /^(0|[1-9][0-9]*)(\.[0-9]{1,2})?$/.test(String(value)) && Number(value) > 0 && Number(value) <= 10000
|
||||
},
|
||||
createRemoteMethod(options) {
|
||||
return (keyword) => {
|
||||
this.selectQueryUser(keyword, options)
|
||||
|
||||
@@ -37,12 +37,12 @@ const basicForm = {
|
||||
<el-radio-button :label="false">不上传</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>-->
|
||||
<!-- <el-form-item label="是否上传附件" prop="isUploadFile">-->
|
||||
<!-- <el-radio-group v-model="formData.isUploadFile">-->
|
||||
<!-- <el-radio-button :label="true">是</el-radio-button>-->
|
||||
<!-- <el-radio-button :label="false">否</el-radio-button>-->
|
||||
<!-- </el-radio-group>-->
|
||||
<!-- </el-form-item>-->
|
||||
<el-form-item label="是否上传附件" prop="isUploadFile">
|
||||
<el-radio-group v-model="formData.isUploadFile">
|
||||
<el-radio-button :label="true">是</el-radio-button>
|
||||
<el-radio-button :label="false">否</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="上传附件说明" prop="uploadFileDesc" v-if="formData.isUploadFile">
|
||||
<el-input v-model="formData.uploadFileDesc" maxlength="50" clearable placeholder="请填写上传附件说明"></el-input>
|
||||
</el-form-item>
|
||||
|
||||
+64
-58
@@ -26,7 +26,7 @@ layout("/layouts/platform.html"){
|
||||
</el-form-item>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="受补助人">
|
||||
<el-form-item prop="userName">
|
||||
<el-form-item prop="userId">
|
||||
<template v-if="formData.mode == 2">
|
||||
<el-select :remote-method="queryRecipients"
|
||||
@change="userChange" clearable filterable
|
||||
@@ -51,7 +51,7 @@ layout("/layouts/platform.html"){
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="身份证号" :span="2">
|
||||
<el-form-item prop="idCard">
|
||||
<el-input maxlength="20" clearable placeholder="请填写身份证号"
|
||||
<el-input clearable placeholder="请填写身份证号"
|
||||
v-model="formData.idCard" maxlength="18"></el-input>
|
||||
</el-form-item>
|
||||
</el-descriptions-item>
|
||||
@@ -84,7 +84,7 @@ layout("/layouts/platform.html"){
|
||||
<el-descriptions-item label="手机号码">
|
||||
<el-form-item prop="mobile">
|
||||
<el-input maxlength="11" clearable placeholder="请填写手机号码"
|
||||
v-model="formData.mobile" maxlength="11"></el-input>
|
||||
v-model="formData.mobile"></el-input>
|
||||
</el-form-item>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="家庭年总收入(万元)">
|
||||
@@ -103,6 +103,7 @@ layout("/layouts/platform.html"){
|
||||
<el-input-number
|
||||
v-model="formData.homeNumber"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
:step="1"
|
||||
style="width: 100%"
|
||||
placeholder="请填写家庭人数">
|
||||
@@ -113,7 +114,7 @@ layout("/layouts/platform.html"){
|
||||
<el-form-item prop="homeAddress">
|
||||
<el-input clearable
|
||||
style="width: 100%"
|
||||
v-model="formData.homeAddress"
|
||||
v-model="formData.homeAddress" maxlength="200"
|
||||
placeholder="请填写家庭住址">
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
@@ -132,7 +133,7 @@ layout("/layouts/platform.html"){
|
||||
<!-- </el-descriptions-item>-->
|
||||
<el-descriptions-item label="银行卡号" >
|
||||
<el-form-item prop="bankCardNum">
|
||||
<el-input maxlength="20" clearable placeholder="请填写银行卡号"
|
||||
<el-input maxlength="19" clearable placeholder="请填写银行卡号"
|
||||
v-model="formData.bankCardNum"></el-input>
|
||||
</el-form-item>
|
||||
</el-descriptions-item>
|
||||
@@ -152,7 +153,7 @@ layout("/layouts/platform.html"){
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="申请金额(元)">
|
||||
<el-form-item prop="applyMoney" :rules="[{required:true,type:'number',min:0.01,message:'请填写大于零的申请金额',trigger:['change','blur']}]">
|
||||
<el-form-item prop="applyMoney">
|
||||
<el-input-number v-model="formData.applyMoney" :precision="2" :min="0.01" placeholder="请输入申请金额"></el-input-number>
|
||||
</el-form-item>
|
||||
</el-descriptions-item>
|
||||
@@ -198,8 +199,8 @@ layout("/layouts/platform.html"){
|
||||
<template slot-scope="{row,$index}">
|
||||
<el-form-item label-width="0"
|
||||
:prop="'familyList.'+$index+'.relation'"
|
||||
:rules="[{required:true,message:'必填',trigger:['change','blur']}]">
|
||||
<el-input v-model="row.relation" placeholder="请填写与本人关系" clearable></el-input>
|
||||
:rules="textRules('与本人关系', 50)">
|
||||
<el-input v-model="row.relation" maxlength="50" placeholder="请填写与本人关系" clearable></el-input>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -207,8 +208,8 @@ layout("/layouts/platform.html"){
|
||||
<template slot-scope="{row,$index}">
|
||||
<el-form-item label-width="0"
|
||||
:prop="'familyList.'+$index+'.name'"
|
||||
:rules="[{required:true,message:'必填',trigger:['change','blur']}]">
|
||||
<el-input v-model="row.name" placeholder="请填写姓名" clearable></el-input>
|
||||
:rules="textRules('姓名', 100)">
|
||||
<el-input v-model="row.name" maxlength="100" placeholder="请填写姓名" clearable></el-input>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -216,9 +217,9 @@ layout("/layouts/platform.html"){
|
||||
<template slot-scope="{row,$index}">
|
||||
<el-form-item label-width="0"
|
||||
:prop="'familyList.'+$index+'.age'"
|
||||
:rules="[{required:true,message:'必填',trigger:['change','blur']}]">
|
||||
:rules="[{required:true,type:'integer',min:1,max:100,message:'年龄须为1至100的整数',trigger:['change','blur']}]">
|
||||
<el-input-number v-model="row.age" placeholder="年龄" :min="1"
|
||||
:max="100" :step="1" style="width: 100%" clearable></el-input-number>
|
||||
:max="100" :precision="0" :step="1" style="width: 100%" clearable></el-input-number>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -226,8 +227,8 @@ layout("/layouts/platform.html"){
|
||||
<template slot-scope="{row,$index}">
|
||||
<el-form-item label-width="0"
|
||||
:prop="'familyList.'+$index+'.homeUnit'"
|
||||
:rules="[{required:true,message:'必填',trigger:['change','blur']}]">
|
||||
<el-input v-model="row.homeUnit" placeholder="请填写工作单位" clearable></el-input>
|
||||
:rules="textRules('工作单位', 200)">
|
||||
<el-input v-model="row.homeUnit" maxlength="200" placeholder="请填写工作单位" clearable></el-input>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -235,9 +236,9 @@ layout("/layouts/platform.html"){
|
||||
<template slot-scope="{row,$index}">
|
||||
<el-form-item label-width="0"
|
||||
:prop="'familyList.'+$index+'.yearIncome'"
|
||||
:rules="[{required:true,message:'必填',trigger:['change','blur']}]">
|
||||
:rules="[{validator:validateIncome,required:true,trigger:['change','blur']}]">
|
||||
<el-input-number v-model="row.yearIncome" placeholder="年收入"
|
||||
:min="0" :step="1" style="width: 100%" clearable></el-input-number>
|
||||
:min="0" :precision="2" :step="1" style="width: 100%" clearable></el-input-number>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -245,8 +246,8 @@ layout("/layouts/platform.html"){
|
||||
<template slot-scope="{row,$index}">
|
||||
<el-form-item label-width="0"
|
||||
:prop="'familyList.'+$index+'.remarks'"
|
||||
:rules="[{required:true,message:'必填',trigger:['change','blur']}]">
|
||||
<el-input v-model="row.remarks" placeholder="请填写备注" clearable></el-input>
|
||||
:rules="textRules('备注', 500)">
|
||||
<el-input v-model="row.remarks" maxlength="500" placeholder="请填写备注" clearable></el-input>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -326,23 +327,37 @@ layout("/layouts/platform.html"){
|
||||
applyCount: '1',
|
||||
},
|
||||
formRules: {
|
||||
proxyUserName: [{required: true, message: '必填', trigger: ['blur', 'change']}],
|
||||
proxyLoginName: [{required: true, message: '必填', trigger: ['blur', 'change']}],
|
||||
sex: [{required: true, message: '请填写性别', trigger: ['blur', 'change']}],
|
||||
userName: [{required: true, message: '请填写受补助人', trigger: ['blur', 'change']}],
|
||||
unitName: [{required: true, message: '请填写所属单位', trigger: ['blur', 'change']}],
|
||||
proxyUserName: this.textRules('填写人姓名', 100),
|
||||
proxyLoginName: this.textRules('填写人工号', 100),
|
||||
mode: [{required: true, type: 'enum', enum: ['1', '2'], message: '请选择申请模式', trigger: ['blur', 'change']}],
|
||||
sex: [{required: true, type: 'enum', enum: ['男', '女'], message: '请选择性别', trigger: ['blur', 'change']}],
|
||||
userId: [{required: true, message: '请选择受补助人', trigger: ['blur', 'change']}],
|
||||
unitName: this.textRules('所属单位', 500),
|
||||
unionName: [{required: true, message: '请填写所属工会', trigger: ['blur', 'change']}],
|
||||
homeAddress: [{required: true, message: '请填写家庭住址', trigger: ['blur', 'change']}],
|
||||
mobile: [{required: true, message: '请填写手机号码', trigger: ['blur', 'change']}],
|
||||
idCard: [{required: true, message: '请填写身份证号', trigger: ['blur', 'change']}],
|
||||
bankCardNum: [{required: true, message: '请填写收款账户卡号', trigger: ['blur', 'change']}],
|
||||
homeAddress: this.textRules('家庭住址', 200),
|
||||
mobile: [
|
||||
{required: true, message: '请填写手机号码', trigger: ['blur', 'change']},
|
||||
{pattern: /^1[3-9]\d{9}$/, message: '请输入正确的11位手机号码', trigger: ['blur', 'change']}
|
||||
],
|
||||
idCard: [{validator: (rule, value, callback) => {
|
||||
const result = this.isValidIdCard(value || '')
|
||||
callback(result.status === 0 ? new Error(result.msg) : undefined)
|
||||
}, required: true, trigger: ['blur', 'change']}],
|
||||
bankCardNum: [{validator: (rule, value, callback) => {
|
||||
const result = this.validateCard(value || '')
|
||||
callback(result.status === 0 ? new Error(result.msg) : undefined)
|
||||
}, required: true, trigger: ['blur', 'change']}],
|
||||
bankUserName: [{required: true, message: '请填写户名', trigger: ['blur', 'change']}],
|
||||
bankOfDeposit: [{required: true, message: '请填写开户行', trigger: ['blur', 'change']}],
|
||||
position: [{required: true, message: '请填写职务', trigger: ['blur', 'change']}],
|
||||
officialCapacity: [{required: true, message: '请填写职称', trigger: ['blur', 'change']}],
|
||||
subsidyStandards: [{required: true, message: '请选择困难类型', trigger: ['blur', 'change']}],
|
||||
reason: [{required: true, message: '请填写申请原因', trigger: ['blur', 'change']}],
|
||||
sign: [{required: true, message: '请签字', trigger: ['blur', 'change']}]
|
||||
bankOfDeposit: this.textRules('开户行', 30),
|
||||
position: this.textRules('职务', 20),
|
||||
officialCapacity: this.textRules('职称', 20),
|
||||
homeIncome: [{validator: this.validateIncome, required: true, trigger: ['blur', 'change']}],
|
||||
homeNumber: [{required: true, type: 'integer', min: 1, message: '家庭人数须为大于零的整数', trigger: ['blur', 'change']}],
|
||||
subsidy: this.textRules('补助类型', 500),
|
||||
subsidyStandards: this.textRules('困难类型', 500),
|
||||
applyMoney: [{validator: this.validateMoney, required: true, trigger: ['blur', 'change']}],
|
||||
reason: this.textRules('生活致困原因', 500),
|
||||
sign: [{required: true, whitespace: true, message: '请完成签字', trigger: ['blur', 'change']}],
|
||||
},
|
||||
|
||||
// 替他人申请相关
|
||||
@@ -350,8 +365,21 @@ layout("/layouts/platform.html"){
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
textRules(label, max) {
|
||||
return [{required: true, whitespace: true, max, message: '请填写' + label + ',且不超过' + max + '个字符', trigger: ['blur', 'change']}]
|
||||
},
|
||||
validateIncome(rule, value, callback) {
|
||||
const valid = typeof value === 'number' && Number.isFinite(value) && value >= 0 && /^\d+(\.\d{1,2})?$/.test(String(value))
|
||||
callback(valid ? undefined : new Error('收入须为不小于零的数字,最多保留两位小数'))
|
||||
},
|
||||
validateMoney(rule, value, callback) {
|
||||
const valid = typeof value === 'number' && Number.isFinite(value) && value > 0 && /^\d+(\.\d{1,2})?$/.test(String(value))
|
||||
callback(valid ? undefined : new Error('申请金额须大于零,最多保留两位小数'))
|
||||
},
|
||||
// 保存
|
||||
onSave() {
|
||||
async onSave() {
|
||||
const valid = await this.$refs.formRef.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
this.$confirm("您确定保存吗?", "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
@@ -368,24 +396,13 @@ layout("/layouts/platform.html"){
|
||||
|
||||
// 提交
|
||||
async onSubmit() {
|
||||
this.$refs.formRef.validate().then((valid) => {
|
||||
this.$refs.formRef.validate().catch(() => false).then((valid) => {
|
||||
if (valid) {
|
||||
this.$confirm("您确定要提交吗?", "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning"
|
||||
}).then(() => {
|
||||
const result = this.isValidIdCard(this.formData.idCard)
|
||||
if (result.status === 0) {
|
||||
this.$message.warning(result.msg)
|
||||
return
|
||||
}
|
||||
const bankRes = this.validateCard(this.formData.bankCardNum)
|
||||
if (bankRes.status === 0) {
|
||||
this.$message.warning(bankRes.msg)
|
||||
return;
|
||||
}
|
||||
|
||||
this.$axios.post('/platform/difficultHelp/apply/submit', {
|
||||
data: JSON.stringify(this.formData)
|
||||
}).then(res => {
|
||||
@@ -400,7 +417,7 @@ layout("/layouts/platform.html"){
|
||||
},
|
||||
|
||||
async onFinishTask() {
|
||||
this.$refs.formRef.validate().then((valid) => {
|
||||
this.$refs.formRef.validate().catch(() => false).then((valid) => {
|
||||
if (valid) {
|
||||
this.$confirm("您确定要提交吗?", "提示", {
|
||||
confirmButtonText: "确定",
|
||||
@@ -408,17 +425,6 @@ layout("/layouts/platform.html"){
|
||||
type: "warning"
|
||||
}).then(() => {
|
||||
|
||||
const result = this.isValidIdCard(this.formData.idCard)
|
||||
if (result.status === 0) {
|
||||
this.$message.warning(result.msg)
|
||||
return
|
||||
}
|
||||
const bankRes = this.validateCard(this.formData.bankCardNum)
|
||||
if (bankRes.status === 0) {
|
||||
this.$message.warning(bankRes.msg)
|
||||
return;
|
||||
}
|
||||
|
||||
this.$axios.post('/platform/difficultHelp/apply/submitAgain', {
|
||||
data: JSON.stringify(this.formData),
|
||||
taskId: GetQueryString("taskId")
|
||||
|
||||
@@ -62,7 +62,7 @@ const filterUser = {
|
||||
</search-item>
|
||||
|
||||
<search-item label="所属单位">
|
||||
<el-select @change="unitChange" clearable filterable
|
||||
<el-select clearable filterable
|
||||
placeholder="请选择所属单位" style="width: 100%"
|
||||
v-model="pageForm.unitId">
|
||||
<el-option
|
||||
@@ -74,17 +74,6 @@ const filterUser = {
|
||||
</el-select>
|
||||
</search-item>
|
||||
|
||||
<search-item label="三级单位">
|
||||
<el-select clearable filterable placeholder="请先选择所属单位" style="width: 100%"
|
||||
v-model="pageForm.threeUnitId">
|
||||
<el-option
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
v-for="item in threeUnitOptions">
|
||||
</el-option>
|
||||
</el-select>
|
||||
</search-item>
|
||||
|
||||
<search-item label="人员类型:">
|
||||
<dict-select clearable code="USER_PERSON_TYPE"
|
||||
@@ -94,13 +83,6 @@ const filterUser = {
|
||||
v-model="pageForm.personTypes"></dict-select>
|
||||
</search-item>
|
||||
|
||||
<search-item label="人员属性:">
|
||||
<dict-select clearable code="USER_ATTRIBUTE"
|
||||
multiple
|
||||
collapse-tags
|
||||
placeholder="请选择人员属性"
|
||||
v-model="pageForm.userAttributes"></dict-select>
|
||||
</search-item>
|
||||
|
||||
<search-item label="在职状态:">
|
||||
<dict-select clearable code="USER_STATE"
|
||||
@@ -169,14 +151,11 @@ const filterUser = {
|
||||
userStates: [],
|
||||
personTypes: [],
|
||||
preparedBys: [],
|
||||
userAttributes: [],
|
||||
unitId: null,
|
||||
threeUnitId: null
|
||||
unitId: null
|
||||
},
|
||||
sexOptions: ["男", "女"],
|
||||
unionOptions: [],
|
||||
unitOptions: [],
|
||||
threeUnitOptions: [],
|
||||
tableColumns: [
|
||||
{ prop: "loginname", label: "工号" },
|
||||
{ prop: "username", label: "姓名" },
|
||||
@@ -185,8 +164,7 @@ const filterUser = {
|
||||
{ prop: "personType", label: "人员类型", sortable: true },
|
||||
{ prop: "userState", label: "在职状态", sortable: true },
|
||||
{ prop: "unionName", label: "所属工会", sortable: true },
|
||||
{ prop: "unitName", label: "所属单位", sortable: true },
|
||||
{ prop: "threeUnitName", label: "三级单位", sortable: true }
|
||||
{ prop: "unitName", label: "所属单位", sortable: true }
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -208,35 +186,12 @@ const filterUser = {
|
||||
this.$set(this.pageForm, "birthMonths", [])
|
||||
this.$set(this.pageForm, "unionId", null)
|
||||
this.$set(this.pageForm, "unitId", null)
|
||||
this.$set(this.pageForm, "threeUnitId", null)
|
||||
this.threeUnitOptions = []
|
||||
this.$set(this.pageForm, "personTypes", [])
|
||||
this.$set(this.pageForm, "preparedBys", [])
|
||||
this.$set(this.pageForm, "userAttributes", [])
|
||||
this.$set(this.pageForm, "userStates", [])
|
||||
this.$set(this.pageForm, "isMember", null)
|
||||
},
|
||||
|
||||
// 所属单位改变后,仅加载该单位直属的三级单位,避免跨单位筛选。
|
||||
unitChange(unitId) {
|
||||
this.$set(this.pageForm, "threeUnitId", null)
|
||||
this.threeUnitOptions = []
|
||||
if (!unitId) {
|
||||
return
|
||||
}
|
||||
this.$axios.post("/platform/sys/unit/child", { pid: unitId })
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
this.threeUnitOptions = res.data || []
|
||||
} else {
|
||||
this.$message.error(res.msg || "三级单位查询失败")
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.$message.error("三级单位查询失败,请稍后重试")
|
||||
})
|
||||
},
|
||||
|
||||
pageData() {
|
||||
this.tableLoading = true
|
||||
this.$axios
|
||||
|
||||
@@ -65,10 +65,6 @@ layout("/layouts/platform.html"){
|
||||
<dict-select v-model="pageForm.aidFundMemberUserType" placeholder="请选择人员分类" @change="doSearch"
|
||||
code="AIDFUND_MEMBER_USER_TYPE"></dict-select>
|
||||
</search-item>
|
||||
<search-item label="人员属性">
|
||||
<dict-select v-model="pageForm.userAttribute" placeholder="请选择人员属性" @change="doSearch"
|
||||
code="USER_ATTRIBUTE"></dict-select>
|
||||
</search-item>
|
||||
</search>
|
||||
</el-card>
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ const addUser = {
|
||||
</search-item>
|
||||
|
||||
<search-item label="所属单位">
|
||||
<el-select @change="unitChange" clearable
|
||||
<el-select clearable
|
||||
filterable
|
||||
placeholder="请选择所属单位" style="width: 100%"
|
||||
v-model="pageForm.unitId">
|
||||
@@ -76,17 +76,6 @@ const addUser = {
|
||||
</el-select>
|
||||
</search-item>
|
||||
|
||||
<search-item label="三级单位">
|
||||
<el-select clearable filterable placeholder="请先选择所属单位" style="width: 100%"
|
||||
v-model="pageForm.threeUnitId">
|
||||
<el-option
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
v-for="item in threeUnitOptions">
|
||||
</el-option>
|
||||
</el-select>
|
||||
</search-item>
|
||||
|
||||
<search-item label="人员类型:">
|
||||
<dict-select clearable code="USER_PERSON_TYPE"
|
||||
@@ -96,13 +85,6 @@ const addUser = {
|
||||
v-model="pageForm.personTypes"></dict-select>
|
||||
</search-item>
|
||||
|
||||
<search-item label="人员属性:">
|
||||
<dict-select clearable code="USER_ATTRIBUTE"
|
||||
multiple
|
||||
collapse-tags
|
||||
placeholder="请选择人员属性"
|
||||
v-model="pageForm.userAttributes"></dict-select>
|
||||
</search-item>
|
||||
|
||||
<search-item label="在职状态:">
|
||||
<dict-select clearable code="USER_STATE"
|
||||
@@ -171,9 +153,7 @@ const addUser = {
|
||||
birthMonths: [],
|
||||
unionId: null,
|
||||
unitId: null,
|
||||
threeUnitId: null,
|
||||
personTypes: [],
|
||||
userAttributes: [],
|
||||
preparedBys: [],
|
||||
userStates: [],
|
||||
isMember: null
|
||||
@@ -181,7 +161,6 @@ const addUser = {
|
||||
sexOptions: ["男", "女"],
|
||||
unionOptions: [],
|
||||
unitOptions: [],
|
||||
threeUnitOptions: [],
|
||||
tableColumns: [
|
||||
{ prop: "loginname", label: "工号" },
|
||||
{ prop: "username", label: "姓名" },
|
||||
@@ -190,8 +169,7 @@ const addUser = {
|
||||
{ prop: "personType", label: "人员类型", sortable: true },
|
||||
{ prop: "userState", label: "在职状态", sortable: true },
|
||||
{ prop: "unionName", label: "所属工会", sortable: true },
|
||||
{ prop: "unitName", label: "所属单位", sortable: true },
|
||||
{ prop: "threeUnitName", label: "三级单位", sortable: true }
|
||||
{ prop: "unitName", label: "所属单位", sortable: true }
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -213,10 +191,7 @@ const addUser = {
|
||||
this.$set(this.pageForm, "birthMonths", [])
|
||||
this.$set(this.pageForm, "unionId", null)
|
||||
this.$set(this.pageForm, "unitId", null)
|
||||
this.$set(this.pageForm, "threeUnitId", null)
|
||||
this.$set(this, "threeUnitOptions", [])
|
||||
this.$set(this.pageForm, "personTypes", [])
|
||||
this.$set(this.pageForm, "userAttributes", [])
|
||||
this.$set(this.pageForm, "preparedBys", [])
|
||||
this.$set(this.pageForm, "userStates", [])
|
||||
this.$set(this.pageForm, "isMember", null)
|
||||
@@ -224,7 +199,6 @@ const addUser = {
|
||||
|
||||
async unionIdChange(val) {
|
||||
this.$set(this.pageForm, "unitId", null)
|
||||
this.unitChange(null)
|
||||
if (val) {
|
||||
this.$set(this, "unitOptions", await this.$businessTool.listUnit(val))
|
||||
} else {
|
||||
@@ -232,26 +206,6 @@ const addUser = {
|
||||
}
|
||||
},
|
||||
|
||||
// 所属单位改变后,仅加载该单位直属的三级单位,避免跨单位筛选。
|
||||
unitChange(unitId) {
|
||||
this.$set(this.pageForm, "threeUnitId", null)
|
||||
this.$set(this, "threeUnitOptions", [])
|
||||
if (!unitId) {
|
||||
return
|
||||
}
|
||||
this.$axios.post("/platform/sys/unit/child", { pid: unitId })
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
this.$set(this, "threeUnitOptions", res.data || [])
|
||||
} else {
|
||||
this.$message.error(res.msg || "三级单位查询失败")
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.$message.error("三级单位查询失败,请稍后重试")
|
||||
})
|
||||
},
|
||||
|
||||
pageData() {
|
||||
this.tableLoading = true
|
||||
this.$axios
|
||||
|
||||
@@ -81,26 +81,12 @@ layout("/layouts/platform.html"){
|
||||
collapse-tags
|
||||
placeholder="请选择所属单位"
|
||||
style="width: 100%"
|
||||
@change="unitIdsChange"
|
||||
v-model="pageForm.unitIds"
|
||||
>
|
||||
<el-option :key="item.id" :label="item.name" :value="item.id" v-for="item in unitOptions"></el-option>
|
||||
</el-select>
|
||||
</search-item>
|
||||
|
||||
<search-item label="三级单位">
|
||||
<el-select
|
||||
clearable
|
||||
filterable
|
||||
multiple
|
||||
collapse-tags
|
||||
placeholder="请先选择所属单位"
|
||||
style="width: 100%"
|
||||
v-model="pageForm.threeUnitIds"
|
||||
>
|
||||
<el-option :key="item.id" :label="item.name" :value="item.id" v-for="item in threeUnitOptions"></el-option>
|
||||
</el-select>
|
||||
</search-item>
|
||||
|
||||
<search-item label="人员类型:">
|
||||
<dict-select
|
||||
@@ -128,10 +114,6 @@ layout("/layouts/platform.html"){
|
||||
<dict-select v-model="pageForm.aidFundMemberUserType" placeholder="请选择人员分类" @change="doSearch"
|
||||
code="AIDFUND_MEMBER_USER_TYPE"></dict-select>
|
||||
</search-item>
|
||||
<search-item label="人员属性">
|
||||
<dict-select v-model="pageForm.userAttributes" placeholder="请选择人员属性" @change="doSearch"
|
||||
code="USER_ATTRIBUTE" clearable multiple collapse-tags></dict-select>
|
||||
</search-item>
|
||||
|
||||
<!-- <search-item label="所选福利:">-->
|
||||
<!-- <el-select clearable placeholder="请选择所选福利" style="width: 100%" v-model="pageForm.optionId">-->
|
||||
@@ -283,7 +265,6 @@ layout("/layouts/platform.html"){
|
||||
},
|
||||
unionOptions: [],
|
||||
unitOptions: [],
|
||||
threeUnitOptions: [],
|
||||
sexOptions: ["男", "女"],
|
||||
projectSelectOptions: [],
|
||||
tableColumns: [
|
||||
@@ -295,7 +276,6 @@ layout("/layouts/platform.html"){
|
||||
{ prop: "userState", label: "在职状态", sortable: true },
|
||||
{ prop: "welfareUnionName", label: "所属工会", sortable: true },
|
||||
{ prop: "welfareUnitName", label: "所属单位", sortable: true },
|
||||
{ prop: "threeUnitName", label: "三级单位", sortable: true },
|
||||
{ prop: "remark", label: "备注", sortable: true }
|
||||
],
|
||||
|
||||
@@ -423,32 +403,6 @@ layout("/layouts/platform.html"){
|
||||
this.doSearch()
|
||||
})
|
||||
},
|
||||
// 根据已选所属单位查询直属三级单位,筛选项只保留当前所属单位范围内的数据。
|
||||
async unitIdsChange(unitIds) {
|
||||
this.$set(this.pageForm, "threeUnitIds", [])
|
||||
this.$set(this, "threeUnitOptions", [])
|
||||
if (!unitIds || unitIds.length === 0) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const responses = await Promise.all(unitIds.map((unitId) => this.$axios.post("/platform/sys/unit/child", { pid: unitId })))
|
||||
const failedResponse = responses.find((res) => res.code !== 0)
|
||||
if (failedResponse) {
|
||||
this.$message.error(failedResponse.msg || "三级单位查询失败")
|
||||
return
|
||||
}
|
||||
const optionMap = {}
|
||||
responses.forEach((res) => {
|
||||
const options = res.data || []
|
||||
options.forEach((item) => {
|
||||
optionMap[item.id] = item
|
||||
})
|
||||
})
|
||||
this.$set(this, "threeUnitOptions", Object.keys(optionMap).map((id) => optionMap[id]))
|
||||
} catch (e) {
|
||||
this.$message.error("三级单位查询失败,请稍后重试")
|
||||
}
|
||||
},
|
||||
pageData() {
|
||||
this.tableLoading = true
|
||||
this.$axios
|
||||
|
||||
@@ -16,6 +16,11 @@ layout("/layouts/platform_h5.html"){
|
||||
</div>
|
||||
|
||||
<div class="quiz-card-scroll" v-if="!answerRecord.isFinish">
|
||||
<van-collapse v-if="activity.description" v-model="descriptionPanels" class="quiz-description" @change="adaptDescriptionToMobile">
|
||||
<van-collapse-item title="说明" name="description" :value="descriptionPanels.length ? '收起' : '展开'">
|
||||
<div ref="descriptionContent" class="quiz-description-content" v-html="activity.description"></div>
|
||||
</van-collapse-item>
|
||||
</van-collapse>
|
||||
<div v-for="(subject, index) in subjects" :key="index" class="subject">
|
||||
<p>{{index+1}}、{{ subject.title }}</p>
|
||||
<div class="tag">
|
||||
@@ -65,6 +70,7 @@ layout("/layouts/platform_h5.html"){
|
||||
H5JumpTo: GetQueryString("H5JumpTo"),
|
||||
subjects: [],
|
||||
activity: {},
|
||||
descriptionPanels: [],
|
||||
isFinished: false,
|
||||
answerRecord: {},
|
||||
answerRecordId: null,
|
||||
@@ -100,6 +106,28 @@ layout("/layouts/platform_h5.html"){
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 按原有字号层次缩小大字,不覆盖富文本颜色、字重、对齐等样式。
|
||||
adaptDescriptionToMobile() {
|
||||
this.$nextTick(() => {
|
||||
const content = this.$refs.descriptionContent
|
||||
if (!content || !this.descriptionPanels.length || content.dataset.mobileAdapted) return
|
||||
// 先读取全部字号,避免父元素缩放后影响子元素的继承字号计算。
|
||||
const sizes = [content, ...content.querySelectorAll('*')].map(element => ({
|
||||
element,
|
||||
size: parseFloat(window.getComputedStyle(element).fontSize)
|
||||
}))
|
||||
const maxSize = sizes.reduce((max, item) => Math.max(max, item.size || 0), 0)
|
||||
if (maxSize > 22) {
|
||||
const ratio = 8 / (maxSize - 14)
|
||||
sizes.forEach(({ element, size }) => {
|
||||
if (!Number.isFinite(size)) return
|
||||
const mobileSize = size > 14 ? 14 + (size - 14) * ratio : size
|
||||
element.style.setProperty('font-size', mobileSize + 'px', 'important')
|
||||
})
|
||||
}
|
||||
content.dataset.mobileAdapted = 'true'
|
||||
})
|
||||
},
|
||||
getCategoryPageTitle(category, defaultTitle) {
|
||||
const titleMap = {
|
||||
QUIZ: "在线答题",
|
||||
@@ -412,6 +440,25 @@ layout("/layouts/platform_h5.html"){
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.quiz-description {
|
||||
margin-bottom: 14px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quiz-description-content {
|
||||
color: #303133;
|
||||
line-height: 1.8;
|
||||
overflow-wrap: break-word;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
|
||||
/deep/ .quiz-description-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.subject {
|
||||
background: #fff;
|
||||
padding: 15px;
|
||||
|
||||
@@ -276,8 +276,22 @@ layout("/layouts/platform_h5.html"){
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 导航栏参与弹层布局,正文独立滚动,避免固定导航遮住首张卡片。 */
|
||||
.tour-record-popup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tour-record-popup > .van-nav-bar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tour-popup-body {
|
||||
height: calc(100vh - 46px);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px 76px;
|
||||
background: #f4f6f8;
|
||||
@@ -594,7 +608,7 @@ layout("/layouts/platform_h5.html"){
|
||||
}
|
||||
|
||||
.tour-popup-body {
|
||||
padding: 12px 12px 82px;
|
||||
padding: 12px 12px calc(82px + env(safe-area-inset-bottom));
|
||||
background-color: #f3f7fd;
|
||||
}
|
||||
|
||||
@@ -616,6 +630,12 @@ layout("/layouts/platform_h5.html"){
|
||||
box-shadow: 0 -6px 18px rgba(38, 66, 103, .08);
|
||||
}
|
||||
|
||||
.tour-bottom-bar .tour-cancel-button {
|
||||
border: 0;
|
||||
color: #65748a;
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
/* 列表页结构与提案校工会预审核保持一致,业务字段及操作条件继续沿用疗休养逻辑。 */
|
||||
.tour-my-page .van-nav-bar {
|
||||
background-color: #fff;
|
||||
@@ -917,8 +937,8 @@ layout("/layouts/platform_h5.html"){
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
<van-popup v-model="detailVisible" position="right" :style="{ width: '100%', height: '100%' }" :close-on-click-overlay="false">
|
||||
<van-nav-bar title="报名详情" left-text="返回" left-arrow @click-left="closeHistoryPopup('detail')" fixed placeholder></van-nav-bar>
|
||||
<van-popup v-model="detailVisible" class="tour-record-popup" position="right" :style="{ width: '100%', height: '100%' }" :close-on-click-overlay="false">
|
||||
<van-nav-bar title="报名详情" left-text="返回" left-arrow @click-left="closeHistoryPopup('detail')"></van-nav-bar>
|
||||
<div class="tour-popup-body">
|
||||
<div class="tour-section">
|
||||
<div class="tour-section-title">教职工信息</div>
|
||||
@@ -926,6 +946,7 @@ layout("/layouts/platform_h5.html"){
|
||||
<van-cell title="姓名" :value="detail.userName || ''"></van-cell>
|
||||
<van-cell title="性别" :value="detail.gender || ''"></van-cell>
|
||||
<van-cell title="身份证号" :value="detail.idCard || ''"></van-cell>
|
||||
<van-cell title="手机号" :value="detail.mobile || ''"></van-cell>
|
||||
<van-cell title="所在单位" :value="detail.unitName || ''"></van-cell>
|
||||
<van-cell title="所在工会" :value="detail.unionName || ''"></van-cell>
|
||||
</div>
|
||||
@@ -972,14 +993,15 @@ layout("/layouts/platform_h5.html"){
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
<van-popup v-model="signupVisible" position="right" :style="{ width: '100%', height: '100%' }" :close-on-click-overlay="false" @closed="destroySignup">
|
||||
<van-nav-bar title="修改报名" left-text="返回" left-arrow @click-left="closeHistoryPopup('signup')" fixed placeholder></van-nav-bar>
|
||||
<van-popup v-model="signupVisible" class="tour-record-popup" position="right" :style="{ width: '100%', height: '100%' }" :close-on-click-overlay="false" @closed="destroySignup">
|
||||
<van-nav-bar title="修改报名" left-text="返回" left-arrow @click-left="closeHistoryPopup('signup')"></van-nav-bar>
|
||||
<div class="tour-popup-body">
|
||||
<div class="tour-section">
|
||||
<div class="tour-section-title">教职工信息</div>
|
||||
<van-field label="姓名" readonly v-model="signupForm.userName"></van-field>
|
||||
<van-field label="工号" readonly v-model="signupForm.jobNo"></van-field>
|
||||
<van-field label="身份证号" v-model="signupForm.idCard" maxlength="30" placeholder="请填写身份证号"></van-field>
|
||||
<van-field required label="身份证号" v-model="signupForm.idCard" maxlength="30" placeholder="请填写身份证号"></van-field>
|
||||
<van-field required label="手机号" v-model="signupForm.mobile" type="tel" maxlength="11" placeholder="请填写手机号"></van-field>
|
||||
<van-field label="所在工会" readonly v-model="signupForm.unionName"></van-field>
|
||||
</div>
|
||||
|
||||
@@ -1007,12 +1029,12 @@ layout("/layouts/platform_h5.html"){
|
||||
|
||||
<div v-if="isDirectFamilyLine(signupForm)" class="tour-section">
|
||||
<div class="tour-section-title">直系亲属线路</div>
|
||||
<van-field label="亲属姓名" v-model="directRelativeForm.relativeName" maxlength="100" placeholder="请输入亲属姓名"></van-field>
|
||||
<van-field label="所在单位" v-model="directRelativeForm.unitName" maxlength="100" placeholder="请输入所在单位"></van-field>
|
||||
<van-field readonly clickable is-link label="亲属关系" v-model="directRelativeForm.relationshipName" placeholder="请选择亲属关系" @click="openDirectRelativePicker"></van-field>
|
||||
<van-field label="线路名称" v-model="directRelativeForm.lineName" maxlength="100" placeholder="请输入线路名称"></van-field>
|
||||
<van-field label="出行开始" v-model="directRelativeForm.travelStartTime" type="date" placeholder="请选择出行开始日期"></van-field>
|
||||
<van-field label="出行结束" v-model="directRelativeForm.travelEndTime" type="date" placeholder="请选择出行结束日期"></van-field>
|
||||
<van-field required label="亲属姓名" v-model="directRelativeForm.relativeName" maxlength="100" placeholder="请输入亲属姓名"></van-field>
|
||||
<van-field required label="所在单位" v-model="directRelativeForm.unitName" maxlength="100" placeholder="请输入所在单位"></van-field>
|
||||
<van-field required readonly clickable is-link label="亲属关系" v-model="directRelativeForm.relationshipName" placeholder="请选择亲属关系" @click="openDirectRelativePicker"></van-field>
|
||||
<van-field required label="线路名称" v-model="directRelativeForm.lineName" maxlength="100" placeholder="请输入线路名称"></van-field>
|
||||
<van-field required label="出行开始" v-model="directRelativeForm.travelStartTime" type="date" placeholder="请选择出行开始日期"></van-field>
|
||||
<van-field required label="出行结束" v-model="directRelativeForm.travelEndTime" type="date" placeholder="请选择出行结束日期"></van-field>
|
||||
</div>
|
||||
|
||||
<div v-if="allowFamily && !isDirectFamilyLine(signupForm)" class="tour-section">
|
||||
@@ -1054,7 +1076,7 @@ layout("/layouts/platform_h5.html"){
|
||||
:title="familyTabTitle(family, familyIndex)">
|
||||
<div class="tour-family-form">
|
||||
<van-field required label="姓名" v-model="family.familyName" maxlength="100" placeholder="请填写姓名"></van-field>
|
||||
<van-field required label="年龄" v-model.number="family.age" type="digit" placeholder="请填写年龄"></van-field>
|
||||
<van-field required label="年龄" v-model.number="family.age" type="number" :formatter="formatFamilyAge" placeholder="请填写年龄"></van-field>
|
||||
<van-field required label="性别">
|
||||
<template #input>
|
||||
<van-radio-group v-model="family.gender" direction="horizontal">
|
||||
@@ -1065,7 +1087,7 @@ layout("/layouts/platform_h5.html"){
|
||||
</van-field>
|
||||
<van-field :required="familyContactRequired(family)" label="身份证号码" v-model="family.idCard" maxlength="18" placeholder="请填写身份证号码" @blur="normalizeFamilyIdCard(family)"></van-field>
|
||||
<van-field :required="familyContactRequired(family)" label="手机号" v-model="family.mobile" type="tel" maxlength="30" placeholder="请填写手机号"></van-field>
|
||||
<van-field required readonly clickable is-link label="与本人关系" v-model="family.relationship" placeholder="请选择关系" @click="openRelationshipPicker"></van-field>
|
||||
<van-field required readonly clickable is-link label="与本人关系" v-model="family.relationship" placeholder="请选择关系" @click="openRelationshipPicker(familyIndex)"></van-field>
|
||||
<van-field v-if="fillBedInfo" readonly clickable is-link label="床型" v-model="family.bedType" placeholder="请选择房型" @click="openBedPicker('family')"></van-field>
|
||||
<van-field v-if="fillBedInfo" label="床位" v-model="family.bedInfo" maxlength="100" placeholder="请输入床位数量"></van-field>
|
||||
<van-field v-if="fillBedInfo" label="意向拼床人" v-model="family.intendedRoommate" maxlength="100" placeholder="请输入意向拼床人"></van-field>
|
||||
@@ -1087,8 +1109,8 @@ layout("/layouts/platform_h5.html"){
|
||||
<div v-else class="tour-family-empty">暂无亲属,请按需添加</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tour-bottom-bar">
|
||||
<van-button v-if="signupForm.id" type="danger" plain block round :loading="cancelLoading" @click="cancelSignup">取消报名</van-button>
|
||||
<div class="tour-bottom-bar tour-h5-fixed-bar">
|
||||
<van-button v-if="signupForm.id" class="tour-cancel-button" type="default" block round :loading="cancelLoading" @click="cancelSignup">取消报名</van-button>
|
||||
<van-button type="info" block round :loading="signupLoading" @click="submitSignup">提交</van-button>
|
||||
</div>
|
||||
</van-popup>
|
||||
@@ -1442,6 +1464,7 @@ layout("/layouts/platform_h5.html"){
|
||||
userName: "",
|
||||
gender: "",
|
||||
idCard: "",
|
||||
mobile: "",
|
||||
unitId: "",
|
||||
unitName: "",
|
||||
unionId: "",
|
||||
@@ -1601,6 +1624,11 @@ layout("/layouts/platform_h5.html"){
|
||||
isBlankValue(value) {
|
||||
return value === undefined || value === null || !String(value).trim()
|
||||
},
|
||||
// 与 Web 端一致,亲属年龄限定为 0~120 岁整数,清空时保留必填状态。
|
||||
formatFamilyAge(value) {
|
||||
const age = parseInt(value, 10)
|
||||
return Number.isNaN(age) ? "" : String(Math.min(120, Math.max(0, age)))
|
||||
},
|
||||
// 年满16周岁的亲属才强制填写身份证号码和手机号。
|
||||
familyContactRequired(family) {
|
||||
return family && !this.isBlankValue(family.age) && Number(family.age) >= 16
|
||||
|
||||
@@ -307,9 +307,9 @@ layout("/layouts/platform_tour_signup_h5.html"){
|
||||
|
||||
<div v-if="isDirectFamilyLine(signupForm)" class="tour-apply-card">
|
||||
<div class="tour-apply-title">直系亲属线路</div>
|
||||
<van-field label="亲属姓名" v-model="directRelativeForm.relativeName" maxlength="100" placeholder="请输入亲属姓名"></van-field>
|
||||
<van-field label="所在单位" v-model="directRelativeForm.unitName" maxlength="100" placeholder="请输入所在单位"></van-field>
|
||||
<van-field
|
||||
<van-field required label="亲属姓名" v-model="directRelativeForm.relativeName" maxlength="100" placeholder="请输入亲属姓名"></van-field>
|
||||
<van-field required label="所在单位" v-model="directRelativeForm.unitName" maxlength="100" placeholder="请输入所在单位"></van-field>
|
||||
<van-field required
|
||||
label="亲属关系"
|
||||
readonly
|
||||
clickable
|
||||
@@ -318,9 +318,9 @@ layout("/layouts/platform_tour_signup_h5.html"){
|
||||
v-model="directRelativeForm.relationshipName"
|
||||
@click="openDirectRelativePicker">
|
||||
</van-field>
|
||||
<van-field label="线路名称" v-model="directRelativeForm.lineName" maxlength="100" placeholder="请输入线路名称"></van-field>
|
||||
<van-field label="出行开始" v-model="directRelativeForm.travelStartTime" type="date" placeholder="请选择出行开始日期"></van-field>
|
||||
<van-field label="出行结束" v-model="directRelativeForm.travelEndTime" type="date" placeholder="请选择出行结束日期"></van-field>
|
||||
<van-field required label="线路名称" v-model="directRelativeForm.lineName" maxlength="100" placeholder="请输入线路名称"></van-field>
|
||||
<van-field required label="出行开始" v-model="directRelativeForm.travelStartTime" type="date" placeholder="请选择出行开始日期"></van-field>
|
||||
<van-field required label="出行结束" v-model="directRelativeForm.travelEndTime" type="date" placeholder="请选择出行结束日期"></van-field>
|
||||
</div>
|
||||
|
||||
<div class="tour-apply-card">
|
||||
@@ -365,7 +365,7 @@ layout("/layouts/platform_tour_signup_h5.html"){
|
||||
:title="familyTabTitle(family, familyIndex)">
|
||||
<div class="tour-family-form">
|
||||
<van-field required label="姓名" v-model="family.familyName" maxlength="100" placeholder="请填写姓名"></van-field>
|
||||
<van-field required label="年龄" v-model.number="family.age" type="digit" placeholder="请填写年龄"></van-field>
|
||||
<van-field required label="年龄" v-model.number="family.age" type="number" :formatter="formatFamilyAge" placeholder="请填写年龄"></van-field>
|
||||
<van-field required label="性别">
|
||||
<template #input>
|
||||
<van-radio-group v-model="family.gender" direction="horizontal">
|
||||
@@ -753,6 +753,11 @@ layout("/layouts/platform_tour_signup_h5.html"){
|
||||
isBlankValue(value) {
|
||||
return value === undefined || value === null || !String(value).trim()
|
||||
},
|
||||
// 与 Web 端一致,亲属年龄限定为 0~120 岁整数,清空时保留必填状态。
|
||||
formatFamilyAge(value) {
|
||||
const age = parseInt(value, 10)
|
||||
return Number.isNaN(age) ? "" : String(Math.min(120, Math.max(0, age)))
|
||||
},
|
||||
// 年满16周岁的亲属才强制填写身份证号码和手机号。
|
||||
familyContactRequired(family) {
|
||||
return family && !this.isBlankValue(family.age) && Number(family.age) >= 16
|
||||
|
||||
@@ -255,7 +255,7 @@ layout("/layouts/platform_h5.html"){
|
||||
<van-empty description="暂无数据" v-else></van-empty>
|
||||
</van-action-sheet>-->
|
||||
|
||||
<van-field
|
||||
<!-- <van-field
|
||||
v-model="formData.certifierUserName"
|
||||
name="certifierUserName"
|
||||
label="证明人"
|
||||
@@ -288,7 +288,7 @@ layout("/layouts/platform_h5.html"){
|
||||
</template>
|
||||
</div>
|
||||
<van-empty description="暂无数据" v-else></van-empty>
|
||||
</van-action-sheet>
|
||||
</van-action-sheet> -->
|
||||
|
||||
<van-field
|
||||
v-model="formData.typeName"
|
||||
@@ -322,7 +322,7 @@ layout("/layouts/platform_h5.html"){
|
||||
name="money"
|
||||
label="慰问金额"
|
||||
required
|
||||
:rules="[{ required: true }]"
|
||||
:rules="[{ required: true, message: '请填写慰问金额' }, { validator: validateMoney, message: '慰问金额须大于0且不超过10000元,最多两位小数' }]"
|
||||
placeholder="选择慰问类型自动匹配金额"
|
||||
clickable
|
||||
type="number"
|
||||
@@ -353,17 +353,19 @@ layout("/layouts/platform_h5.html"){
|
||||
</van-popup>
|
||||
|
||||
<van-field
|
||||
v-if="!['A', 'D'].includes(formData.typeCode)"
|
||||
v-model="formData.bankCardNumber"
|
||||
label="收款账户"
|
||||
maxlength="20"
|
||||
maxlength="30"
|
||||
clearable
|
||||
name="bankCardNumber"
|
||||
placeholder="请填写收款账户"
|
||||
:rules="[{ required: true }]"
|
||||
placeholder="请填写银行卡号或对公账户号"
|
||||
:rules="[{ required: true, message: '请填写银行卡号或对公账户号' }, { pattern: /^[0-9]{6,30}$/, message: '收款账户须为6至30位数字(银行卡号或对公账户号)' }]"
|
||||
required
|
||||
></van-field>
|
||||
|
||||
<van-field
|
||||
v-if="!['A', 'D'].includes(formData.typeCode)"
|
||||
v-model="formData.bankUserName"
|
||||
label="户名"
|
||||
placeholder="请填写户名"
|
||||
@@ -373,9 +375,10 @@ layout("/layouts/platform_h5.html"){
|
||||
name="bankUserName"
|
||||
></van-field>
|
||||
<van-field
|
||||
v-if="!['A', 'D'].includes(formData.typeCode)"
|
||||
v-model="formData.bankOfDeposit"
|
||||
label="开户行"
|
||||
maxlength="30"
|
||||
maxlength="20"
|
||||
type="textarea"
|
||||
rows="4"
|
||||
autosize
|
||||
@@ -433,8 +436,8 @@ layout("/layouts/platform_h5.html"){
|
||||
></van-datetime-picker>
|
||||
</van-popup>
|
||||
|
||||
<van-field type="digit" label="当年第几次住院"
|
||||
:rules="[{ required: true }]"
|
||||
<van-field type="digit" label="当年第几次住院" name="thisYearHospitalizationNum"
|
||||
:rules="[{ required: true }, { validator: value => /^[0-9]+$/.test(String(value)) && Number(value) <= 10000, message: '住院次数须为0至10000的整数' }]"
|
||||
required
|
||||
placeholder="请输入当年第几次住院"
|
||||
v-model="formData.thisYearHospitalizationNum"></van-field>
|
||||
@@ -443,6 +446,7 @@ layout("/layouts/platform_h5.html"){
|
||||
<van-field
|
||||
v-model="formData.remark"
|
||||
name="remark"
|
||||
maxlength="500"
|
||||
label="申请事由"
|
||||
type="textarea"
|
||||
rows="4"
|
||||
@@ -460,15 +464,18 @@ layout("/layouts/platform_h5.html"){
|
||||
{{ '附件说明:' + chooseType.uploadFileDesc }}
|
||||
</div>
|
||||
<van-cell-group :border="false">
|
||||
<van-field class="direction-column-field" name="avatar" label="">
|
||||
<van-field class="direction-column-field" name="files" label="附件" required
|
||||
:rules="[{ validator: () => Array.isArray(formData.files) && formData.files.length > 0 && formData.files.length <= 5, message: '请上传1至5个附件' }]">
|
||||
<template #input>
|
||||
<h5-file-upload
|
||||
slot="input"
|
||||
:value.sync="formData.files"
|
||||
:upload_number="5"
|
||||
upload_text="上传附件"
|
||||
upload_result_category="array"
|
||||
complete_result
|
||||
></h5-file-upload>
|
||||
<div class="condolence-card__desc">最多上传 5 个文件</div>
|
||||
</template>
|
||||
</van-field>
|
||||
</van-cell-group>
|
||||
@@ -477,7 +484,8 @@ layout("/layouts/platform_h5.html"){
|
||||
<section class="form-section">
|
||||
<h2 class="condolence-card__title">签字确认</h2>
|
||||
<van-cell-group :border="false">
|
||||
<van-field class="direction-column-field" name="signature" label="">
|
||||
<van-field class="direction-column-field" name="signature" label="" :value="formData.signature"
|
||||
:rules="[{ required: true, message: '请填写签字' }]">
|
||||
<template #input>
|
||||
<h5-signature v-model="formData.signature" slot="input"></h5-signature>
|
||||
</template>
|
||||
@@ -532,6 +540,9 @@ layout("/layouts/platform_h5.html"){
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
validateMoney(value) {
|
||||
return /^(0|[1-9][0-9]*)(\.[0-9]{1,2})?$/.test(String(value)) && Number(value) > 0 && Number(value) <= 10000
|
||||
},
|
||||
// 选择出院时间
|
||||
showLeaveHospitalTimePickerClick() {
|
||||
if (this.formData.leaveHospitalTime) {
|
||||
|
||||
+1
-1
@@ -151,7 +151,7 @@ layout("/layouts/platform_h5.html"){
|
||||
<h2 class="condolence-info-card__title">
|
||||
审批签字<span class="condolence-audit-card__required">*</span>
|
||||
</h2>
|
||||
<van-field name="tf_sign" :value="formData.tf_sign" required :rules="[{required:true,message:'请完成审批签字'}]">
|
||||
<van-field name="tf_sign" :value="formData.tf_sign" :rules="[{required:true,message:'请完成审批签字'}]">
|
||||
<template #input><h5-signature v-model="formData.tf_sign" :key="formData.processTaskId"></h5-signature></template>
|
||||
</van-field>
|
||||
</van-form>
|
||||
|
||||
@@ -158,19 +158,44 @@
|
||||
}
|
||||
|
||||
.condolence-info-long-cell {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.condolence-info-card .condolence-attachments .empty-container {
|
||||
height: auto;
|
||||
justify-content: flex-start;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.condolence-info-card .condolence-attachments .empty-text {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.condolence-info-opinion > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.condolence-info-opinion > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.condolence-signature-image,
|
||||
.condolence-approval-signature-image {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.condolence-info-long-cell .van-cell__title {
|
||||
width: auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.condolence-info-long-cell .van-cell__value {
|
||||
margin-top: 7px;
|
||||
.condolence-signature-image .van-image__img,
|
||||
.condolence-approval-signature-image {
|
||||
object-fit: contain;
|
||||
object-position: left center;
|
||||
}
|
||||
|
||||
.condolence-signature-image {
|
||||
display: block;
|
||||
.condolence-info-opinion img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@@ -25,13 +25,13 @@ const condolenceInfo = {
|
||||
<van-cell title="申请人单位">{{ viewData.applyUnitName || '--' }}</van-cell>
|
||||
<van-cell title="申请人工会">{{ viewData.applyUnionName || '--' }}</van-cell>
|
||||
<van-cell title="慰问对象">{{ viewData.helpUserName || '--' }}</van-cell>
|
||||
<van-cell title="证明人">{{ viewData.certifierUserName || '--' }}</van-cell>
|
||||
<!-- <van-cell title="证明人">{{ viewData.certifierUserName || '--' }}</van-cell> -->
|
||||
<van-cell title="慰问类型">{{ viewData.typeName || '--' }}</van-cell>
|
||||
<van-cell title="慰问金额">{{ viewData.money || '--' }}</van-cell>
|
||||
<van-cell title="慰问时间">{{ viewData.occurTime || '--' }}</van-cell>
|
||||
<van-cell title="收款账户">{{ viewData.bankCardNumber || '--' }}</van-cell>
|
||||
<van-cell title="户名">{{ viewData.bankUserName || '--' }}</van-cell>
|
||||
<van-cell class="condolence-info-long-cell" title="开户行">
|
||||
<van-cell title="开户行">
|
||||
{{ viewData.bankOfDeposit || '暂无' }}
|
||||
</van-cell>
|
||||
<template v-if="['2'].includes(viewData.typeCode)">
|
||||
@@ -39,17 +39,18 @@ const condolenceInfo = {
|
||||
<van-cell title="出院时间">{{ viewData.leaveHospitalTime || '--' }}</van-cell>
|
||||
<van-cell title="当年第几次住院(次)">{{ viewData.thisYearHospitalizationNum || '--' }}</van-cell>
|
||||
</template>
|
||||
<van-cell class="condolence-info-long-cell" title="申请事由">
|
||||
<van-cell title="申请事由">
|
||||
{{ viewData.remark || '暂无' }}
|
||||
</van-cell>
|
||||
<van-cell class="condolence-info-long-cell" title="附件">
|
||||
<file-preview :files="viewData.files" complete_result></file-preview>
|
||||
<file-preview class="condolence-attachments" :files="viewData.files" complete_result></file-preview>
|
||||
</van-cell>
|
||||
<van-cell class="condolence-info-long-cell" title="签字">
|
||||
<van-image
|
||||
v-if="viewData.signature"
|
||||
:src="viewData.signature"
|
||||
class="condolence-signature-image"
|
||||
fit="contain"
|
||||
></van-image>
|
||||
<span v-else>暂无签字</span>
|
||||
</van-cell>
|
||||
@@ -86,10 +87,10 @@ const condolenceInfo = {
|
||||
></dict-tag>
|
||||
</van-cell>
|
||||
<van-cell v-if="task.taskFormData && task.taskFormData.sign" title="审批签字">
|
||||
<img :src="task.taskFormData.sign" style="max-width: 100%; height: 80px; object-fit: contain">
|
||||
<img :src="task.taskFormData.sign" class="condolence-approval-signature-image">
|
||||
</van-cell>
|
||||
<van-cell class="condolence-info-long-cell" title="办理意见">
|
||||
<div v-html="task.taskFormData && task.taskFormData.tf_opinion || '暂无'"></div>
|
||||
<van-cell title="办理意见">
|
||||
<div class="condolence-info-opinion" v-html="task.taskFormData && task.taskFormData.tf_opinion || '暂无'"></div>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
</section>
|
||||
|
||||
+18
-1
@@ -151,7 +151,7 @@ layout("/layouts/platform_h5.html"){
|
||||
<h2 class="condolence-info-card__title">
|
||||
审批签字<span class="condolence-audit-card__required">*</span>
|
||||
</h2>
|
||||
<van-field name="tf_sign" :value="formData.tf_sign" required :rules="[{required:true,message:'请完成审批签字'}]">
|
||||
<van-field name="tf_sign" :value="formData.tf_sign" :rules="[{required:true,message:'请完成审批签字'}]">
|
||||
<template #input><h5-signature v-model="formData.tf_sign" :key="formData.processTaskId"></h5-signature></template>
|
||||
</van-field>
|
||||
</van-form>
|
||||
@@ -223,6 +223,23 @@ layout("/layouts/platform_h5.html"){
|
||||
},
|
||||
onReady() {
|
||||
this.queryCondolenceType()
|
||||
this.openTodoTask()
|
||||
},
|
||||
openTodoTask() {
|
||||
const taskId = GetQueryString("taskId")
|
||||
const bizId = GetQueryString("bizId")
|
||||
if (!taskId || !bizId) return
|
||||
this.$axios.post("/flow/common/taskInfo", {taskId}).then((res) => {
|
||||
if (res.code !== 0 || !res.data) return
|
||||
const task = res.data
|
||||
if (task.taskName !== "cbd41337-3c87-4959-9027-809df5c7ef5e") return
|
||||
const row = {id: bizId, taskId: task.id, curTaskName: task.displayName}
|
||||
if (task.taskState === 10 && task.executable && GetQueryString("tab") === "todo") {
|
||||
this.onApproval(row)
|
||||
} else {
|
||||
this.onView(row)
|
||||
}
|
||||
})
|
||||
},
|
||||
onView(row) {
|
||||
this.$set(this, "showApprovalForm", false)
|
||||
|
||||
+1
-1
@@ -151,7 +151,7 @@ layout("/layouts/platform_h5.html"){
|
||||
<h2 class="condolence-info-card__title">
|
||||
审批签字<span class="condolence-audit-card__required">*</span>
|
||||
</h2>
|
||||
<van-field name="tf_sign" :value="formData.tf_sign" required :rules="[{required:true,message:'请完成审批签字'}]">
|
||||
<van-field name="tf_sign" :value="formData.tf_sign" :rules="[{required:true,message:'请完成审批签字'}]">
|
||||
<template #input><h5-signature v-model="formData.tf_sign" :key="formData.processTaskId"></h5-signature></template>
|
||||
</van-field>
|
||||
</van-form>
|
||||
|
||||
+2
-2
@@ -55,7 +55,7 @@ layout("/layouts/platform_h5.html"){
|
||||
|
||||
<div id="app">
|
||||
<div class="condolence-list-page">
|
||||
<van-nav-bar title="分工会主席审批" left-text="返回" left-arrow @click-left="historyBack" fixed placeholder></van-nav-bar>
|
||||
<van-nav-bar title="校工会主席审批" left-text="返回" left-arrow @click-left="historyBack" fixed placeholder></van-nav-bar>
|
||||
|
||||
<van-sticky offset-top="46px">
|
||||
<div class="condolence-list-sticky">
|
||||
@@ -151,7 +151,7 @@ layout("/layouts/platform_h5.html"){
|
||||
<h2 class="condolence-info-card__title">
|
||||
审批签字<span class="condolence-audit-card__required">*</span>
|
||||
</h2>
|
||||
<van-field name="tf_sign" :value="formData.tf_sign" required :rules="[{required:true,message:'请完成审批签字'}]">
|
||||
<van-field name="tf_sign" :value="formData.tf_sign" :rules="[{required:true,message:'请完成审批签字'}]">
|
||||
<template #input><h5-signature v-model="formData.tf_sign" :key="formData.processTaskId"></h5-signature></template>
|
||||
</van-field>
|
||||
</van-form>
|
||||
|
||||
+26
-9
@@ -9,7 +9,7 @@ layout("/layouts/platform_h5.html"){
|
||||
</style>
|
||||
<div id="app">
|
||||
<div class="difficult-help-approval-page">
|
||||
<van-nav-bar title="帮扶分工会审核" left-text="返回" left-arrow @click-left="historyBack" placeholder fixed></van-nav-bar>
|
||||
<van-nav-bar title="校工会办公室主任审批" left-text="返回" left-arrow @click-left="historyBack" placeholder fixed></van-nav-bar>
|
||||
<van-sticky offset-top="46px">
|
||||
<div class="difficult-help-approval-sticky">
|
||||
<van-search
|
||||
@@ -29,7 +29,7 @@ layout("/layouts/platform_h5.html"){
|
||||
:close-on-click-overlay="false"
|
||||
>
|
||||
<van-dropdown-item
|
||||
v-if="$auth.hasRoleOr('SYSADMIN,SCHOOL_UNION_ADMIN,BRANCH_UNION_CHAIRMAN')"
|
||||
v-if="filterUserReady && $auth.hasRoleOr('SYSADMIN,SCHOOL_UNION_ADMIN,BRANCH_UNION_CHAIRMAN')"
|
||||
:options="unionList"
|
||||
@change="unionChange"
|
||||
@open="onFilterOpen"
|
||||
@@ -37,7 +37,7 @@ layout("/layouts/platform_h5.html"){
|
||||
v-model="pageForm.unionId">
|
||||
</van-dropdown-item>
|
||||
<van-dropdown-item
|
||||
v-if="$auth.hasRoleOr('SYSADMIN,SCHOOL_UNION_ADMIN,BRANCH_UNION_CHAIRMAN')"
|
||||
v-if="filterUserReady && $auth.hasRoleOr('SYSADMIN,SCHOOL_UNION_ADMIN,BRANCH_UNION_CHAIRMAN')"
|
||||
:options="unitList"
|
||||
@change="doSearch"
|
||||
@open="onFilterOpen"
|
||||
@@ -228,6 +228,14 @@ layout("/layouts/platform_h5.html"){
|
||||
components: {
|
||||
"info": INFO
|
||||
},
|
||||
computed: {
|
||||
// 首次进入时用户接口可能尚未返回;非管理员还需所属工会,避免空对象报错或请求全部工会。
|
||||
filterUserReady() {
|
||||
const user = this.$store.state.user
|
||||
if (!user || !Array.isArray(user.roles)) return false
|
||||
return this.$auth.hasRoleOr(["SYSADMIN", "SCHOOL_UNION_ADMIN"]) || !!(user.union && user.union.id)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
historyBack,
|
||||
|
||||
@@ -419,13 +427,22 @@ layout("/layouts/platform_h5.html"){
|
||||
},
|
||||
},
|
||||
created() {
|
||||
// 先订阅弹框状态,工会加载异常也不能阻断等级选择器与确认框的打开、返回。
|
||||
if (window.h5HistoryLayerManager) {
|
||||
this.$set(this, "historyLayerUnsubscribe", window.h5HistoryLayerManager.subscribe((stack) => {
|
||||
this.$set(this, "confirmVisible", stack.includes("difficult-help-action-confirm"))
|
||||
this.$set(this, "showLevelPicker", stack.includes("difficult-help-level-picker"))
|
||||
}))
|
||||
}
|
||||
this.initLevelDict()
|
||||
this.initUnion().then(() => this.flushUnits())
|
||||
if (!window.h5HistoryLayerManager) return
|
||||
this.$set(this, "historyLayerUnsubscribe", window.h5HistoryLayerManager.subscribe((stack) => {
|
||||
this.$set(this, "confirmVisible", stack.includes("difficult-help-action-confirm"))
|
||||
this.$set(this, "showLevelPicker", stack.includes("difficult-help-level-picker"))
|
||||
}))
|
||||
// 缓存已就绪时立即加载,否则等待用户接口回填;只初始化一次,避免重置已选筛选条件。
|
||||
// $watch 由当前 Vue 实例管理,页面销毁时自动注销。
|
||||
let filtersInitialized = false
|
||||
this.$watch("filterUserReady", (ready) => {
|
||||
if (!ready || filtersInitialized) return
|
||||
filtersInitialized = true
|
||||
this.initUnion().then(() => this.flushUnits())
|
||||
}, {immediate: true})
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (typeof this.historyLayerUnsubscribe === "function") this.historyLayerUnsubscribe()
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ layout("/layouts/platform_h5.html"){
|
||||
</style>
|
||||
<div id="app">
|
||||
<div class="difficult-help-approval-page">
|
||||
<van-nav-bar title="帮扶校工会审核" left-text="返回" left-arrow @click-left="historyBack" placeholder fixed></van-nav-bar>
|
||||
<van-nav-bar title="分管校领导审批" left-text="返回" left-arrow @click-left="historyBack" placeholder fixed></van-nav-bar>
|
||||
<van-sticky offset-top="46px">
|
||||
<div class="difficult-help-approval-sticky">
|
||||
<van-search
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ layout("/layouts/platform_h5.html"){
|
||||
</style>
|
||||
<div id="app">
|
||||
<div class="difficult-help-approval-page">
|
||||
<van-nav-bar title="帮扶二级党组织审核" left-text="返回" left-arrow @click-left="historyBack" placeholder fixed></van-nav-bar>
|
||||
<van-nav-bar title="校工会主席审批" left-text="返回" left-arrow @click-left="historyBack" placeholder fixed></van-nav-bar>
|
||||
<van-sticky offset-top="46px">
|
||||
<div class="difficult-help-approval-sticky">
|
||||
<van-search
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
const apps = {
|
||||
// 从“我的工具”进入时指定分类名称;空值表示恢复应用中心原有浏览状态。
|
||||
props: {
|
||||
initialCategoryName: {type: String, default: ""}
|
||||
},
|
||||
template: /*language=HTML*/ `
|
||||
<div class="apps-container">
|
||||
<van-nav-bar title="应用中心" placeholder fixed></van-nav-bar>
|
||||
@@ -201,7 +205,8 @@ const apps = {
|
||||
},
|
||||
mounted() {
|
||||
// 动态分类加载完成后再恢复分类,避免把缓存的动态分类误判为“全部应用”
|
||||
this.restoreAppsState()
|
||||
// 指定入口不恢复旧搜索词、滚动位置及子菜单弹框,避免目标分类被旧状态遮挡。
|
||||
if (!this.initialCategoryName) this.restoreAppsState()
|
||||
this.loadCategories().finally(() => {
|
||||
this.restoreActiveCategory()
|
||||
this.loadApps()
|
||||
@@ -249,6 +254,13 @@ const apps = {
|
||||
},
|
||||
|
||||
restoreActiveCategory() {
|
||||
// 动态分类返回后按名称定位;未配置或无可用分类时明确提示,并展示全部应用。
|
||||
if (this.initialCategoryName) {
|
||||
const targetIndex = this.allCategories.findIndex(item => item.name === this.initialCategoryName)
|
||||
this.$set(this, "activeTab", targetIndex >= 0 ? targetIndex : 0)
|
||||
if (targetIndex < 0) this.$toast("未找到“" + this.initialCategoryName + "”分类")
|
||||
return
|
||||
}
|
||||
const index = this.allCategories.findIndex(item => String(item.id) === String(this.cachedCategoryId))
|
||||
this.activeTab = index >= 0 ? index : 0
|
||||
},
|
||||
|
||||
@@ -73,7 +73,7 @@ layout("/layouts/platform_h5.html"){
|
||||
<todo v-if="homeTabbarActive === 'todo'" @todo-count-change="updateTodoCount"></todo>
|
||||
<work v-if="homeTabbarActive === 'work'"></work>
|
||||
<mine v-if="homeTabbarActive === 'mine'" @switch-tab="switchHomeTab"></mine>
|
||||
<apps v-if="homeTabbarActive === 'apps'"></apps>
|
||||
<apps v-if="homeTabbarActive === 'apps'" :initial-category-name="appsCategoryName"></apps>
|
||||
<msg v-if="homeTabbarActive === 'msg'" @unread-count-change="updateUnreadMessageCount"></msg>
|
||||
</div>
|
||||
|
||||
@@ -111,19 +111,25 @@ layout("/layouts/platform_h5.html"){
|
||||
data() {
|
||||
return {
|
||||
homeTabbarActive: "home",
|
||||
appsCategoryName: "",
|
||||
todoCount: 0,
|
||||
unreadMessageCount: 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
homeTabbarChange(val) {
|
||||
// 普通页签切换沿用应用页缓存,不保留工具入口指定的分类。
|
||||
this.$set(this, "appsCategoryName", "")
|
||||
this.$store.commit("setActiveTarBar", val)
|
||||
},
|
||||
switchHomeTab(val) {
|
||||
this.homeTabbarActive = val
|
||||
// categoryName 来自“我的工具”,先传分类再挂载应用页,避免读取上一次的分类缓存。
|
||||
switchHomeTab(val, categoryName = "") {
|
||||
this.$set(this, "appsCategoryName", val === "apps" ? categoryName : "")
|
||||
this.$set(this, "homeTabbarActive", val)
|
||||
this.$store.commit("setActiveTarBar", val)
|
||||
},
|
||||
moduleClick(val) {
|
||||
this.$set(this, "appsCategoryName", "")
|
||||
this.homeTabbarActive = "apps"
|
||||
this.$store.commit("setActiveTarBar", "apps")
|
||||
},
|
||||
|
||||
@@ -74,22 +74,23 @@ const mine = {
|
||||
<van-icon name="edit"></van-icon>
|
||||
<span>我的签名</span>
|
||||
</button>
|
||||
<button type="button" class="mine-grid-item" @click="switchTab('apps')">
|
||||
<button type="button" class="mine-grid-item" @click="switchTab('apps', '我的收藏')">
|
||||
<van-icon name="star-o"></van-icon>
|
||||
<span>我的收藏</span>
|
||||
</button>
|
||||
<button type="button" class="mine-grid-item" @click="switchTab('apps')">
|
||||
<button type="button" class="mine-grid-item" @click="switchTab('apps', '职工福利')">
|
||||
<van-icon name="gift-o"></van-icon>
|
||||
<span>福利中心</span>
|
||||
</button>
|
||||
<button type="button" class="mine-grid-item" @click="switchTab('apps')">
|
||||
<button type="button" class="mine-grid-item" @click="switchTab('apps', '活动管理')">
|
||||
<van-icon name="calendar-o"></van-icon>
|
||||
<span>活动报名</span>
|
||||
</button>
|
||||
<button type="button" class="mine-grid-item" @click="switchTab('apps')">
|
||||
<button type="button" class="mine-grid-item" @click="$pjaxReplace('/platform/h5/qsv')">
|
||||
<van-icon name="completed-o"></van-icon>
|
||||
<span>问卷调查</span>
|
||||
</button>
|
||||
<!-- 暂不开放设置中心和帮助中心,保留入口模板便于恢复。
|
||||
<button type="button" class="mine-grid-item" @click="switchTab('apps')">
|
||||
<van-icon name="setting-o"></van-icon>
|
||||
<span>设置中心</span>
|
||||
@@ -98,6 +99,7 @@ const mine = {
|
||||
<van-icon name="question-o"></van-icon>
|
||||
<span>帮助中心</span>
|
||||
</button>
|
||||
-->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -150,9 +152,10 @@ const mine = {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
switchTab(tab) {
|
||||
// tab 为底部页签,categoryName 为可选应用分类名称;通过事件交给首页切换并定位分类。
|
||||
switchTab(tab, categoryName = "") {
|
||||
this.$store.commit("setActiveTarBar", tab)
|
||||
this.$emit("switch-tab", tab)
|
||||
this.$emit("switch-tab", tab, categoryName)
|
||||
},
|
||||
logout() {
|
||||
this.$dialog
|
||||
|
||||
@@ -281,11 +281,13 @@ const todo = {
|
||||
// 处理任务
|
||||
onView(task) {
|
||||
const {taskId, taskKey, instanceId, businessNo, formKey, h5FormKey} = task;
|
||||
if (!h5FormKey) {
|
||||
const mobileFormKey = h5FormKey || (taskKey === "cbd41337-3c87-4959-9027-809df5c7ef5e"
|
||||
? "/platform/condolence/fourthApproval/h5" : "");
|
||||
if (!mobileFormKey) {
|
||||
this.$toast("当前流程暂不支持手机端审核,请前往PC端页面审核!");
|
||||
return;
|
||||
}
|
||||
this.$pjaxReplace(h5FormKey + "?taskId=" + taskId + "&bizId=" + businessNo + "&taskKey=" + taskKey + "&tab=" + this.activeTab)
|
||||
this.$pjaxReplace(mobileFormKey + "?taskId=" + taskId + "&bizId=" + businessNo + "&taskKey=" + taskKey + "&tab=" + this.activeTab)
|
||||
},
|
||||
|
||||
// 根据当前列表类型统一展示流程状态,发起列表保留流程实例的实际状态。
|
||||
|
||||
Reference in New Issue
Block a user