1 Commits
5 changed files with 351 additions and 215 deletions
@@ -1,66 +1,23 @@
package com.budwk.app.zhgh.democratic.proposal.controller.dashboard; package com.budwk.app.zhgh.democratic.proposal.controller.dashboard;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm; import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result; import com.budwk.app.base.result.Result;
import com.budwk.app.flow.engine.model.ProcessModel;
import com.budwk.app.flow.engine.model.TaskModel;
import com.budwk.app.flow.entity.ProcessDefine;
import com.budwk.app.flow.enums.ProcessTaskStateEnum;
import com.budwk.app.flow.service.ProcessDefineService;
import com.budwk.app.sys.models.Sys_dict;
import com.budwk.app.sys.services.SysDictService;
import com.budwk.app.zhgh.democratic.proposal.models.ProposalInfo;
import com.budwk.app.zhgh.democratic.proposal.service.common.ProposalCommonService; import com.budwk.app.zhgh.democratic.proposal.service.common.ProposalCommonService;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param; import org.nutz.mvc.annotation.Param;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@IocBean @IocBean
@At("/platform/proposal/dashboard") @At("/platform/proposal/dashboard")
@Api("提案数据看板") @Api("提案数据看板")
@Ok("json:full") @Ok("json:full")
public class ProposalDashboardController { public class ProposalDashboardController {
/**
* 工作台表格允许排序的字段映射,值为查询中的真实字段或安全别名。
*/
private static final Map<String, String> DASHBOARD_ORDER_COLUMNS = Map.ofEntries(
Map.entry("code", "info.code"),
Map.entry("name", "info.name"),
Map.entry("createUserName", "info.createUserName"),
Map.entry("typeName", "typeName"),
Map.entry("delegationName", "delegationName"),
Map.entry("caseFilingResult", "info.caseFilingResult"),
Map.entry("isConsolidation", "info.isConsolidation"),
Map.entry("masterUnitName", "masterUnitName"),
Map.entry("slaveUnitNames", "slaveUnitNames"),
Map.entry("curTaskName", "curTaskName"),
Map.entry("instanceState", "instanceState")
);
@Inject @Inject
private ProposalCommonService proposalCommonService; private ProposalCommonService proposalCommonService;
@Inject
private ProcessDefineService processDefineService;
@Inject
private SysDictService sysDictService;
@Inject
private Dao dao;
@At("") @At("")
@Ok("beetl:/platform/zhgh/democratic/proposal/dashboard/index.html") @Ok("beetl:/platform/zhgh/democratic/proposal/dashboard/index.html")
@@ -68,147 +25,30 @@ public class ProposalDashboardController {
public void index() { public void index() {
} }
/**
* 接收届次ID查询卡片;Result.data 为卡片列表,含 count 或 todoCount/doneCount。
* @param sessionId 教代会届次ID;为空时卡片数量为零
* @return 统一结果对象,data 为各节点和立案结果统计
*/
@At @At
@SaCheckPermission("proposal.dashboard") @SaCheckPermission("proposal.dashboard")
public Result listNode(@Param("sessionId") String sessionId) { public Result listNode(@Param("sessionId") String sessionId) {
List<NutMap> nodes = new ArrayList<>(); return Result.success(proposalCommonService.listDashboardNodes(sessionId));
// 提案总数
nodes.add(NutMap.NEW().addv("name", "提案总数").addv("id", "total").addv("type", "total").addv("count", 0));
// 节点
ProcessDefine define = processDefineService.getLastByName("JDHTA_NC");
ProcessModel processModel = processDefineService.processDefineToModel(define);
// 按流程图的连线关系遍历任务节点,保证看板展示顺序与流程图一致。
List<TaskModel> taskModels = processModel.getModels(TaskModel.class);
List<String> excludeNodes = new ArrayList<>(3);
excludeNodes.add("撰写提案");
excludeNodes.add("邀请附议人");
excludeNodes.add("提案附议");
// opinion_unit_reply、two_unit_reply 为临时屏蔽的承办单位答复节点,不在看板展示。
List<String> hiddenNodeIds = List.of("opinion_unit_reply", "two_unit_reply");
List<NutMap> taskNodes = new ArrayList<>(taskModels.stream()
.filter(taskModel -> !excludeNodes.contains(taskModel.getDisplayName()))
.filter(taskModel -> !hiddenNodeIds.contains(taskModel.getName()))
.map(taskModel -> NutMap.NEW()
.addv("name", taskModel.getDisplayName())
.addv("id", taskModel.getName())
.addv("type", "task")
.addv("count", 0)
.addv("todoCount", 0)
.addv("doneCount", 0)
).toList());
// 立案结果
List<Sys_dict> caseFilingResult = sysDictService.getSubListByCode("PROPOSAL_CASE_FILING_RESULT");
List<NutMap> caseFilingResultNodes = new ArrayList<>();
for (Sys_dict dict : caseFilingResult) {
caseFilingResultNodes.add(NutMap.NEW()
.addv("name", dict.getName())
.addv("id", dict.getCode())
.addv("type", "caseFilingResult")
.addv("count", 0));
}
// 立案结果需展示在委员会确认承办单位之前,反馈评价统一固定到看板末尾。
List<NutMap> feedbackNodes = taskNodes.stream()
.filter(taskNode -> "feedback".equals(taskNode.getString("id")))
.toList();
taskNodes.removeIf(taskNode -> "feedback".equals(taskNode.getString("id")));
int committeeFilingUnitIndex = taskNodes.size();
for (int i = 0; i < taskNodes.size(); i++) {
if ("committeeFilingUnit".equals(taskNodes.get(i).getString("id"))) {
committeeFilingUnitIndex = i;
break;
}
}
taskNodes.addAll(committeeFilingUnitIndex, caseFilingResultNodes);
nodes.addAll(taskNodes);
nodes.addAll(feedbackNodes);
Map<String, NutMap> taskProposalCounts = proposalCommonService.countDashboardTaskProposals(sessionId);
for (NutMap node : nodes) {
if (node.getString("type").equals("task")) {
NutMap taskCount = taskProposalCounts.get(node.getString("id"));
node.put("todoCount", taskCount == null ? 0 : taskCount.getLong("todoCount", 0L));
node.put("doneCount", taskCount == null ? 0 : taskCount.getLong("doneCount", 0L));
} else if (node.getString("type").equals("total")) {
int count = dao.count(ProposalInfo.class, Cnd.where(ProposalInfo::getSessionId, "=", sessionId));
node.put("count", count);
} else if (node.getString("type").equals("caseFilingResult")) {
int count = dao.count(ProposalInfo.class, Cnd.where(ProposalInfo::getSessionId, "=", sessionId).and(ProposalInfo::getCaseFilingResult, "=", node.getString("id")));
node.put("count", count);
}
}
return Result.success(nodes);
} }
/**
* 接收看板筛选参数,返回与卡片统计范围一致的提案分页数据。
* @param pageForm pageNumber/pageSize 为页码和每页条数,pageOrderName/pageOrderBy 为排序参数
* @param sessionId 教代会届次ID;为空时返回空列表
* @param selectNodeId task 节点编码或立案结果字典编码
* @param selectNodeType task、total 或 caseFilingResult;为空时按总数查询
* @param selectNodeMode done 表示已办,其余值表示待办
* @return Result.data.list 为提案列表,Result.data.totalCount 为筛选后的总数
*/
@At @At
@SaCheckPermission("proposal.dashboard") @SaCheckPermission("proposal.dashboard")
public Result pageData(PageForm pageForm, String sessionId, String selectNodeId, String selectNodeType, String selectNodeMode) { public Result pageData(PageForm pageForm, String sessionId, String selectNodeId, String selectNodeType, String selectNodeMode) {
Sql sql = Sqls.create(""" return Result.success(proposalCommonService.pageDashboardData(pageForm, sessionId,
SELECT selectNodeId, selectNodeType, selectNodeMode));
info.*,
type.name AS typeName,
tcs.fullName AS sessionName,
tcd.`name` AS delegationName,
ins.id AS instanceId,
ins.businessNo,
ins.state instanceState,
ins.variable instanceVariable,
ins.processDefineId instanceProcessDefineId,
t.displayName curTaskName,
GROUP_CONCAT(DISTINCT CASE WHEN pru.isMaster = 1 THEN pru.unitName END) AS masterUnitName,
GROUP_CONCAT(DISTINCT CASE WHEN pru.isMaster = 0 THEN pru.unitName END) AS slaveUnitNames
FROM
proposal_info info
LEFT JOIN proposal_type type on type.id = info.typeId
LEFT JOIN teacher_congress_session tcs on tcs.id = info.sessionId
LEFT JOIN teacher_congress_delegation tcd on tcd.id = info.delegationId
LEFT JOIN teacher_congress_delegate tcde on tcde.loginName = info.createUserLoginName
LEFT JOIN wf_process_instance ins ON ins.businessNo = info.id
LEFT JOIN proposal_reply_unit pru ON pru.proposalId = info.id
LEFT JOIN wf_process_task t ON t.processInstanceId = ins.id AND t.taskState = @taskState
$condition
""");
Cnd cnd = Cnd.NEW();
cnd.and("info.sessionId", "=", sessionId);
cnd.groupBy("info.id");
// 默认显示进行中的任务
sql.setParam("taskState", ProcessTaskStateEnum.DOING.getCode());
if (StrUtil.isNotBlank(selectNodeType) && StrUtil.isNotBlank(selectNodeId)) {
switch (selectNodeType) {
case "task":
cnd.and("t.taskName", "=", selectNodeId);
if (selectNodeMode.equals("todo")) {
sql.setParam("taskState", ProcessTaskStateEnum.DOING.getCode());
} else {
sql.setParam("taskState", ProcessTaskStateEnum.FINISHED.getCode());
}
break;
case "total":
break;
case "caseFilingResult":
cnd.and("info.caseFilingResult", "=", selectNodeId);
break;
}
}
// 根据工作台字段白名单追加排序,未选择排序时保持原有查询顺序。
proposalCommonService.applySafePageOrder(cnd, pageForm, DASHBOARD_ORDER_COLUMNS);
sql.setCondition(cnd);
Pagination pagination = proposalCommonService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql);
return Result.success(pagination);
} }
} }
@@ -288,4 +288,22 @@ public interface ProposalCommonService extends BaseService<ProposalInfo> {
* @return 键为流程节点编码、值为待办和已办数量的统计结果 * @return 键为流程节点编码、值为待办和已办数量的统计结果
*/ */
Map<String, NutMap> countDashboardTaskProposals(String sessionId); Map<String, NutMap> countDashboardTaskProposals(String sessionId);
/**
* 查询看板卡片,统计范围与列表一致。
* @param sessionId 届次ID;为空时数量为零
* @return 包含节点编码、类型、名称和统计数量的卡片列表
*/
List<NutMap> listDashboardNodes(String sessionId);
/**
* 查询看板提案分页数据。
* @param pageForm 分页及排序参数
* @param sessionId 届次ID;为空时返回空列表
* @param selectNodeId 流程节点编码或立案结果编码
* @param selectNodeType task、total 或 caseFilingResult
* @param selectNodeMode done 表示已办,其余值表示待办
* @return 分页对象,list 为提案记录,totalCount 为相同条件下的提案总数
*/
Pagination pageDashboardData(PageForm pageForm, String sessionId, String selectNodeId,
String selectNodeType, String selectNodeMode);
} }
@@ -23,6 +23,10 @@ import com.budwk.app.base.utils.PageUtil;
import com.budwk.app.base.utils.SysOfficeTemplateUtil; import com.budwk.app.base.utils.SysOfficeTemplateUtil;
import com.budwk.app.flow.constant.FlowConst; import com.budwk.app.flow.constant.FlowConst;
import com.budwk.app.flow.engine.FlowEngine; import com.budwk.app.flow.engine.FlowEngine;
import com.budwk.app.flow.engine.model.ProcessModel;
import com.budwk.app.flow.engine.model.TaskModel;
import com.budwk.app.flow.entity.ProcessDefine;
import com.budwk.app.flow.service.ProcessDefineService;
import com.budwk.app.flow.engine.util.FlowUtil; import com.budwk.app.flow.engine.util.FlowUtil;
import com.budwk.app.flow.entity.ProcessInstance; import com.budwk.app.flow.entity.ProcessInstance;
import com.budwk.app.flow.entity.ProcessTask; import com.budwk.app.flow.entity.ProcessTask;
@@ -82,6 +86,24 @@ import java.util.stream.Collectors;
@IocBean(args = {"refer:dao"}) @IocBean(args = {"refer:dao"})
public class ProposalCommonServiceImpl extends BaseServiceImpl<ProposalInfo> implements ProposalCommonService { public class ProposalCommonServiceImpl extends BaseServiceImpl<ProposalInfo> implements ProposalCommonService {
/**
* 工作台表格允许排序的字段映射,值为查询中的真实字段或安全别名。
*/
private static final Map<String, String> DASHBOARD_ORDER_COLUMNS = Map.ofEntries(
Map.entry("code", "info.code"),
Map.entry("name", "info.name"),
Map.entry("createUserName", "info.createUserName"),
Map.entry("typeName", "typeName"),
Map.entry("delegationName", "delegationName"),
Map.entry("caseFilingResult", "info.caseFilingResult"),
Map.entry("caseFilingType", "info.caseFilingType"),
Map.entry("isConsolidation", "info.isConsolidation"),
Map.entry("masterUnitName", "masterUnitName"),
Map.entry("slaveUnitNames", "slaveUnitNames"),
Map.entry("curTaskName", "curTaskName"),
Map.entry("instanceState", "instanceState")
);
/** 确定立案提案的 Word 模板编码。 */ /** 确定立案提案的 Word 模板编码。 */
private static final String PROPOSAL_CONFIRM_FILING_TEMPLATE_CODE = "proposal_confirm_filing"; private static final String PROPOSAL_CONFIRM_FILING_TEMPLATE_CODE = "proposal_confirm_filing";
@@ -187,6 +209,8 @@ public class ProposalCommonServiceImpl extends BaseServiceImpl<ProposalInfo> imp
@Inject @Inject
private SysDictService sysDictService; private SysDictService sysDictService;
@Inject @Inject
private ProcessDefineService processDefineService;
@Inject
private SysUserService sysUserService; private SysUserService sysUserService;
@Inject @Inject
private TeacherCongressDelegateService teacherCongressDelegateService; private TeacherCongressDelegateService teacherCongressDelegateService;
@@ -506,6 +530,216 @@ public class ProposalCommonServiceImpl extends BaseServiceImpl<ProposalInfo> imp
return sql; return sql;
} }
/**
* 获取提案看板卡片,沿用现有节点顺序,数量与下方列表共用同一数据范围。
* @param sessionId 教代会届次ID;为空时所有卡片数量为零
* @return 卡片列表;任务卡片含 todoCount/doneCount,其他卡片含 count
*/
@Override
public List<NutMap> listDashboardNodes(String sessionId) {
List<NutMap> nodes = new ArrayList<>();
// 提案总数
nodes.add(NutMap.NEW().addv("name", "提案总数").addv("id", "total").addv("type", "total").addv("count", 0));
// 节点
ProcessDefine define = processDefineService.getLastByName("JDHTA_NC");
ProcessModel processModel = processDefineService.processDefineToModel(define);
// 按流程图的连线关系遍历任务节点,保证看板展示顺序与流程图一致。
List<TaskModel> taskModels = processModel.getModels(TaskModel.class);
List<String> excludeNodes = new ArrayList<>(3);
excludeNodes.add("撰写提案");
excludeNodes.add("邀请附议人");
excludeNodes.add("提案附议");
// opinion_unit_reply、two_unit_reply 为临时屏蔽的承办单位答复节点,不在看板展示。
List<String> hiddenNodeIds = List.of("opinion_unit_reply", "two_unit_reply");
List<NutMap> taskNodes = new ArrayList<>(taskModels.stream()
.filter(taskModel -> !excludeNodes.contains(taskModel.getDisplayName()))
.filter(taskModel -> !hiddenNodeIds.contains(taskModel.getName()))
.map(taskModel -> NutMap.NEW()
.addv("name", taskModel.getDisplayName())
.addv("id", taskModel.getName())
.addv("type", "task")
.addv("count", 0)
.addv("todoCount", 0)
.addv("doneCount", 0)
).toList());
// 立案结果
List<Sys_dict> caseFilingResult = sysDictService.getSubListByCode("PROPOSAL_CASE_FILING_RESULT");
List<NutMap> caseFilingResultNodes = new ArrayList<>();
for (Sys_dict dict : caseFilingResult) {
caseFilingResultNodes.add(NutMap.NEW()
.addv("name", dict.getName())
.addv("id", dict.getCode())
.addv("type", "caseFilingResult")
.addv("count", 0));
}
// 立案结果需展示在委员会确认承办单位之前,反馈评价统一固定到看板末尾。
List<NutMap> feedbackNodes = taskNodes.stream()
.filter(taskNode -> "feedback".equals(taskNode.getString("id")))
.toList();
taskNodes.removeIf(taskNode -> "feedback".equals(taskNode.getString("id")));
int committeeFilingUnitIndex = taskNodes.size();
for (int i = 0; i < taskNodes.size(); i++) {
if ("committeeFilingUnit".equals(taskNodes.get(i).getString("id"))) {
committeeFilingUnitIndex = i;
break;
}
}
taskNodes.addAll(committeeFilingUnitIndex, caseFilingResultNodes);
nodes.addAll(taskNodes);
nodes.addAll(feedbackNodes);
Map<String, Long> categoryCounts = countDashboardCategories(sessionId);
Map<String, NutMap> taskProposalCounts = countDashboardTaskProposals(sessionId);
for (NutMap node : nodes) {
if (node.getString("type").equals("task")) {
NutMap taskCount = taskProposalCounts.get(node.getString("id"));
node.put("todoCount", taskCount == null ? 0 : taskCount.getLong("todoCount", 0L));
node.put("doneCount", taskCount == null ? 0 : taskCount.getLong("doneCount", 0L));
} else if (node.getString("type").equals("total")) {
node.put("count", categoryCounts.getOrDefault("total", 0L));
} else if (node.getString("type").equals("caseFilingResult")) {
node.put("count", categoryCounts.getOrDefault(node.getString("id"), 0L));
}
}
return nodes;
}
/**
* 查询看板提案列表,统一使用附议完成后的提案范围,按提案ID去重。
* @param pageForm 分页和白名单排序参数
* @param sessionId 教代会届次ID;为空时返回空列表
* @param selectNodeId 节点编码或立案结果字典编码
* @param selectNodeType task、total、caseFilingResult;为空时查询总数列表
* @param selectNodeMode done 查询已办任务,其余值查询待办任务
* @return 含 list 和 totalCount 的分页结果
*/
@Override
public Pagination pageDashboardData(PageForm pageForm, String sessionId, String selectNodeId,
String selectNodeType, String selectNodeMode) {
Sql sql = Sqls.create("""
SELECT
info.*,
type.name AS typeName,
tcs.fullName AS sessionName,
tcd.`name` AS delegationName,
ins.id AS instanceId,
ins.businessNo,
ins.state instanceState,
ins.variable instanceVariable,
ins.processDefineId instanceProcessDefineId,
t.displayName curTaskName,
GROUP_CONCAT(DISTINCT CASE WHEN pru.isMaster = 1 THEN pru.unitName END) AS masterUnitName,
GROUP_CONCAT(DISTINCT CASE WHEN pru.isMaster = 0 THEN pru.unitName END) AS slaveUnitNames
FROM
proposal_info info
LEFT JOIN proposal_type type on type.id = info.typeId
LEFT JOIN teacher_congress_session tcs on tcs.id = info.sessionId
LEFT JOIN teacher_congress_delegation tcd on tcd.id = info.delegationId
LEFT JOIN teacher_congress_delegate tcde on tcde.loginName = info.createUserLoginName
LEFT JOIN wf_process_instance ins ON ins.businessNo = info.id
LEFT JOIN proposal_reply_unit pru ON pru.proposalId = info.id
LEFT JOIN wf_process_task t ON t.processInstanceId = ins.id AND t.taskState = @taskState
$condition
""");
Cnd cnd = buildDashboardCondition(sessionId);
cnd.groupBy("info.id");
// 默认显示进行中的任务
sql.setParam("taskState", ProcessTaskStateEnum.DOING.getCode());
if (StrUtil.isNotBlank(selectNodeType) && StrUtil.isNotBlank(selectNodeId)) {
switch (selectNodeType) {
case "task":
cnd.and("t.taskName", "=", selectNodeId);
if (!"done".equals(selectNodeMode)) {
sql.setParam("taskState", ProcessTaskStateEnum.DOING.getCode());
} else {
sql.setParam("taskState", ProcessTaskStateEnum.FINISHED.getCode());
}
break;
case "total":
break;
case "caseFilingResult":
cnd.and("info.caseFilingResult", "=", selectNodeId);
break;
}
}
// 只接受白名单排序字段,复用现有分页能力。
applySafePageOrder(cnd, pageForm, DASHBOARD_ORDER_COLUMNS);
sql.setCondition(cnd);
return listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql);
}
/**
* 统计看板总数及立案结果;一次按结果分组后求和,确保卡片与列表口径一致。
* @param sessionId 教代会届次ID
* @return total 为提案总数,其他键为立案结果编码
*/
private Map<String, Long> countDashboardCategories(String sessionId) {
Sql sql = Sqls.create("SELECT info.caseFilingResult, COUNT(1) AS proposalCount FROM proposal_info info $condition");
Cnd cnd = buildDashboardCondition(sessionId);
cnd.groupBy("info.caseFilingResult");
sql.setCondition(cnd);
Map<String, Long> counts = new HashMap<>();
long total = 0L;
for (NutMap row : listMap(sql)) {
long count = row.getLong("proposalCount", 0L);
total += count;
if (StrUtil.isNotBlank(row.getString("caseFilingResult"))) {
counts.put(row.getString("caseFilingResult"), count);
}
}
counts.put("total", total);
return counts;
}
/**
* 构建看板统一范围:迁入提案按参考业务阶段排除草稿、附议阶段;新提案按实际WF任务判断。
* 历史迁入实例可能缺少当前任务,不能仅因缺任务而漏算已进入审核的历史提案。
* 新WF提案的旧 stateCode 不随每个节点更新,必须读取有效审核任务,并排除已退回撰写/附议的实例。
* @param sessionId 届次ID;清空选择时返回空范围,避免跨届次查询
* @return 仅包含筛选条件、不包含排序和分组的 Cnd
*/
private Cnd buildDashboardCondition(String sessionId) {
Cnd cnd = Cnd.where("info.sessionId", "=", StrUtil.blankToDefault(sessionId, ""));
cnd.and(new Static("""
EXISTS (
SELECT 1 FROM wf_process_instance scopeIns
WHERE scopeIns.businessNo = info.id
AND (
(JSON_UNQUOTE(JSON_EXTRACT(scopeIns.variable, '$.legacyV3')) = 'true' AND info.stateCode >= 200)
OR (
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(scopeIns.variable, '$.legacyV3')), 'false') <> 'true'
AND EXISTS (
SELECT 1 FROM wf_process_task scopeTask
WHERE scopeTask.processInstanceId = scopeIns.id
AND scopeTask.taskName NOT IN ('startTask', 'invite', 'second')
AND scopeTask.taskState IN (10, 20)
)
AND NOT EXISTS (
SELECT 1 FROM wf_process_task earlyTask
WHERE earlyTask.processInstanceId = scopeIns.id
AND earlyTask.taskName IN ('startTask', 'invite', 'second')
AND earlyTask.taskState = 10
)
)
)
)
"""));
return cnd;
}
/** /**
* 按提案ID去重统计数据看板的流程节点数量。 * 按提案ID去重统计数据看板的流程节点数量。
* 同一提案因撤回、退回等再次经过同一节点时,任务表会保留多条已办记录; * 同一提案因撤回、退回等再次经过同一节点时,任务表会保留多条已办记录;
@@ -524,10 +758,11 @@ public class ProposalCommonServiceImpl extends BaseServiceImpl<ProposalInfo> imp
FROM wf_process_task t FROM wf_process_task t
INNER JOIN wf_process_instance ins ON ins.id = t.processInstanceId INNER JOIN wf_process_instance ins ON ins.id = t.processInstanceId
INNER JOIN proposal_info info ON info.id = ins.businessNo INNER JOIN proposal_info info ON info.id = ins.businessNo
WHERE info.sessionId = @sessionId $condition
GROUP BY t.taskName
"""); """);
sql.setParam("sessionId", sessionId); Cnd cnd = buildDashboardCondition(sessionId);
cnd.groupBy("t.taskName");
sql.setCondition(cnd);
sql.setParam("doingState", ProcessTaskStateEnum.DOING.getCode()); sql.setParam("doingState", ProcessTaskStateEnum.DOING.getCode());
sql.setParam("finishedState", ProcessTaskStateEnum.FINISHED.getCode()); sql.setParam("finishedState", ProcessTaskStateEnum.FINISHED.getCode());
List<NutMap> taskCounts = listMap(sql); List<NutMap> taskCounts = listMap(sql);
@@ -206,6 +206,15 @@ layout("/layouts/platform.html"){
<dict-tag :options="dict.type.PROPOSAL_CASE_FILING_RESULT" :value="row.caseFilingResult"></dict-tag> <dict-tag :options="dict.type.PROPOSAL_CASE_FILING_RESULT" :value="row.caseFilingResult"></dict-tag>
</template> </template>
</el-table-column> </el-table-column>
<!-- 是否重点复用立案类型字段;未立案与类型未填写分开展示,避免空值被误认为普通提案。 -->
<el-table-column label="是否重点" prop="caseFilingType" sortable="custom" width="110">
<template slot-scope="{row}">
<dict-tag v-if="row.caseFilingResult === 'CONFIRM_FILING' && row.caseFilingType"
:options="dict.type.PROPOSAL_CASE_FILING_TYPE" :value="row.caseFilingType"></dict-tag>
<span v-else-if="!row.caseFilingResult || row.caseFilingResult === 'CONFIRM_FILING'">未确定</span>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="是否并案" prop="isConsolidation" sortable="custom" width="100"> <el-table-column label="是否并案" prop="isConsolidation" sortable="custom" width="100">
<template scope="{row}"> <template scope="{row}">
<el-tag size="mini" v-if="row.isConsolidation" type="success"></el-tag> <el-tag size="mini" v-if="row.isConsolidation" type="success"></el-tag>
@@ -241,7 +250,7 @@ layout("/layouts/platform.html"){
new Vue({ new Vue({
el: '#app', el: '#app',
dicts: ["PROPOSAL_TYPE", "PROPOSAL_CASE_FILING_RESULT"], dicts: ["PROPOSAL_TYPE", "PROPOSAL_CASE_FILING_RESULT", "PROPOSAL_CASE_FILING_TYPE"],
components: { components: {
"proposal-info": PROPOSAL_INFO "proposal-info": PROPOSAL_INFO
}, },
@@ -252,6 +261,8 @@ layout("/layouts/platform.html"){
nodes: [], nodes: [],
nodeDisplayModes: {}, // 存储每个节点的显示模式 nodeDisplayModes: {}, // 存储每个节点的显示模式
pageForm: { pageForm: {
sessionId: null,
selectNodeMode: "todo",
selectNodeId: null, selectNodeId: null,
selectNodeType: null, selectNodeType: null,
pageSize: 5 pageSize: 5
@@ -268,6 +279,10 @@ layout("/layouts/platform.html"){
// 届次改变 // 届次改变
sessionChange() { sessionChange() {
// 切换届次后回到总数卡片,避免沿用上一届次的分类筛选。
this.$set(this.pageForm, "selectNodeId", null)
this.$set(this.pageForm, "selectNodeType", null)
this.$set(this.pageForm, "selectNodeMode", "todo")
this.getNodes() this.getNodes()
this.doSearch() this.doSearch()
}, },
@@ -276,8 +291,8 @@ layout("/layouts/platform.html"){
listSession() { listSession() {
this.$axios.post("/platform/proposal/common/listSession").then((res) => { this.$axios.post("/platform/proposal/common/listSession").then((res) => {
if (res.code === 0) { if (res.code === 0) {
this.sessionOptions = res.data this.$set(this, "sessionOptions", res.data || [])
if (this.sessionOptions) { if (this.sessionOptions.length) {
this.$set(this.pageForm, "sessionId", this.sessionOptions[0].id) this.$set(this.pageForm, "sessionId", this.sessionOptions[0].id)
this.getNodes() this.getNodes()
this.doSearch() this.doSearch()
@@ -290,7 +305,7 @@ layout("/layouts/platform.html"){
getNodes() { getNodes() {
this.$axios.post('/platform/proposal/dashboard/listNode', {sessionId: this.pageForm.sessionId}).then(res => { this.$axios.post('/platform/proposal/dashboard/listNode', {sessionId: this.pageForm.sessionId}).then(res => {
if (res.code === 0) { if (res.code === 0) {
this.nodes = res.data this.$set(this, "nodes", res.data || [])
} }
}) })
}, },
@@ -303,9 +318,9 @@ layout("/layouts/platform.html"){
// 节点选择 // 节点选择
selectNode(node, index) { selectNode(node, index) {
this.pageForm.selectNodeId = node.id this.$set(this.pageForm, "selectNodeId", node.id)
this.pageForm.selectNodeType = node.type this.$set(this.pageForm, "selectNodeType", node.type)
this.pageForm.selectNodeMode = this.getNodeDisplayMode(node.id) this.$set(this.pageForm, "selectNodeMode", this.getNodeDisplayMode(node.id))
this.doSearch() this.doSearch()
}, },
@@ -297,10 +297,11 @@ layout("/layouts/platform.html"){
{{memberNumberData.memberNum}} {{memberNumberData.memberNum}}
<span style="font-size: 14px">&ensp;</span> <span style="font-size: 14px">&ensp;</span>
</div> </div>
<!-- <div class="stat-meta" v-loading="loading.memberNumber">--> <div class="stat-meta" style="text-align: right" v-loading="loading.memberNumber">
<!-- 年同比 {{memberNumberData.ratio}}&ensp;--> 年同比 {{memberNumberData.ratio}}&ensp;
<!-- <i :class="memberNumberData.diff >= 0 ? 'el-icon-top trend-up' : 'el-icon-bottom trend-down'"></i>--> <i v-if="memberNumberData.diff > 0" class="el-icon-top trend-up"></i>
<!-- </div>--> <i v-else-if="memberNumberData.diff < 0" class="el-icon-bottom trend-down"></i>
</div>
</div> </div>
<div class="card stats-card"> <div class="card stats-card">
@@ -393,8 +394,8 @@ layout("/layouts/platform.html"){
memberNumberData: { memberNumberData: {
memberNum: "--", memberNum: "--",
lastYearMemberNum: "--", lastYearMemberNum: "--",
ratio: "0.0%", ratio: "",
diff: 0 diff: null
}, },
memberPercentage: "--", memberPercentage: "--",
unionMember: [] unionMember: []
@@ -403,23 +404,39 @@ layout("/layouts/platform.html"){
}, },
methods: { methods: {
/** /**
* 会员数 * 无需传参;接口返回 memberNum 当前会员数和 lastYearMemberNum 去年归档会员数。
* @returns {Promise<void>} * @returns {Promise<void>} 更新会员数、同比及涨跌方向,请求结束后关闭加载状态。
*/ */
async getMemberNumber() { getMemberNumber() {
this.loading.memberNumber = true this.$set(this.loading, "memberNumber", true)
this.$axios.post("/platform/member/info/board/memberNumber").then((resp) => { return this.$axios.post("/platform/member/info/board/memberNumber").then((resp) => {
if (resp.code === 0) { if (resp.code === 0) {
const { const data = resp.data || {}
data: { memberNum, lastYearMemberNum } // 兼容数字字符串,但不把空值、布尔值或异常人数转换成有效的零人数。
} = resp const memberNum = typeof data.memberNum === "number" || (typeof data.memberNum === "string" && data.memberNum.trim() !== "") ? Number(data.memberNum) : NaN
const diff = memberNum - lastYearMemberNum const lastYearMemberNum = typeof data.lastYearMemberNum === "number" || (typeof data.lastYearMemberNum === "string" && data.lastYearMemberNum.trim() !== "") ? Number(data.lastYearMemberNum) : NaN
const ratio = (lastYearMemberNum ? (Math.abs(diff) / lastYearMemberNum) * 100 : (diff / 1) * 10).toFixed(2) + " %" const validMemberNum = Number.isSafeInteger(memberNum) && memberNum >= 0
this.memberNumberData = { memberNum, lastYearMemberNum, diff, ratio } const validLastYearMemberNum = Number.isSafeInteger(lastYearMemberNum) && lastYearMemberNum >= 0
this.loading.memberNumber = false let diff = null
let ratio = "—"
// 去年为零或没有归档人数时无法计算同比;仅在基数有效且大于零时显示涨跌。
if (validMemberNum && validLastYearMemberNum && lastYearMemberNum > 0) {
diff = memberNum - lastYearMemberNum
// 先按万分比对涨跌幅绝对值四舍五入,再恢复负号,避免 4.525% 被直接格式化成 4.52%。
const changePercent = Math.round(Math.abs(diff) * 10000 / lastYearMemberNum) / 100
ratio = diff === 0 ? "持平" : (diff < 0 && changePercent > 0 ? "-" : "") + changePercent.toFixed(2) + " %"
}
this.$set(this, "memberNumberData", {
memberNum: validMemberNum ? memberNum : "--",
lastYearMemberNum: validLastYearMemberNum ? lastYearMemberNum : "--",
diff: diff,
ratio: ratio
})
} else { } else {
this.$message.warning(resp.msg) this.$message.warning(resp.msg)
} }
}).finally(() => {
this.$set(this.loading, "memberNumber", false)
}) })
}, },
/** /**
@@ -484,18 +501,23 @@ layout("/layouts/platform.html"){
}) })
}, },
/** /**
* 会员占比 * 无需传参;接口 data 为 0~1 的会员比例,兼容数值字符串后用于水波图。
* @returns {Promise<void>} * @returns {Promise<void>} 完成比例校验和图表更新,并在请求结束时关闭加载状态。
*/ */
async getMemberPercentage() { getMemberPercentage() {
this.loading.memberPercentage = true this.$set(this.loading, "memberPercentage", true)
this.$axios.post("/platform/member/info/board/memberPercentage").then((resp) => { return this.$axios.post("/platform/member/info/board/memberPercentage").then((resp) => {
this.loading.memberPercentage = false
if (resp.code === 0) { if (resp.code === 0) {
const { data } = resp const { data } = resp
this.memberPercentage = data.toFixed(2) // 空值、非数值和超出比例范围的数据不能作为 0% 展示;合法的零值需要保留。
const percent = typeof data === "number" || (typeof data === "string" && data.trim() !== "") ? Number(data) : NaN
if (!Number.isFinite(percent) || percent < 0 || percent > 1) {
this.$message.warning("会员占比数据异常,请稍后重试")
return
}
this.$set(this, "memberPercentage", percent.toFixed(2))
if (chart.memberPercentageChart) { if (chart.memberPercentageChart) {
chart.memberPercentageChart.changeData(data) chart.memberPercentageChart.changeData(percent)
return return
} }
chart.memberPercentageChart = new G2Plot.Liquid("memberPercentageChart", { chart.memberPercentageChart = new G2Plot.Liquid("memberPercentageChart", {
@@ -507,13 +529,17 @@ layout("/layouts/platform.html"){
length: 128 length: 128
}, },
autoFit: true, autoFit: true,
percent: data, percent: percent,
color: '#217050', color: '#217050',
statistic: { statistic: {
content: { content: {
style: { style: {
fontSize: 16, fontSize: 16,
fill: '#fff', // 色文字 // 色文字配浅色描边,确保低水位白底和高水位绿底上的百分比均可读。
fill: '#1f2937',
fontWeight: 'bold',
stroke: '#fff',
lineWidth: 0.5,
} }
} }
} }
@@ -522,6 +548,8 @@ layout("/layouts/platform.html"){
} else { } else {
this.$message.warning(resp.msg) this.$message.warning(resp.msg)
} }
}).finally(() => {
this.$set(this.loading, "memberPercentage", false)
}) })
}, },
/** /**