经费提案整改

This commit is contained in:
2026-10-30 15:50:53 +08:00
parent 29260423d5
commit 4f85bb08e2
9 changed files with 271 additions and 9 deletions
@@ -19,6 +19,7 @@ RoleConstant {
SCHOOL_UNION_ADMIN("校工会管理员"),
SCHOOL_UNION_CHAIRMAN("校工会主席"),
SCHOOL_UNION_VICE_CHAIRMAN("校工会副主席"),
SCHOOL_UNION_FINANCE("校工会财务"),
SCHOOL_OFFICE_DIRECTOR("校工会办公室主任"),
SCHOOL_UNION_WELFARE_ADMIN("校工会福利管理员"),
SCHOOL_UNION_MEMBER_ADMIN("校工会会员管理员"),
@@ -0,0 +1,54 @@
package com.budwk.app.flow.handler;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.flow.engine.AssignmentHandler;
import com.budwk.app.flow.engine.core.Execution;
import com.budwk.app.flow.engine.core.ServiceContext;
import com.budwk.app.flow.engine.model.TaskModel;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import java.util.List;
/**
* 为流程任务分配拥有校工会财务角色的用户。
*/
public class FlowSchoolUnionFinanceHandler implements AssignmentHandler {
/**
* 按校工会财务角色查询参与者,并去除重复的用户 ID。
*
* @param model 当前流程任务模型,无需额外传入人员参数
* @param execution 当前流程执行上下文,无需额外传入业务参数
* @return 用户 ID 列表;未配置角色或角色用户时返回空列表
*/
@Override
public List<String> assign(TaskModel model, Execution execution) {
Dao dao = ServiceContext.find(Dao.class);
Sql sql = Sqls.create("""
SELECT
userRole.userId
FROM
`sys_user_role` userRole
LEFT JOIN sys_role role ON role.id = userRole.roleId
WHERE
role.`code` = @roleCode
GROUP BY
userRole.userId
""").setParam("roleCode", RoleConstant.SCHOOL_UNION_FINANCE.name());
sql.setCallback(Sqls.callback.strList());
dao.execute(sql);
return sql.getList(String.class);
}
/**
* 返回流程设计器中展示的参与者处理类名称。
*
* @return 流程设计器下拉选项名称
*/
@Override
public String getMessage() {
return "获取校工会财务";
}
}
@@ -229,14 +229,18 @@ public class ProposalMineController {
}
/**
* 删除当前用户可删除的提案。
*
* @param id 提案 ID;仅允许删除本人未发起流程或退回发起节点的提案
* @return Result,成功时返回成功结果,不满足删除条件时返回原因
*/
@At
@SaCheckPermission("proposal.mine")
@Aop(TransAop.READ_COMMITTED)
@SLog(tag = "提案管理系统-我的提案", msg = "删除提案")
public Result delete(@Valid String id) {
dao.delete(ProposalInfo.class, id);
flowEngine.processInstanceService().deleteProcessInstanceByBusinessKey(id);
return Result.success();
return proposalCommonService.deleteMine(id, SecurityUtil.getUserId());
}
@At
@@ -3,6 +3,7 @@ package com.budwk.app.zhgh.democratic.proposal.service.common;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.param.ExportTableColumns;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.democratic.proposal.models.ProposalInfo;
import com.budwk.app.zhgh.democratic.proposal.models.ProposalUndertake;
import com.budwk.app.zhgh.democratic.proposal.param.ProposalQueryComprehensiveParam;
@@ -19,6 +20,15 @@ import java.util.Set;
public interface ProposalCommonService extends BaseService<ProposalInfo> {
/**
* 删除当前用户尚未发起流程或已退回发起节点的提案。
*
* @param id 待删除的提案 ID
* @param userId 当前登录用户 ID,用于核对提案创建人
* @return Result,删除成功返回 success;提案不存在、非本人或流程不允许删除时返回错误信息
*/
Result deleteMine(String id, String userId);
/**
* 按页面允许的字段白名单添加排序条件,避免前端字段直接进入SQL。
*
@@ -17,6 +17,7 @@ import com.budwk.app.base.exception.BaseException;
import com.budwk.app.base.param.ExportTableColumns;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.base.utils.CommonDownloadUtil;
import com.budwk.app.base.utils.PageUtil;
@@ -52,6 +53,7 @@ import com.deepoove.poi.config.Configure;
import com.deepoove.poi.plugin.table.LoopRowTableRenderPolicy;
import lombok.extern.slf4j.Slf4j;
import org.ddr.poi.html.HtmlRenderPolicy;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.FieldFilter;
@@ -62,6 +64,7 @@ import org.nutz.dao.util.cri.SqlExpressionGroup;
import org.nutz.dao.util.cri.Static;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.ioc.aop.Aop;
import org.nutz.json.Json;
import org.nutz.lang.Lang;
import org.nutz.lang.Strings;
@@ -184,6 +187,45 @@ public class ProposalCommonServiceImpl extends BaseServiceImpl<ProposalInfo> imp
super(dao);
}
/**
* 仅允许创建人删除草稿或退回发起节点的提案,并清理其 WF 流程。
*
* @param id 待删除的提案 ID
* @param userId 当前登录用户 ID,用于核对提案创建人
* @return Result,删除成功返回 success;提案不存在、非本人或流程不允许删除时返回错误信息
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public Result deleteMine(String id, String userId) {
if (StrUtil.isBlank(id) || StrUtil.isBlank(userId)) {
return Result.error("提案不存在或无权删除");
}
ProposalInfo proposal = dao().fetch(ProposalInfo.class, id);
if (proposal == null || !Objects.equals(proposal.getCreatedBy(), userId)) {
return Result.error("提案不存在或无权删除");
}
List<ProcessInstance> instances = dao().query(ProcessInstance.class,
Cnd.where(ProcessInstance::getBusinessNo, "=", id));
// 一个提案出现多个流程实例时无法安全判断当前节点,拒绝删除并保留数据。
if (instances.size() > 1) {
return Result.error("提案流程数据异常,无法删除");
}
if (!instances.isEmpty()) {
ProcessInstance instance = instances.get(0);
List<ProcessTask> tasks = flowEngine.processTaskService().getDoingTaskList(instance.getId(), null);
// 存在并行任务时,只有所有活动任务均位于发起节点才允许删除。
if (!Objects.equals(instance.getState(), ProcessInstanceStateEnum.DOING.getCode())
|| tasks.isEmpty() || tasks.stream().anyMatch(task -> !Objects.equals(task.getTaskName(), "startTask"))) {
return Result.error("仅未提交或退回发起节点的提案可以删除");
}
}
dao().delete(ProposalInfo.class, id);
flowEngine.processInstanceService().deleteProcessInstanceByBusinessKey(id);
return Result.success();
}
/**
* 分页查询提案综合列表。
* 普通查询不关联历史任务表;仅在 origins 包含 answer 且 commonKeyword 非空时,
@@ -10,7 +10,7 @@
:finished="tableFinished"
finished-text="没有更多了"
@load="onLoad">
<div class="table-list-container">
<div class="table-list-container" @click="showOverflowText">
<div v-for="(row, index) in tableData" :key="index" class="table-list-item"
:data-row-id="row && row.id ? row.id : ''">
<slot name="header" :index="index" :row="row">
@@ -36,6 +36,12 @@
</div>
</div>
</van-list>
<van-popover ref="overflowPopover" :value="overflowVisible" :style="overflowAnchorStyle"
:placement="overflowPlacement" get-container="body" @input="onOverflowInput"
@opened="positionOverflow">
<div class="table-list-overflow-content">{{ overflowText }}</div>
<template #reference><span></span></template>
</van-popover>
</van-pull-refresh>
</template>
<script>
@@ -90,10 +96,138 @@ module.exports = {
tableLoading: false,
tableFinished: false,
tableRefreshing: false,
localPageForm: {...this.page_form}
localPageForm: {...this.page_form},
overflowVisible: false,
overflowText: "",
overflowTarget: null,
overflowPlacement: "top",
overflowAnchorStyle: {}
}
},
methods: {
// 只响应真正被截断的纯文字,保留卡片内按钮和业务插槽原有的点击行为。
showOverflowText(event) {
const target = event.target
if (!target || !target.closest) return
const card = target.closest(".table-list-item")
if (!card || target.closest(".item-actions, a, button, input, textarea, select, [role=button], [tabindex], [onclick]")) return
if (target.children.length || !target.textContent.trim() || this.hasOverflowClickHandler(target, card)) return
const style = window.getComputedStyle(target)
if (style.textOverflow !== "ellipsis" || (target.scrollWidth <= target.clientWidth && target.scrollHeight <= target.clientHeight)) return
const rect = target.getBoundingClientRect()
this.overflowTarget = target
this.overflowText = target.textContent.trim()
this.overflowPlacement = rect.top < 180 ? "bottom" : "top"
this.overflowAnchorStyle = {
position: "fixed",
display: "block",
pointerEvents: "none",
left: rect.left + "px",
top: rect.top + "px",
width: rect.width + "px",
height: rect.height + "px"
}
if (!this.overflowVisible) this.bindOverflowListeners()
this.overflowVisible = true
this.$nextTick(() => {
const instance = this.$refs.overflowPopover
if (instance) instance.updateLocation()
setTimeout(() => this.positionOverflow(), 50)
setTimeout(() => this.positionOverflow(), 350)
})
},
// 检查文字及其卡片内的祖先节点,避免干扰已有 Vue 点击事件。
hasOverflowClickHandler(target, card) {
const ancestors = []
let element = target
while (element && element !== card.parentElement) {
ancestors.push(element)
element = element.parentElement
}
const visit = (node) => {
if (!node) return false
const events = node.data ? [node.data.on, node.data.nativeOn] : []
if (ancestors.includes(node.elm) && events.some((handlers) => handlers &&
Object.keys(handlers).some((key) => /^[~!&]*(click|touchstart|pointerdown|mousedown)$/.test(key)))) return true
if (node.componentInstance && visit(node.componentInstance._vnode)) return true
return Array.isArray(node.children) && node.children.some((child) => visit(child))
}
return visit(this._vnode)
},
// Vant 的弹层挂在 body;校正窗口边缘的位置,避免完整文字被屏幕裁掉。
positionOverflow() {
const instance = this.$refs.overflowPopover
const popup = instance && instance.$refs.popover && instance.$refs.popover.$el
if (!this.overflowVisible || !this.overflowTarget || !popup) return
const rect = popup.getBoundingClientRect()
const anchor = this.overflowTarget.getBoundingClientRect()
if (!rect.width || !rect.height) return
const margin = 8
const left = Math.max(margin, Math.min(rect.left, window.innerWidth - rect.width - margin))
let top = rect.top
if (top < margin) top = anchor.bottom + 8
if (top + rect.height > window.innerHeight - margin) top = anchor.top - rect.height - 8
top = Math.max(margin, Math.min(top, window.innerHeight - rect.height - margin))
popup.style.left = (parseFloat(popup.style.left) || 0) + left - rect.left + "px"
popup.style.top = (parseFloat(popup.style.top) || 0) + top - rect.top + "px"
const arrow = popup.querySelector(".van-popover__arrow")
if (arrow) {
arrow.style.left = Math.max(12, Math.min(anchor.left + anchor.width / 2 - left, rect.width - 12)) + "px"
}
},
onOverflowInput(visible) {
this.overflowVisible = visible
if (!visible) {
this.overflowTarget = null
this.unbindOverflowListeners()
}
},
// 监听只在气泡打开时存在,PJAX 替换页面前也会关闭气泡并注销。
bindOverflowListeners() {
document.addEventListener("click", this.closeOverflowOnClick)
window.addEventListener("scroll", this.closeOverflowOnMove, true)
window.addEventListener("resize", this.closeOverflowOnMove)
window.addEventListener("transitionend", this.onOverflowAnimationEnd)
window.addEventListener("animationend", this.onOverflowAnimationEnd)
$(document).on("pjax:send", this.closeOverflowForNavigation)
},
unbindOverflowListeners() {
document.removeEventListener("click", this.closeOverflowOnClick)
window.removeEventListener("scroll", this.closeOverflowOnMove, true)
window.removeEventListener("resize", this.closeOverflowOnMove)
window.removeEventListener("transitionend", this.onOverflowAnimationEnd)
window.removeEventListener("animationend", this.onOverflowAnimationEnd)
$(document).off("pjax:send", this.closeOverflowForNavigation)
},
closeOverflowForNavigation() {
this.onOverflowInput(false)
},
// Vant 会在动画结束时重新计算定位,随后再次限制气泡的屏幕边界。
onOverflowAnimationEnd() {
if (this.overflowVisible) setTimeout(() => this.positionOverflow(), 0)
},
// 滚动或改变屏幕尺寸时关闭气泡,避免气泡脱离原文字位置。
closeOverflowOnMove(event) {
if (event && event.target && event.target.closest && event.target.closest(".table-list-overflow-content")) return
this.onOverflowInput(false)
},
closeOverflowOnClick(event) {
if (!this.overflowVisible) return
if (this.overflowTarget && this.overflowTarget.contains(event.target)) return
if (event.target.closest && event.target.closest(".table-list-overflow-content")) return
this.onOverflowInput(false)
},
/**
* 获取当前列表快照。
* 返回值包含已加载记录、分页参数和加载完成状态,供业务页面在进入详情前暂存列表现场。
@@ -186,10 +320,28 @@ module.exports = {
},
mounted() {
this.$emit("ready")
},
beforeDestroy() {
this.unbindOverflowListeners()
}
}
</script>
<style>
/* Vant 将气泡挂到 body,控制宽高并允许长文本在气泡内滚动。 */
.table-list-overflow-content {
max-width: min(240px, calc(100vw - 48px));
max-height: 160px;
padding: 10px 12px;
overflow-y: auto;
color: #253247;
font-size: 13px;
line-height: 1.55;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
</style>
<style scoped>
.table-list-container {
padding: 0;
@@ -706,7 +706,7 @@
Vue.component("year-van-dropdown-item", httpVueLoader("/components/plugins/vantMore/yearVanDropDownItem.vue?v=" + h5ComponentVersion))
Vue.component("van-text-dialog", httpVueLoader("/components/plugins/vantMore/vantTextDialog.vue?v=" + h5ComponentVersion))
Vue.component("enum-tag", httpVueLoader("/components/plugins/sysEnum/EnumTag.vue?v=" + h5ComponentVersion))
Vue.component("table-list", httpVueLoader("/components/plugins/h5/TableList.vue?v=" + h5ComponentVersion))
Vue.component("table-list", httpVueLoader("/components/plugins/h5/TableList.vue?v=" + h5ComponentVersion + "_overflow1"))
Vue.component("table-column", httpVueLoader("/components/plugins/h5/TableColumn.vue?v=" + h5ComponentVersion))
Vue.component("file-preview", httpVueLoader("/components/plugins/sysFilePreview/H5Index.vue?v=" + h5ComponentVersion))
</script>
@@ -100,7 +100,7 @@ layout("/layouts/platform.html"){
size="mini" type="danger">删除
</el-button>
<el-button @click="doExportDeclare(row)" size="mini" type="primary">申请表导出</el-button>
<el-button v-if="row.instanceState === 20" @click="doExportDeclare(row)" size="mini" type="primary">申请表导出</el-button>
<el-button v-if="row.instanceState === 20" @click="doExportReimbursement(row)" size="mini"
type="primary">报销凭证导出
</el-button>
@@ -76,8 +76,7 @@ layout("/layouts/platform.html"){
<el-button v-if="row.canRevoke" size="mini" type="danger" @click="openRevoke(row)">
撤回
</el-button>
<!-- v-if="row.taskKey === 'startTask' || !row.instanceId"-->
<el-button size="mini" type="danger" @click="del(row.id)">
<el-button v-if="row.taskKey === 'startTask' || !row.instanceId" size="mini" type="danger" @click="del(row.id)">
删除
</el-button>
</template>