From be401ab0f0cdf5926b28f485b08b99f3a9b6bee6 Mon Sep 17 00:00:00 2001 From: zhouhefeng Date: Thu, 22 Oct 2026 08:47:43 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AD=BE=E5=AD=97=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/SignatureController.java | 229 ++++++++++++------ .../v/nutz/zhgh/mobile/common/updateSign.java | 57 +++-- .../static/components/sign/mobileSign.vue | 119 +++++++-- .../resources/static/components/sign/sign.vue | 3 +- src/main/resources/views/mobile/home.html | 7 +- .../jf/activitybx/association/index.html | 4 +- .../mobile/jf/activitybx/association/info.js | 104 -------- .../mobile/jf/activitybx/association/page.js | 2 +- .../views/mobile/jf/activitybx/review/info.js | 171 +++++++++---- .../mobile/jf/reimbursement/review/info.js | 108 +++++++-- .../views/platform/common/signature.html | 15 +- 11 files changed, 530 insertions(+), 289 deletions(-) delete mode 100644 src/main/resources/views/mobile/jf/activitybx/association/info.js diff --git a/src/main/java/io/v/nutz/web/commons/controller/SignatureController.java b/src/main/java/io/v/nutz/web/commons/controller/SignatureController.java index eec3d14..e4f1239 100644 --- a/src/main/java/io/v/nutz/web/commons/controller/SignatureController.java +++ b/src/main/java/io/v/nutz/web/commons/controller/SignatureController.java @@ -2,16 +2,24 @@ package io.v.nutz.web.commons.controller; import cn.hutool.core.codec.Base64; import cn.hutool.core.date.DateUtil; -import cn.hutool.core.io.IoUtil; import cn.wizzer.framework.base.Result; -import com.aliyun.core.utils.Base64Util; import io.v.nutz.sys.models.Sys_file; +import io.v.nutz.web.commons.utils.ShiroUtil; +import io.v.nutz.zhgh.zgfw.model.UserSign; import org.apache.shiro.authz.annotation.RequiresAuthentication; +import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.boot.starter.ftp.FtpService; +import org.nutz.dao.Chain; +import org.nutz.dao.Cnd; import org.nutz.dao.Dao; +import org.nutz.dao.Sqls; +import org.nutz.dao.sql.Sql; import org.nutz.integration.jedis.RedisService; +import org.nutz.ioc.Ioc; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.lang.Strings; import org.nutz.lang.random.R; import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; @@ -19,6 +27,7 @@ import org.nutz.mvc.annotation.Ok; import javax.servlet.http.HttpServletResponse; import java.io.ByteArrayInputStream; import java.io.InputStream; +import java.util.List; /** * TODO @@ -30,15 +39,23 @@ import java.io.InputStream; @At("/signature") public class SignatureController { - @Inject - private Dao dao; - @Inject private RedisService redisService; + // 服务由本类工厂注册,按需获取避免控制器与工厂产物形成循环注入。 + @Inject("refer:$ioc") + private Ioc ioc; + @Inject private FtpService ftpService; + /** 当前 Nutz 不扫描内部类,通过同文件的工厂方法注册个人签名服务及其依赖。 */ + @IocBean(name = "personalSignatureService") + public PersonalSignatureServiceImpl createPersonalSignatureService(@Inject("dao") Dao dao, + @Inject("ftpService") FtpService ftp, @Inject("redisService") RedisService redis) { + return new PersonalSignatureServiceImpl(dao, ftp, redis); + } + @At("") @Ok("re") @RequiresAuthentication @@ -47,48 +64,21 @@ public class SignatureController { } /** - * 移动端提交签名数据存到redis里 - * - * @param prefix 前缀 - * @param id 唯一id - * @param base64 - * @return + * prefix/id 为二维码对应的轮询标识;base64 兼容手写图片数据及当前用户已保存的文件路径。 + * is_value_base64 默认 true,false 时返回文件路径给 PC;返回 {code,msg,data},data 为已保存签名。 */ @At @Ok("json") @RequiresAuthentication + @Aop(TransAop.READ_COMMITTED) public Object doSub(String prefix, String id, String base64, Boolean is_value_base64) { - if (is_value_base64) { - redisService.setex(prefix + ":" + id, 60 * 10, base64); - } else { - String today = DateUtil.today(); - String[] split = today.split("-"); - String todayPath = "/signature/" + split[0] + "/" + split[1] + "/" + split[2]; - String fileName = R.UU32() + ".png"; - String fullPath = todayPath + "/" + fileName; - - String replaceData = base64.replaceAll("data:image/png;base64,", ""); - - try (InputStream is = new ByteArrayInputStream(Base64.decode(replaceData))) { - boolean uploadRes = ftpService.upload(todayPath, fileName, is); - if (uploadRes) { - //上传成功记录到文件表 - Sys_file sysFile = new Sys_file(); - sysFile.setFilename(fileName); - sysFile.setFilepath(fullPath); - sysFile.setSource(null); - dao.insert(sysFile); - redisService.setex(prefix + ":" + id, 60 * 10, fullPath); - } else { - throw new RuntimeException("上传文件失败"); - } - } catch (Exception e) { - return Result.error("签字数据上传失败"); - } + if (Strings.isBlank(prefix) || Strings.isBlank(id) || Strings.isBlank(base64)) { + return Result.error("缺少签字参数,请重新扫码"); } - - - return Result.success(); + String sign = ioc.get(PersonalSignatureService.class, "personalSignatureService") + .submitScan(ShiroUtil.getPrincipalProperty("id").toString(), + prefix, id, base64, !Boolean.FALSE.equals(is_value_base64)); + return Result.success().addData(sign); } @@ -108,40 +98,15 @@ public class SignatureController { /** - * 签字base64转图片链接 - * - * @param data - * @return + * data 为 data:image/...;base64,... 图片字符串;返回 {code,msg,data},data 是上传后的文件路径。 */ @At @Ok("json") @RequiresAuthentication + @Aop(TransAop.READ_COMMITTED) public Result uploadSignData(String data) { - String today = DateUtil.today(); - String[] split = today.split("-"); - String todayPath = "/signature/" + split[0] + "/" + split[1] + "/" + split[2]; - String fileName = R.UU32() + ".png"; - String fullPath = todayPath + "/" + fileName; - - String replaceData = data.replaceAll("data:image/png;base64,", ""); - - try (InputStream is = new ByteArrayInputStream(Base64.decode(replaceData))) { - boolean uploadRes = ftpService.upload(todayPath, fileName, is); - if (uploadRes) { - //上传成功记录到文件表 - Sys_file sysFile = new Sys_file(); - sysFile.setFilename(fileName); - sysFile.setFilepath(fullPath); - sysFile.setSource(null); - dao.insert(sysFile); - } else { - throw new RuntimeException("上传文件失败"); - } - } catch (Exception e) { - return Result.error("签字数据上传失败"); - } - - return Result.success().addData(fullPath); + if (Strings.isBlank(data)) return Result.error("请先签字"); + return Result.success().addData(ioc.get(PersonalSignatureService.class, "personalSignatureService").uploadImage(data)); } @@ -158,4 +123,128 @@ public class SignatureController { } } + /** 个人签名服务与控制器同文件定义,查询、上传和按类型保存由独立 IoC 服务承接。 */ + public interface PersonalSignatureService { + /** userId 为登录用户ID;base64 为签名类型;返回同类型签名原值,未找到返回 null。 */ + String findPersonal(String userId, boolean base64); + + /** sign 为手写 Base64 或本人已有文件路径;按 base64 类型保存,返回实际保存值。 */ + String savePersonal(String userId, String sign, boolean base64); + + /** data 为带图片类型头的 Base64;上传并记录文件信息,返回文件路径。 */ + String uploadImage(String data); + + /** prefix/id 对应 PC 轮询;保存本人签名后发布结果,返回与 base64 参数一致的数据格式。 */ + String submitScan(String userId, String prefix, String id, String sign, boolean base64); + } + + public static class PersonalSignatureServiceImpl implements PersonalSignatureService { + private Dao dao; + private FtpService ftpService; + private RedisService redisService; + + public PersonalSignatureServiceImpl(Dao dao, FtpService ftpService, RedisService redisService) { + this.dao = dao; + this.ftpService = ftpService; + this.redisService = redisService; + } + + /** 只把有明确图片数据头的内容归为 Base64,空值和异常内容不冒充文件签名。 */ + private boolean isBase64(String sign) { + return sign != null && sign.matches("^data:image/(png|jpeg|jpg|gif|webp);base64,[A-Za-z0-9+/]+={0,2}$"); + } + + /** 文件模式使用站内存储路径;排除 URL、路径穿越和图片数据字符串。 */ + private boolean isFile(String sign) { + return sign != null && sign.startsWith("/") && !sign.startsWith("//") + && !sign.contains("..") && !sign.contains("?") && !sign.contains("#") + && !sign.contains("\\") && !sign.contains("\n") && !sign.contains("\r"); + } + + /** 同类型历史多条按 ID 固定选择,优先公共记录;更新时保留该记录原有 prefix,不批量覆盖。 */ + private UserSign findRecord(String userId, boolean base64) { + if (Strings.isBlank(userId)) throw new IllegalArgumentException("请先登录"); + List records = dao.query(UserSign.class, Cnd.where("user_id", "=", userId).asc("id")); + UserSign first = null; + for (UserSign record : records) { + if (!(base64 ? isBase64(record.getData()) : isFile(record.getData()))) continue; + if (Strings.isBlank(record.getPrefix())) return record; + if (first == null) first = record; + } + return first; + } + + @Override + public String findPersonal(String userId, boolean base64) { + UserSign record = findRecord(userId, base64); + return record == null ? null : record.getData(); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public String savePersonal(String userId, String sign, boolean base64) { + if (Strings.isBlank(userId) || Strings.isBlank(sign)) throw new IllegalArgumentException("请先登录并签字"); + // 锁定已有用户行,使同一用户的首次并发保存串行执行,无需新增类型字段或唯一索引。 + Sql lock = Sqls.create("SELECT id FROM sys_user WHERE id=@id FOR UPDATE").setParam("id", userId); + lock.setCallback(Sqls.callback.map()); + dao.execute(lock); + if (lock.getResult() == null) throw new IllegalArgumentException("当前用户不存在"); + + String saved; + if (base64) { + if (!isBase64(sign)) throw new IllegalArgumentException("当前页面需要 Base64 签名,请重新签字"); + saved = sign; + } else if (isBase64(sign)) { + // 这里只上传本次新手写的图片;查询个人签名时不转换另一种类型的历史记录。 + saved = uploadImage(sign); + } else { + if (!isFile(sign) || dao.fetch(UserSign.class, Cnd.where("user_id", "=", userId) + .and("data", "=", sign)) == null) { + throw new IllegalArgumentException("请使用本人的文件签名或重新签字"); + } + saved = sign; + } + UserSign record = findRecord(userId, base64); + if (record == null) { + dao.insert(new UserSign(userId, saved)); + } else if (!saved.equals(record.getData())) { + dao.update(UserSign.class, Chain.make("data", saved), Cnd.where("id", "=", record.getId()) + .and("user_id", "=", userId)); + } + return saved; + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public String uploadImage(String data) { + if (!isBase64(data)) throw new IllegalArgumentException("签字图片格式不正确"); + String type = data.substring("data:image/".length(), data.indexOf(';')); + String directory = "/signature/" + DateUtil.format(new java.util.Date(), "yyyy/MM/dd"); + String filename = R.UU32() + "." + ("jpeg".equals(type) ? "jpg" : type); + String path = directory + "/" + filename; + byte[] bytes = Base64.decode(data.substring(data.indexOf(',') + 1)); + try (InputStream stream = new ByteArrayInputStream(bytes)) { + if (!ftpService.upload(directory, filename, stream)) throw new IllegalStateException("签字图片上传失败"); + } catch (Exception e) { + throw new IllegalStateException("签字图片上传失败,请重试", e); + } + Sys_file file = new Sys_file(); + file.setFilename(filename); + file.setFilepath(path); + file.setSource(null); + dao.insert(file); + return path; + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public String submitScan(String userId, String prefix, String id, String sign, boolean base64) { + if (Strings.isBlank(prefix) || Strings.isBlank(id)) throw new IllegalArgumentException("请重新扫码"); + // 新版组件已保存个人签名,文件路径直接复用;旧扫码客户端仍可提交原始手写图片。 + String saved = savePersonal(userId, sign, base64); + redisService.setex(prefix + ":" + id, 60 * 10, saved); + return saved; + } + } + } diff --git a/src/main/java/io/v/nutz/zhgh/mobile/common/updateSign.java b/src/main/java/io/v/nutz/zhgh/mobile/common/updateSign.java index dd17d89..cb45108 100644 --- a/src/main/java/io/v/nutz/zhgh/mobile/common/updateSign.java +++ b/src/main/java/io/v/nutz/zhgh/mobile/common/updateSign.java @@ -1,21 +1,17 @@ package io.v.nutz.zhgh.mobile.common; -import cn.hutool.core.util.StrUtil; -import io.v.nutz.base.annontation.ViReturn; import io.v.nutz.base.result.Result; -import io.v.nutz.base.service.ViService; +import io.v.nutz.web.commons.controller.SignatureController.PersonalSignatureService; import io.v.nutz.web.commons.utils.ShiroUtil; -import io.v.nutz.zhgh.zgfw.model.UserSign; -import org.nutz.dao.Chain; -import org.nutz.dao.Cnd; +import org.apache.shiro.authz.annotation.RequiresAuthentication; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.Strings; import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; -import java.util.List; - /** * @author: Aaron * @create: 2020-11-25 11:29 @@ -26,26 +22,41 @@ import java.util.List; @Ok("json:full") public class updateSign { - @Inject("UserSign") - private ViService userSignService; + @Inject("personalSignatureService") + private PersonalSignatureService personalSignatureService; - @At + /** + * 兼容旧 Java 调用:sign 为带图片数据头的 Base64,user_id 为空或当前登录人ID。 + * 默认按 Base64 类型保存;返回 Result,code=0 表示成功,data 为保存后的签名原值。 + * 此重载仅用于内部调用,不标注 @At,HTTP 请求仍使用三参数入口。 + */ + @RequiresAuthentication + @Aop(TransAop.READ_COMMITTED) public Result update(String sign, String user_id) { - if(StrUtil.isBlank(user_id)){ - user_id = (String) ShiroUtil.getPrincipalProperty("id"); - } - if (Strings.isNotBlank(getMySign())) { - userSignService.update(Chain.make("data", sign), Cnd.where("user_id", "=", user_id)); - } else { - userSignService.insert(new UserSign(user_id, sign)); - } - return Result.success(); + return update(sign, user_id, Boolean.TRUE); } + /** + * sign 为新手写 Base64 或本人已有文件路径;is_value_base64 默认 true,false 保存文件类型。 + * user_id 兼容旧参数但只允许当前登录人;返回 {code,msg,data},data 为保存后的签名原值。 + */ @At - public String getMySign() { - List userSigns = userSignService.query(Cnd.NEW().and("user_id", "=", ShiroUtil.getPrincipalProperty("id"))); - return userSigns.isEmpty() ? null : userSigns.get(0).getData(); + @RequiresAuthentication + @Aop(TransAop.READ_COMMITTED) + public Result update(String sign, String user_id, Boolean is_value_base64) { + String currentUserId = ShiroUtil.getPrincipalProperty("id").toString(); + if (Strings.isNotBlank(user_id) && !currentUserId.equals(user_id)) return Result.error("只能保存本人的签名"); + if (Strings.isBlank(sign)) return Result.error("请先签字"); + String saved = personalSignatureService.savePersonal(currentUserId, sign, !Boolean.FALSE.equals(is_value_base64)); + return Result.success().addData(saved); + } + + /** is_value_base64 默认 true;按当前登录用户查询对应类型,返回签名字符串,未找到返回 null。 */ + @At + @RequiresAuthentication + public String getMySign(Boolean is_value_base64) { + return personalSignatureService.findPersonal(ShiroUtil.getPrincipalProperty("id").toString(), + !Boolean.FALSE.equals(is_value_base64)); } } diff --git a/src/main/resources/static/components/sign/mobileSign.vue b/src/main/resources/static/components/sign/mobileSign.vue index 405107a..9baae44 100644 --- a/src/main/resources/static/components/sign/mobileSign.vue +++ b/src/main/resources/static/components/sign/mobileSign.vue @@ -3,8 +3,11 @@
@@ -37,7 +40,9 @@ module.exports = { my_sign: {type: String, default: ""}, // 不传 popupVisible 时保持组件自行开关;传入布尔值时由页面统一历史栈管理。 popupVisible: {type: Boolean, default: null}, - // 默认仍保存个人签名;业务只需本次签字时传 false,通过 input 回传 Base64。 + // 与 PC 一致:true 使用 Base64,false 使用个人签名表中的文件路径。 + is_value_base64: {type: Boolean, default: true}, + // 仅接收旧页面属性以兼容模板;不再参与判断,所有主动提交均保存个人签名。 saveToProfile: {type: Boolean, default: true}, disabled: {type: Boolean, default: false}, panelTitle: {type: String, default: "签字"} @@ -51,15 +56,34 @@ module.exports = { }, boundSignature() { return Object.prototype.hasOwnProperty.call(this.$options.propsData || {}, "value") ? this.value : this.my_sign + }, + canUsePersonalSignature() { + return !this.matchesSignatureType(this.boundSignature) && this.matchesSignatureType(this.personalSignature) + && this.signatureContent === this.personalSignature + }, + signatureImageUrl() { + // 显示地址与提交值分离:文件接口 URL 只用于 img,回填仍使用原始文件路径。 + if (!this.matchesSignatureType(this.signatureContent)) return '' + return this.is_value_base64 ? this.signatureContent + : '/signature/getSignData?path=' + encodeURIComponent(this.signatureContent) } }, watch: { boundSignature: { handler(val) { - this.signatureContent = val + // 业务回显优先;表单清空时恢复个人签名预览,仍须点击使用才回填。 + this.signatureVersion++ + this.signatureContent = this.matchesSignatureType(val) ? val : this.personalSignature }, immediate: true }, + is_value_base64() { + // 类型切换后清除另一种个人签名,只重新读取新类型,不互转历史签名。 + this.signatureVersion++ + this.personalSignature = '' + this.signatureContent = this.matchesSignatureType(this.boundSignature) ? this.boundSignature : '' + this.getMySign(true) + }, signaturePanelVisible(visible) { // 每次打开重新创建画布;等待弹出动画完成后再按真实可用区域计算尺寸。 if (!visible) this.boardReady = false @@ -68,12 +92,27 @@ module.exports = { data() { return { signatureContent: null, + personalSignature: '', + signatureVersion: 0, + signatureRequest: 0, + signatureLoading: false, showSignaturePanel: false, boardReady: false, saving: false } }, methods: { + /** value 为签名原值;只接受当前参数对应的数据,空值或另一种格式不能冒充本类型签名。 */ + matchesSignatureType(value) { + if (typeof value !== 'string' || !value) return false + return this.is_value_base64 ? /^data:image\/(png|jpeg|jpg|gif|webp);base64,[A-Za-z0-9+/]+={0,2}$/.test(value) + : value.startsWith('/') && !value.startsWith('//') && !/[?#\\\r\n]/.test(value) && !value.includes('..') + }, + /** 复用签名同样经过个人签名保存;没有打开签字层时不能误关父页面的详情弹框。 */ + usePersonalSignature() { + if (this.disabled || this.saving || !this.canUsePersonalSignature) return + return this.save(this.personalSignature, false) + }, openSignature() { if (!this.disabled && !this.saving) this.changePanelVisible(true) }, @@ -93,41 +132,73 @@ module.exports = { onPanelOpened() { this.boardReady = this.signaturePanelVisible }, - /** data 为签名图片字符串,同时通知两种已有绑定方式,父表单提交字段保持不变。 */ + /** data 为当前模式的 Base64 或文件路径,同时通知两种已有绑定方式。 */ updateSignature(data) { + this.signatureVersion++ this.signatureContent = data this.$emit("input", data) this.$emit("update:my_sign", data) }, - /** signature 为公共签字板生成的 Base64;本次签字模式不调用个人签名保存接口。 */ - save(signature) { + /** + * signature 为新手写 Base64 或已读取的同类型个人签名;closePanel=false 表示在预览区复用。 + * 提交 sign/is_value_base64,接口返回 {code,msg,data};data 为保存后的对应类型原值。 + * 个人签名保存成功后才回填业务表单,文件模式的新手写图片由服务端上传。 + */ + save(signature, closePanel = true) { if (this.saving || this.disabled) return - if (!this.saveToProfile) { - this.updateSignature(signature) - this.changePanelVisible(false) - return - } + // 新签字开始保存后,之前发出的个人签名查询不得覆盖本次操作。 + const version = ++this.signatureVersion + const base64 = this.is_value_base64 this.saving = true - return $.post("/mobile/common/signing/update", {sign: signature}) + return $.post("/mobile/common/signing/update", {sign: signature, is_value_base64: base64}) .then((res) => { + if (this._isDestroyed || this._isBeingDestroyed || version !== this.signatureVersion + || base64 !== this.is_value_base64) return if (res.code === 0) { + if (!this.matchesSignatureType(res.data)) { + this.$toast.fail("返回的签名格式不正确,请重试") + return + } + this.personalSignature = res.data + if (closePanel && this.signaturePanelVisible) this.changePanelVisible(false) + this.updateSignature(res.data) this.$toast.success("保存成功") - this.changePanelVisible(false) - return this.getMySign() } else { - this.$toast.fail("保存失败") + this.$toast.fail(res.msg || "保存失败") } }) .fail(() => this.$toast.fail("签字保存失败,请重试")) .always(() => { this.saving = false }) }, - /** 沿用个人签名查询接口,返回图片数据并同步给已有 v-model 调用方。 */ - getMySign() { - return $.get("/mobile/common/signing/getMySign").then((data) => { - if (data) { - this.updateSignature(data) - } - }) + /** + * previewOnly=true 仅初始化预览;省略参数保留旧页面主动调用时的回填行为。 + * 接口传 is_value_base64,不传用户ID;按登录账号返回对应类型签名或 null,方法返回 jQuery Promise。 + * 无个人签名时保留当前内容;请求期间表单变更、重签或组件销毁后不再应用旧结果。 + */ + getMySign(previewOnly = false) { + const request = ++this.signatureRequest + const version = this.signatureVersion + this.signatureLoading = true + return $.get("/mobile/common/signing/getMySign", {is_value_base64: this.is_value_base64}) + .then((data) => { + if (request !== this.signatureRequest || version !== this.signatureVersion + || this._isDestroyed || this._isBeingDestroyed) return + this.personalSignature = this.matchesSignatureType(data) ? data : '' + if (previewOnly) { + if (!this.matchesSignatureType(this.boundSignature)) this.signatureContent = this.personalSignature + } else if (this.personalSignature) { + this.updateSignature(this.personalSignature) + } + }) + .fail(() => { + // 自动预览失败仍可手写;仅主动读取失败时提示,避免阻断业务页面进入。 + if (!previewOnly && request === this.signatureRequest && !this._isDestroyed && !this._isBeingDestroyed) { + this.$toast.fail("个人签名读取失败,请重新签字") + } + }) + .always(() => { + if (request === this.signatureRequest) this.signatureLoading = false + }) } }, created() { @@ -137,6 +208,8 @@ module.exports = { }) // 旧表单通过 PJAX 替换 DOM 时不会自动销毁 Vue,主动释放签字实例和公共栈回调。 $(document).one("pjax:beforeReplace.mobileSignature" + this._uid, () => this.$destroy()) + // 所有页面均按类型预览个人签名,初始化不触发 input、保存或打开签字板。 + this.getMySign(true) }, beforeDestroy() { $(document).off("pjax:beforeReplace.mobileSignature" + this._uid) diff --git a/src/main/resources/static/components/sign/sign.vue b/src/main/resources/static/components/sign/sign.vue index 736c227..b9f5547 100644 --- a/src/main/resources/static/components/sign/sign.vue +++ b/src/main/resources/static/components/sign/sign.vue @@ -131,7 +131,8 @@ module.exports = { if (this.qz) { this.getImageUrl(this.qz) } else { - $.get("/mobile/common/signing/getMySign").then(data => { + // 与手机共用分类查询:只读取当前模式对应的个人签名。 + $.get("/mobile/common/signing/getMySign", {is_value_base64: this.is_value_base64}).then(data => { if (data) { this.getImageUrl(data) this.$emit("change", data) diff --git a/src/main/resources/views/mobile/home.html b/src/main/resources/views/mobile/home.html index 5373a17..c0cf7bc 100644 --- a/src/main/resources/views/mobile/home.html +++ b/src/main/resources/views/mobile/home.html @@ -172,7 +172,7 @@ layout("/mobile/platform.html"){
-
+
@@ -385,7 +385,7 @@ layout("/mobile/platform.html"){
-
+
{{m.moduleName}} @@ -401,7 +401,8 @@ layout("/mobile/platform.html"){
- + + 首页 我的办理 工作台 diff --git a/src/main/resources/views/mobile/jf/activitybx/association/index.html b/src/main/resources/views/mobile/jf/activitybx/association/index.html index e8a6ce4..3d33300 100644 --- a/src/main/resources/views/mobile/jf/activitybx/association/index.html +++ b/src/main/resources/views/mobile/jf/activitybx/association/index.html @@ -33,6 +33,8 @@ layout("/mobile/platform.html"){ .association-description { padding: 0 14px; line-height: 1.8; white-space: pre-wrap; overflow-wrap: anywhere; } .association-muted { margin: 10px 14px; color: #969799; } .association-total .van-cell__value { color: #086bc3; font-weight: 600; } + /* 共用详情中的预算分组沿用申报审核页的分隔样式。 */ + .association-budget-item + .association-budget-item { border-top: 1px solid #f2f3f5; } .association-sign-image { display: block; max-width: calc(100% - 28px); width: 240px; height: 100px; object-fit: contain; margin: 8px 14px; } .association-actions { flex-shrink: 0; display: flex; gap: 12px; padding: 10px 14px calc(10px + env(safe-area-inset-bottom)); background: white; border-top: 1px solid #eee; z-index: 2; } .association-actions .van-button { flex: 1; border-radius: 6px; height: 40px; } @@ -127,7 +129,7 @@ layout("/mobile/platform.html"){
+
+

{{ data.activity_name }}

- - - - + + + + + - + - - + + +
-
+

活动方案及简介

-
{{ activityText || '暂无' }}
- 活动方案配图 +
-
+

预算明细

-
+
- +
-
+

申请附件

- -

暂无附件

+

暂无附件

申请人签字

- 申请人签字 -

暂无签字

+ 申请人签字 +

暂无签字

-
+

{{ group.title }}

-

暂无附件

+ class="review-file association-file" value="查看" is-link @click="$emit('preview', file)"> +

暂无附件

-
+

{{ audit.title }}

- +
@@ -74,9 +76,11 @@ const activityReviewInfo = { return {40001: '校工会活动', 40002: '基层工会活动', 40003: '协会活动', 40004: '女职工活动'}[this.data.activity_type] || '—' }, reimbursementFiles() { + // 报销状态存在且附件组有内容时才展示,未上传的可选附件不占用空白分组。 if (!this.data.reimbursement_state_id) return [] return [{key: 'billFiles', title: '发票'}, {key: 'photoFiles', title: '活动报道照片/总结/链接'}, {key: 'otherFiles', title: '奖品/纪念品/服装/道具等活动人员名单'}] .map((group) => Object.assign({}, group, {files: this.asList(this.data[group.key])})) + .filter((group) => group.files.length) }, budgets() { return this.asList(this.data.budgets) @@ -84,18 +88,94 @@ const activityReviewInfo = { files() { return this.asList(this.data.files) }, - activityText() { - // 申请内容来自富文本,按段落提取文本,避免在审核页面执行其中的脚本或事件属性。 - const container = document.createElement('div') - container.innerHTML = (this.data.activity_content || '').replace(/<\/(p|div|li)>/gi, '\n') - container.querySelectorAll('script,style').forEach((node) => node.remove()) - return container.textContent.trim() - }, - activityImages() { - // 保留富文本中的方案配图,只提取图片地址,不执行原文中的 HTML 属性和事件。 + activityHtml() { + // activity_content 为历史富文本;只重建允许的内容节点,不复制脚本、事件、类名或任意内联样式。 const content = new DOMParser().parseFromString(this.data.activity_content || '', 'text/html') - return Array.from(content.querySelectorAll('img')).map((img) => img.getAttribute('src') || '') - .filter((url) => /^(https?:\/\/|\/(?!\/)|data:image\/(?:png|jpeg|gif|webp);base64,)/i.test(url)) + const container = content.createElement('div') + const allowed = new Set(['P', 'DIV', 'SPAN', 'BR', 'B', 'STRONG', 'I', 'EM', 'U', 'S', 'DEL', 'SUB', 'SUP', + 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'UL', 'OL', 'LI', 'BLOCKQUOTE', 'PRE', 'CODE', 'HR', + 'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR', 'TH', 'TD', 'CAPTION', 'IMG', 'FONT', 'MARK']) + const blocked = new Set(['SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'SVG', 'MATH', 'TEMPLATE', + 'FORM', 'INPUT', 'BUTTON', 'TEXTAREA', 'SELECT', 'LINK', 'META', 'BASE']) + const appendContent = (source, parent) => { + if (source.nodeType === 3) { + parent.appendChild(content.createTextNode(source.textContent)) + return + } + if (source.nodeType !== 1 || blocked.has(source.tagName)) return + const tag = source.tagName + if (!allowed.has(tag)) { + Array.from(source.childNodes).forEach((child) => appendContent(child, parent)) + return + } + const node = content.createElement(tag === 'FONT' ? 'span' : tag.toLowerCase()) + // 仅保留文字强调、颜色和对齐;固定宽高、定位和 Word 段落间距不带入手机页面。 + const textStyles = ['color', 'backgroundColor', 'fontWeight', 'fontStyle', 'textDecorationLine', 'textAlign', 'verticalAlign'] + textStyles.forEach((name) => { node.style[name] = source.style[name] }) + if (tag === 'FONT' && !node.style.color) node.style.color = source.getAttribute('color') || '' + if (!node.style.textAlign && /^(left|center|right|justify)$/i.test(source.getAttribute('align') || '')) { + node.style.textAlign = source.getAttribute('align') + } + if (/^(P|DIV|H[1-6]|BLOCKQUOTE|PRE|UL|OL)$/.test(tag)) { + node.style.margin = '0 0 8px' + node.style.lineHeight = '1.8' + } + if (/^H[1-6]$/.test(tag)) node.style.fontSize = '16px' + if (tag === 'UL' || tag === 'OL') { + node.style.paddingLeft = '24px' + node.style.listStyleType = tag === 'UL' ? 'disc' : 'decimal' + if (tag === 'OL' && /^-?\d{1,6}$/.test(source.getAttribute('start') || '')) { + node.setAttribute('start', source.getAttribute('start')) + } + } + if (tag === 'PRE') { + node.style.whiteSpace = 'pre-wrap' + node.style.overflowWrap = 'anywhere' + } + if (tag === 'IMG') { + // 图片沿用原有地址范围;保留正文中的位置,点击后交给现有预览弹框和历史栈。 + const url = (source.getAttribute('src') || '').trim() + if (!/^(https?:\/\/|\/(?![\/\\])|data:image\/(?:png|jpeg|gif|webp);base64,)/i.test(url)) return + node.setAttribute('src', url) + node.setAttribute('alt', source.getAttribute('alt') || '活动方案配图') + node.style.cssText = 'display:block;max-width:100%;height:auto;margin:8px 0;cursor:pointer' + } + if (tag === 'TABLE') { + // 每张表单独横向滚动,保留行列关系;不让宽表撑开整个审核页面。 + const scroll = content.createElement('div') + scroll.style.cssText = 'max-width:100%;overflow-x:auto;margin:8px 0;white-space:normal' + parent.appendChild(scroll) + parent = scroll + node.style.width = '100%' + node.style.borderCollapse = 'collapse' + node.style.tableLayout = 'auto' + } + if (tag === 'TD' || tag === 'TH') { + node.style.border = '1px solid #dcdfe6' + node.style.padding = '6px 8px' + node.style.minWidth = '72px' + node.style.overflowWrap = 'anywhere' + // 合并单元格只接受浏览器支持范围内的整数,保留预算表的表头和分组结构。 + ;['rowspan', 'colspan'].forEach((name) => { + const value = source.getAttribute(name) + if (/^\d{1,4}$/.test(value || '') && Number(value) >= 1 && Number(value) <= 1000) { + node.setAttribute(name, value) + } + }) + } + Array.from(source.childNodes).forEach((child) => appendContent(child, node)) + parent.appendChild(node) + } + if (!content.body.children.length) { + // 旧手机申请可能保存纯文字;保留用户输入的换行,但不把它作为 HTML 再解析。 + const plain = content.createElement('div') + plain.style.whiteSpace = 'pre-wrap' + plain.textContent = content.body.textContent.trim() + container.appendChild(plain) + } else { + Array.from(content.body.childNodes).forEach((node) => appendContent(node, container)) + } + return container.textContent.trim() || container.querySelector('img') ? container.innerHTML : '' }, audits() { const data = this.data @@ -124,6 +204,11 @@ const activityReviewInfo = { } }, methods: { + /** event 为正文点击事件;仅图片触发 preview,参数 imageUrl 交由父页面现有弹框处理,无返回值。 */ + previewActivityImage(event) { + const target = event.target + if (target && target.tagName === 'IMG') this.$emit('preview', {imageUrl: target.getAttribute('src')}) + }, /** 历史接口可能返回 JSON 字符串或数组;空值、异常数据均不阻断整页展示。 */ asList(value) { if (Array.isArray(value)) return value diff --git a/src/main/resources/views/mobile/jf/reimbursement/review/info.js b/src/main/resources/views/mobile/jf/reimbursement/review/info.js index 873ce0b..f41d2f0 100644 --- a/src/main/resources/views/mobile/jf/reimbursement/review/info.js +++ b/src/main/resources/views/mobile/jf/reimbursement/review/info.js @@ -23,9 +23,8 @@ const reimbursementInfo = {

活动方案及简介

-
{{ activityText || '暂无' }}
- 活动方案配图 +

费用及收款信息

@@ -85,18 +84,94 @@ const reimbursementInfo = { files() { return this.asList(this.data.files) }, - activityText() { - // 申请内容来自富文本,按段落提取文本,避免在审核页面执行其中的脚本或事件属性。 - const container = document.createElement('div') - container.innerHTML = (this.data.activity_content || '').replace(/<\/(p|div|li)>/gi, '\n') - container.querySelectorAll('script,style').forEach((node) => node.remove()) - return container.textContent.trim() - }, - activityImages() { - // 保留富文本中的方案配图,只提取图片地址,不执行原文中的 HTML 属性和事件。 + activityHtml() { + // activity_content 为历史富文本;只重建允许的内容节点,不复制脚本、事件、类名或任意内联样式。 const content = new DOMParser().parseFromString(this.data.activity_content || '', 'text/html') - return Array.from(content.querySelectorAll('img')).map((img) => img.getAttribute('src') || '') - .filter((url) => /^(https?:\/\/|\/(?!\/)|data:image\/(?:png|jpeg|gif|webp);base64,)/i.test(url)) + const container = content.createElement('div') + const allowed = new Set(['P', 'DIV', 'SPAN', 'BR', 'B', 'STRONG', 'I', 'EM', 'U', 'S', 'DEL', 'SUB', 'SUP', + 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'UL', 'OL', 'LI', 'BLOCKQUOTE', 'PRE', 'CODE', 'HR', + 'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR', 'TH', 'TD', 'CAPTION', 'IMG', 'FONT', 'MARK']) + const blocked = new Set(['SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'SVG', 'MATH', 'TEMPLATE', + 'FORM', 'INPUT', 'BUTTON', 'TEXTAREA', 'SELECT', 'LINK', 'META', 'BASE']) + const appendContent = (source, parent) => { + if (source.nodeType === 3) { + parent.appendChild(content.createTextNode(source.textContent)) + return + } + if (source.nodeType !== 1 || blocked.has(source.tagName)) return + const tag = source.tagName + if (!allowed.has(tag)) { + Array.from(source.childNodes).forEach((child) => appendContent(child, parent)) + return + } + const node = content.createElement(tag === 'FONT' ? 'span' : tag.toLowerCase()) + // 仅保留文字强调、颜色和对齐;固定宽高、定位和 Word 段落间距不带入手机页面。 + const textStyles = ['color', 'backgroundColor', 'fontWeight', 'fontStyle', 'textDecorationLine', 'textAlign', 'verticalAlign'] + textStyles.forEach((name) => { node.style[name] = source.style[name] }) + if (tag === 'FONT' && !node.style.color) node.style.color = source.getAttribute('color') || '' + if (!node.style.textAlign && /^(left|center|right|justify)$/i.test(source.getAttribute('align') || '')) { + node.style.textAlign = source.getAttribute('align') + } + if (/^(P|DIV|H[1-6]|BLOCKQUOTE|PRE|UL|OL)$/.test(tag)) { + node.style.margin = '0 0 8px' + node.style.lineHeight = '1.8' + } + if (/^H[1-6]$/.test(tag)) node.style.fontSize = '16px' + if (tag === 'UL' || tag === 'OL') { + node.style.paddingLeft = '24px' + node.style.listStyleType = tag === 'UL' ? 'disc' : 'decimal' + if (tag === 'OL' && /^-?\d{1,6}$/.test(source.getAttribute('start') || '')) { + node.setAttribute('start', source.getAttribute('start')) + } + } + if (tag === 'PRE') { + node.style.whiteSpace = 'pre-wrap' + node.style.overflowWrap = 'anywhere' + } + if (tag === 'IMG') { + // 图片沿用原有地址范围;保留正文中的位置,点击后交给现有预览弹框和历史栈。 + const url = (source.getAttribute('src') || '').trim() + if (!/^(https?:\/\/|\/(?![\/\\])|data:image\/(?:png|jpeg|gif|webp);base64,)/i.test(url)) return + node.setAttribute('src', url) + node.setAttribute('alt', source.getAttribute('alt') || '活动方案配图') + node.style.cssText = 'display:block;max-width:100%;height:auto;margin:8px 0;cursor:pointer' + } + if (tag === 'TABLE') { + // 每张表单独横向滚动,保留行列关系;不让宽表撑开整个审核页面。 + const scroll = content.createElement('div') + scroll.style.cssText = 'max-width:100%;overflow-x:auto;margin:8px 0;white-space:normal' + parent.appendChild(scroll) + parent = scroll + node.style.width = '100%' + node.style.borderCollapse = 'collapse' + node.style.tableLayout = 'auto' + } + if (tag === 'TD' || tag === 'TH') { + node.style.border = '1px solid #dcdfe6' + node.style.padding = '6px 8px' + node.style.minWidth = '72px' + node.style.overflowWrap = 'anywhere' + // 合并单元格只接受浏览器支持范围内的整数,保留预算表的表头和分组结构。 + ;['rowspan', 'colspan'].forEach((name) => { + const value = source.getAttribute(name) + if (/^\d{1,4}$/.test(value || '') && Number(value) >= 1 && Number(value) <= 1000) { + node.setAttribute(name, value) + } + }) + } + Array.from(source.childNodes).forEach((child) => appendContent(child, node)) + parent.appendChild(node) + } + if (!content.body.children.length) { + // 旧手机申请可能保存纯文字;保留用户输入的换行,但不把它作为 HTML 再解析。 + const plain = content.createElement('div') + plain.style.whiteSpace = 'pre-wrap' + plain.textContent = content.body.textContent.trim() + container.appendChild(plain) + } else { + Array.from(content.body.childNodes).forEach((node) => appendContent(node, container)) + } + return container.textContent.trim() || container.querySelector('img') ? container.innerHTML : '' }, audits() { const data = this.data @@ -125,6 +200,11 @@ const reimbursementInfo = { } }, methods: { + /** event 为正文点击事件;仅图片触发 preview,参数 imageUrl 交由父页面现有弹框处理,无返回值。 */ + previewActivityImage(event) { + const target = event.target + if (target && target.tagName === 'IMG') this.$emit('preview', {imageUrl: target.getAttribute('src')}) + }, /** 历史接口可能返回 JSON 字符串或数组;空值、异常数据均不阻断整页展示。 */ asList(value) { if (Array.isArray(value)) return value diff --git a/src/main/resources/views/platform/common/signature.html b/src/main/resources/views/platform/common/signature.html index cb7e485..823b88e 100644 --- a/src/main/resources/views/platform/common/signature.html +++ b/src/main/resources/views/platform/common/signature.html @@ -17,7 +17,7 @@
-