Compare commits
2 Commits
9c64dd8451
...
365fc08b62
| Author | SHA1 | Date | |
|---|---|---|---|
| 365fc08b62 | |||
| 50f3f15f48 |
@@ -0,0 +1,64 @@
|
||||
package com.ruoyi.oa.controller;
|
||||
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.R;
|
||||
import com.ruoyi.oa.domain.bo.OaProjectScheduleBo;
|
||||
import com.ruoyi.oa.domain.bo.OaProjectScheduleStepBo;
|
||||
import com.ruoyi.oa.domain.vo.OaProjectScheduleStepVo;
|
||||
import com.ruoyi.oa.domain.vo.OaProjectScheduleVo;
|
||||
import com.ruoyi.oa.domain.vo.OaScheduleListVo;
|
||||
import com.ruoyi.oa.service.IOaProjectScheduleService;
|
||||
import com.ruoyi.oa.service.IOaProjectScheduleStepService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 项目进度(主表 + 子表聚合)
|
||||
*
|
||||
* 该控制器用于满足综合看板接口规范:
|
||||
* - GET /oa/schedule/list?projectId=xx
|
||||
*
|
||||
* 复用现有服务:
|
||||
* - /oa/projectSchedule(主表)
|
||||
* - /oa/projectScheduleStep(子表)
|
||||
*/
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/oa/schedule")
|
||||
public class OaScheduleController extends BaseController {
|
||||
|
||||
private final IOaProjectScheduleService projectScheduleService;
|
||||
private final IOaProjectScheduleStepService projectScheduleStepService;
|
||||
|
||||
/**
|
||||
* 获取进度主表 + 子表数据
|
||||
*
|
||||
* @param projectId 项目ID
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
public R<List<OaScheduleListVo>> list(@RequestParam("projectId") Long projectId) {
|
||||
OaProjectScheduleBo scheduleBo = new OaProjectScheduleBo();
|
||||
scheduleBo.setProjectId(projectId);
|
||||
List<OaProjectScheduleVo> schedules = projectScheduleService.queryList(scheduleBo);
|
||||
|
||||
List<OaScheduleListVo> result = new ArrayList<>();
|
||||
for (OaProjectScheduleVo schedule : schedules) {
|
||||
List<OaProjectScheduleStepVo> steps = projectScheduleStepService.selectProjectScheduleStepList(schedule.getScheduleId());
|
||||
|
||||
OaScheduleListVo vo = new OaScheduleListVo();
|
||||
vo.setSchedule(schedule);
|
||||
vo.setStepList(steps);
|
||||
result.add(vo);
|
||||
}
|
||||
return R.ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,17 @@ public class SysOaProjectController extends BaseController {
|
||||
return R.ok(iSysOaProjectService.getMaxCode(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目综合看板聚合数据(项目 + 任务 + 进度主表 + 步骤)
|
||||
* 必须声明在 /{projectId} 之前,避免部分环境下通配路径优先匹配导致 404。
|
||||
* 菜单权限请在 sys_menu 中配置后使用全局路由守卫或接口级注解单独加回。
|
||||
*/
|
||||
@GetMapping("/dashboard/{projectId}")
|
||||
public R<OaProjectDashboardVo> dashboard(@NotNull(message = "主键不能为空") @PathVariable Long projectId) {
|
||||
OaProjectDashboardVo data = iSysOaProjectService.getProjectDashboard(projectId);
|
||||
return data == null ? R.fail("项目不存在") : R.ok(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目管理详细信息
|
||||
*
|
||||
|
||||
@@ -201,6 +201,12 @@ public class SysOaProjectBo extends BaseEntity {
|
||||
private Long customerId;
|
||||
//客户名称
|
||||
private String customerName;
|
||||
|
||||
/**
|
||||
* 综合模糊查询(名称 / 编号 / 代号 任一匹配),与 projectName、projectNum、projectCode 互斥:有值时优先使用本字段
|
||||
*/
|
||||
private String keyword;
|
||||
|
||||
//是否置顶
|
||||
private Integer isTop;
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ruoyi.oa.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 项目综合看板聚合数据
|
||||
*/
|
||||
@Data
|
||||
public class OaProjectDashboardVo implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private SysOaProjectVo project;
|
||||
|
||||
private List<SysOaTaskVo> tasks;
|
||||
|
||||
private List<OaProjectScheduleVo> schedules;
|
||||
|
||||
private List<OaProjectScheduleStepVo> steps;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.ruoyi.oa.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 项目进度列表(主表 + 子表)
|
||||
*
|
||||
* 对应表:
|
||||
* - oa_project_schedule(主表)
|
||||
* - oa_project_schedule_step(子表)
|
||||
*
|
||||
* 该 VO 用于接口:GET /oa/schedule/list
|
||||
*/
|
||||
@Data
|
||||
public class OaScheduleListVo implements Serializable {
|
||||
|
||||
/** 进度主表 */
|
||||
private OaProjectScheduleVo schedule;
|
||||
|
||||
/** 进度子表步骤 */
|
||||
private List<OaProjectScheduleStepVo> stepList;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.ruoyi.oa.domain.vo;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
|
||||
import com.alibaba.excel.annotation.ExcelProperty;
|
||||
@@ -218,11 +216,28 @@ public class SysOaTaskVo {
|
||||
//部门名称
|
||||
private String deptName;
|
||||
|
||||
//关联项目进度id
|
||||
/** 关联进度子表主键 oa_project_schedule_step.track_id */
|
||||
private Long trackId;
|
||||
|
||||
//联查的节点信息
|
||||
private String tabNode;
|
||||
private String firstLevelNode;
|
||||
private String secondLevelNode;
|
||||
|
||||
/** 进度步骤名称 oa_project_schedule_step.step_name(综合看板联表) */
|
||||
private String scheduleStepName;
|
||||
|
||||
/** 进度步骤状态 0未开始 1进行中 2完成 3暂停 */
|
||||
private Long scheduleStatus;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd")
|
||||
private Date schedulePlanStart;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd")
|
||||
private Date schedulePlanEnd;
|
||||
|
||||
/** 进度负责人 oa_project_schedule_step.header */
|
||||
private String scheduleHeader;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,11 @@ public interface OaProjectScheduleStepMapper extends BaseMapperPlus<OaProjectSch
|
||||
|
||||
void saveBatch(List<OaProjectScheduleStep> entities);
|
||||
|
||||
/**
|
||||
* 按 schedule_id 查询步骤列表(综合看板使用)
|
||||
*/
|
||||
List<OaProjectScheduleStepVo> selectProjectScheduleStepList(@Param("scheduleId") Long scheduleId);
|
||||
|
||||
/**
|
||||
* 根据 schedule_id 批量删除步骤记录
|
||||
* @param scheduleIds 主表的 schedule_id 集合
|
||||
|
||||
@@ -46,6 +46,11 @@ public interface SysOaTaskMapper extends BaseMapperPlus<SysOaTaskMapper, SysOaTa
|
||||
|
||||
List<SysOaTaskVo> listDocumentProject(Long projectId);
|
||||
|
||||
/**
|
||||
* 综合看板:某项目下任务(一行一条任务)
|
||||
*/
|
||||
List<SysOaTaskVo> selectDashboardTasksByProjectId(@Param("projectId") Long projectId);
|
||||
|
||||
/**
|
||||
* 新增:自定义查询任务列表(Plus版)
|
||||
*/
|
||||
|
||||
@@ -38,6 +38,11 @@ public interface IOaProjectScheduleStepService{
|
||||
*/
|
||||
List<OaProjectScheduleStepVo> queryList(OaProjectScheduleStepBo bo);
|
||||
|
||||
/**
|
||||
* 按 scheduleId 查询进度步骤列表(综合看板聚合接口使用)
|
||||
*/
|
||||
List<OaProjectScheduleStepVo> selectProjectScheduleStepList(Long scheduleId);
|
||||
|
||||
/**
|
||||
* 新增项目进度步骤跟踪
|
||||
*/
|
||||
|
||||
@@ -103,4 +103,9 @@ public interface ISysOaProjectService {
|
||||
Boolean postponeProject(SysOaProject bo);
|
||||
|
||||
SysOaProjectVo getMaxCode(String prefix);
|
||||
|
||||
/**
|
||||
* 综合看板:项目详情 + 任务 + 进度主表 + 进度步骤(一次返回)
|
||||
*/
|
||||
OaProjectDashboardVo getProjectDashboard(Long projectId);
|
||||
}
|
||||
|
||||
@@ -109,6 +109,11 @@ public interface ISysOaTaskService {
|
||||
* @return
|
||||
*/
|
||||
List<SysOaTaskVo> listDocumentProject(Long projectId);
|
||||
|
||||
/**
|
||||
* 综合看板任务列表(完整字段,无 task_item 重复行)
|
||||
*/
|
||||
List<SysOaTaskVo> listDashboardTasks(Long projectId);
|
||||
/**
|
||||
* 新增:自定义查询任务列表(Plus版)
|
||||
*/
|
||||
|
||||
@@ -68,7 +68,8 @@ public class OaProjectScheduleServiceImpl implements IOaProjectScheduleService {
|
||||
*/
|
||||
@Override
|
||||
public TableDataInfo<OaProjectScheduleVo> queryPageList(OaProjectScheduleBo bo, PageQuery pageQuery) {
|
||||
QueryWrapper<OaProjectSchedule> lqw = buildQueryWrapper(bo);
|
||||
// 分页列表走自定义 Join SQL(XML 中存在 ops/op 别名)
|
||||
QueryWrapper<OaProjectSchedule> lqw = buildQueryWrapperJoin(bo);
|
||||
Page<OaProjectScheduleVo> result = baseMapper.selectVoPagePlus(pageQuery.build(), lqw);
|
||||
return TableDataInfo.build(result);
|
||||
}
|
||||
@@ -78,11 +79,17 @@ public class OaProjectScheduleServiceImpl implements IOaProjectScheduleService {
|
||||
*/
|
||||
@Override
|
||||
public List<OaProjectScheduleVo> queryList(OaProjectScheduleBo bo) {
|
||||
QueryWrapper<OaProjectSchedule> lqw = buildQueryWrapper(bo);
|
||||
// 非分页列表走 BaseMapper 默认 SQL(无 ops/op 别名),避免出现 ops.del_flag 这类不存在的列引用
|
||||
QueryWrapper<OaProjectSchedule> lqw = buildQueryWrapperBase(bo);
|
||||
return baseMapper.selectVoList(lqw);
|
||||
}
|
||||
|
||||
private QueryWrapper<OaProjectSchedule> buildQueryWrapper(OaProjectScheduleBo bo) {
|
||||
/**
|
||||
* Join 场景查询条件(对应 XML: OaProjectScheduleMapper.selectVoPagePlus)
|
||||
* - 主表别名:ops (oa_project_schedule)
|
||||
* - 项目表别名:op (sys_oa_project)
|
||||
*/
|
||||
private QueryWrapper<OaProjectSchedule> buildQueryWrapperJoin(OaProjectScheduleBo bo) {
|
||||
Map<String, Object> params = bo.getParams();
|
||||
QueryWrapper<OaProjectSchedule> lqw = Wrappers.query();
|
||||
lqw.eq("ops.del_flag", 0);
|
||||
@@ -112,6 +119,27 @@ public class OaProjectScheduleServiceImpl implements IOaProjectScheduleService {
|
||||
return lqw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base 表场景查询条件(对应 BaseMapper 默认 SQL:FROM oa_project_schedule)
|
||||
* 仅使用 oa_project_schedule 表字段,严格对齐表结构:
|
||||
* - oa_project_schedule(project_id、schedule_id、...、del_flag)
|
||||
*/
|
||||
private QueryWrapper<OaProjectSchedule> buildQueryWrapperBase(OaProjectScheduleBo bo) {
|
||||
QueryWrapper<OaProjectSchedule> lqw = Wrappers.query();
|
||||
// TableLogic 字段,默认查询只取未删除
|
||||
lqw.eq("del_flag", 0);
|
||||
lqw.eq(bo.getProjectId() != null, "project_id", bo.getProjectId());
|
||||
lqw.eq(bo.getTemplateId() != null, "template_id", bo.getTemplateId());
|
||||
lqw.eq(bo.getCurrentStep() != null, "current_step", bo.getCurrentStep());
|
||||
lqw.eq(bo.getStatus() != null, "status", bo.getStatus());
|
||||
lqw.eq(bo.getSteward() != null, "steward", bo.getSteward());
|
||||
// 使用 startTime / endTime 进行范围筛选
|
||||
lqw.between(bo.getStartTime() != null && bo.getEndTime() != null, "start_time", bo.getStartTime(), bo.getEndTime());
|
||||
lqw.ge(bo.getStartTime() != null && bo.getEndTime() == null, "start_time", bo.getStartTime());
|
||||
lqw.le(bo.getStartTime() == null && bo.getEndTime() != null, "end_time", bo.getEndTime());
|
||||
return lqw;
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增项目进度
|
||||
*/
|
||||
|
||||
@@ -95,13 +95,13 @@ public class OaProjectScheduleStepServiceImpl implements IOaProjectScheduleStepS
|
||||
*/
|
||||
@Override
|
||||
public TableDataInfo<OaProjectScheduleStepVo> queryPageList(OaProjectScheduleStepBo bo, PageQuery pageQuery) {
|
||||
QueryWrapper<OaProjectScheduleStep> lqw = buildQueryWrapper(bo);
|
||||
QueryWrapper<OaProjectScheduleStep> lqw = buildQueryWrapper(bo, true);
|
||||
Page<OaProjectScheduleStepVo> result = baseMapper.selectVoPagePlus(pageQuery.build(), lqw,LoginHelper.getUserId());
|
||||
return TableDataInfo.build(result);
|
||||
}
|
||||
@Override
|
||||
public TableDataInfo<OaProjectScheduleStepVo> queryPageListPage(OaProjectScheduleStepBo bo, PageQuery pageQuery) {
|
||||
QueryWrapper<OaProjectScheduleStep> lqw = buildQueryWrapper(bo);
|
||||
QueryWrapper<OaProjectScheduleStep> lqw = buildQueryWrapper(bo, true);
|
||||
Page<OaProjectScheduleStepVo> result = baseMapper.selectVoPageNew(pageQuery.build(), lqw);
|
||||
return TableDataInfo.build(result);
|
||||
}
|
||||
@@ -113,22 +113,13 @@ public class OaProjectScheduleStepServiceImpl implements IOaProjectScheduleStepS
|
||||
return TableDataInfo.build(result);
|
||||
}
|
||||
private QueryWrapper<OaProjectScheduleStep> buildQueryMyWrapper(OaProjectScheduleStepBo bo) {
|
||||
Map<String, Object> params = bo.getParams();
|
||||
QueryWrapper<OaProjectScheduleStep> lqw = Wrappers.query();
|
||||
lqw.eq(bo.getScheduleId() != null, "opss.schedule_id", bo.getScheduleId());
|
||||
lqw.eq("opss.del_flag", 0);
|
||||
lqw.eq(bo.getStepOrder() != null, "opss.step_order", bo.getStepOrder());
|
||||
lqw.like(StringUtils.isNotBlank(bo.getStepName()), "opss.step_name", bo.getStepName());
|
||||
lqw.eq(bo.getStatus() != null, "opss.status", bo.getStatus());
|
||||
appendCommonStepFilters(lqw, bo, "opss");
|
||||
// 默认将负责人设置为当前用户
|
||||
String currentUser = LoginHelper.getNickName();
|
||||
lqw.eq(StringUtils.isNotBlank(currentUser), "opss.node_header", currentUser);
|
||||
lqw.eq(bo.getSupplierId() != null, "opss.supplier_id", bo.getSupplierId());
|
||||
//根据开始时间和结束时间作为范围判断planEnd
|
||||
lqw.ge(bo.getStartTime() != null, "opss.plan_end", bo.getStartTime());
|
||||
lqw.le(bo.getEndTime() != null, "opss.plan_end", bo.getEndTime());
|
||||
lqw.eq(StringUtils.isNotBlank(currentUser), stepCol("opss", "node_header"), currentUser);
|
||||
// 按时间倒序排列,已完成的排在最后
|
||||
lqw.orderByDesc("opss.plan_end");
|
||||
lqw.orderByDesc(stepCol("opss", "plan_end"));
|
||||
lqw.orderByAsc("opss.status = 2"); // 状态为2表示已完成,将其排在最后
|
||||
return lqw;
|
||||
}
|
||||
@@ -137,26 +128,41 @@ public class OaProjectScheduleStepServiceImpl implements IOaProjectScheduleStepS
|
||||
*/
|
||||
@Override
|
||||
public List<OaProjectScheduleStepVo> queryList(OaProjectScheduleStepBo bo) {
|
||||
QueryWrapper<OaProjectScheduleStep> lqw = buildQueryWrapper(bo);
|
||||
QueryWrapper<OaProjectScheduleStep> lqw = buildQueryWrapper(bo, false);
|
||||
return baseMapper.selectVoList(lqw);
|
||||
}
|
||||
|
||||
private QueryWrapper<OaProjectScheduleStep> buildQueryWrapper(OaProjectScheduleStepBo bo) {
|
||||
Map<String, Object> params = bo.getParams();
|
||||
@Override
|
||||
public List<OaProjectScheduleStepVo> selectProjectScheduleStepList(Long scheduleId) {
|
||||
return baseMapper.selectProjectScheduleStepList(scheduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param forJoinSql true:XML 中带 opss/schedule 等多表别名,条件须加 opss. 前缀,避免 schedule_id 等列歧义
|
||||
*/
|
||||
private QueryWrapper<OaProjectScheduleStep> buildQueryWrapper(OaProjectScheduleStepBo bo, boolean forJoinSql) {
|
||||
QueryWrapper<OaProjectScheduleStep> lqw = Wrappers.query();
|
||||
lqw.eq(bo.getScheduleId() != null, "opss.schedule_id", bo.getScheduleId());
|
||||
lqw.eq("opss.del_flag", 0);
|
||||
lqw.eq(bo.getStepOrder() != null, "opss.step_order", bo.getStepOrder());
|
||||
lqw.like(StringUtils.isNotBlank(bo.getStepName()), "opss.step_name", bo.getStepName());
|
||||
lqw.eq(bo.getStatus() != null, "opss.status", bo.getStatus());
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getNodeHeader()), "opss.node_header", bo.getNodeHeader());
|
||||
lqw.eq(bo.getSupplierId() != null, "opss.supplier_id", bo.getSupplierId());
|
||||
//根据开始时间和结束时间作为范围判断planEnd
|
||||
lqw.ge(bo.getStartTime() != null, "opss.plan_end", bo.getStartTime());
|
||||
lqw.le(bo.getEndTime() != null, "opss.plan_end", bo.getEndTime());
|
||||
String alias = forJoinSql ? "opss" : null;
|
||||
appendCommonStepFilters(lqw, bo, alias);
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getNodeHeader()), stepCol(alias, "node_header"), bo.getNodeHeader());
|
||||
return lqw;
|
||||
}
|
||||
|
||||
private static String stepCol (String tableAlias, String column) {
|
||||
return StringUtils.isBlank(tableAlias) ? column : tableAlias + "." + column;
|
||||
}
|
||||
|
||||
/** 步骤表公共筛选(与 oa_project_schedule 联表时须使用别名 opss) */
|
||||
private void appendCommonStepFilters (QueryWrapper<OaProjectScheduleStep> lqw, OaProjectScheduleStepBo bo, String alias) {
|
||||
lqw.eq(bo.getScheduleId() != null, stepCol(alias, "schedule_id"), bo.getScheduleId());
|
||||
lqw.eq(bo.getStepOrder() != null, stepCol(alias, "step_order"), bo.getStepOrder());
|
||||
lqw.like(StringUtils.isNotBlank(bo.getStepName()), stepCol(alias, "step_name"), bo.getStepName());
|
||||
lqw.eq(bo.getStatus() != null, stepCol(alias, "status"), bo.getStatus());
|
||||
lqw.eq(bo.getSupplierId() != null, stepCol(alias, "supplier_id"), bo.getSupplierId());
|
||||
lqw.ge(bo.getStartTime() != null, stepCol(alias, "plan_end"), bo.getStartTime());
|
||||
lqw.le(bo.getEndTime() != null, stepCol(alias, "plan_end"), bo.getEndTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增项目进度步骤跟踪
|
||||
*/
|
||||
|
||||
@@ -12,12 +12,16 @@ import com.ruoyi.common.core.domain.PageQuery;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.ruoyi.oa.domain.bo.OaProjectScheduleBo;
|
||||
import com.ruoyi.oa.domain.bo.SysOaWarehouseDetailBo;
|
||||
import com.ruoyi.oa.domain.dto.ProjectActivityDTO;
|
||||
import com.ruoyi.oa.domain.dto.ProjectDataDTO;
|
||||
import com.ruoyi.oa.domain.vo.*;
|
||||
import com.ruoyi.oa.service.CodeGeneratorService;
|
||||
import com.ruoyi.oa.service.IExchangeRateService;
|
||||
import com.ruoyi.oa.service.IOaProjectScheduleService;
|
||||
import com.ruoyi.oa.service.IOaProjectScheduleStepService;
|
||||
import com.ruoyi.oa.service.ISysOaTaskService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -43,6 +47,12 @@ public class SysOaProjectServiceImpl implements ISysOaProjectService {
|
||||
|
||||
private final SysOaProjectMapper baseMapper;
|
||||
|
||||
private final ISysOaTaskService sysOaTaskService;
|
||||
|
||||
private final IOaProjectScheduleService oaProjectScheduleService;
|
||||
|
||||
private final IOaProjectScheduleStepService oaProjectScheduleStepService;
|
||||
|
||||
@Autowired
|
||||
private CodeGeneratorService codeGeneratorService;
|
||||
@Autowired
|
||||
@@ -187,9 +197,16 @@ public class SysOaProjectServiceImpl implements ISysOaProjectService {
|
||||
private QueryWrapper<SysOaProject> buildAliasPQueryWrapper(SysOaProjectBo bo) {
|
||||
Map<String, Object> params = bo.getParams();
|
||||
QueryWrapper<SysOaProject> qw = Wrappers.query();
|
||||
qw.like(StringUtils.isNotBlank(bo.getProjectName()), "p.project_name", bo.getProjectName());
|
||||
qw.like(StringUtils.isNotBlank(bo.getProjectNum()), "p.project_num", bo.getProjectNum());
|
||||
qw.like(StringUtils.isNotBlank(bo.getProjectCode()), "p.project_code", bo.getProjectCode());
|
||||
if (StringUtils.isNotBlank(bo.getKeyword())) {
|
||||
String kw = bo.getKeyword().trim();
|
||||
qw.and(w -> w.like("p.project_name", kw)
|
||||
.or().like("p.project_num", kw)
|
||||
.or().like("p.project_code", kw));
|
||||
} else {
|
||||
qw.like(StringUtils.isNotBlank(bo.getProjectName()), "p.project_name", bo.getProjectName());
|
||||
qw.like(StringUtils.isNotBlank(bo.getProjectNum()), "p.project_num", bo.getProjectNum());
|
||||
qw.like(StringUtils.isNotBlank(bo.getProjectCode()), "p.project_code", bo.getProjectCode());
|
||||
}
|
||||
qw.eq(bo.getProductStatus() != null, "p.product_status", bo.getProductStatus());
|
||||
qw.eq(bo.getTradeType() != null, "p.trade_type", bo.getTradeType());
|
||||
if (bo.getPrePay() != null && bo.getPrePay() > 0) {
|
||||
@@ -219,9 +236,16 @@ public class SysOaProjectServiceImpl implements ISysOaProjectService {
|
||||
private LambdaQueryWrapper<SysOaProject> buildQueryWrapper(SysOaProjectBo bo) {
|
||||
Map<String, Object> params = bo.getParams();
|
||||
LambdaQueryWrapper<SysOaProject> lqw = Wrappers.lambdaQuery();
|
||||
lqw.like(StringUtils.isNotBlank(bo.getProjectName()), SysOaProject::getProjectName, bo.getProjectName());
|
||||
lqw.like(StringUtils.isNotBlank(bo.getProjectNum()), SysOaProject::getProjectNum, bo.getProjectNum());
|
||||
lqw.like(StringUtils.isNotBlank(bo.getProjectCode()), SysOaProject::getProjectCode, bo.getProjectCode());
|
||||
if (StringUtils.isNotBlank(bo.getKeyword())) {
|
||||
String kw = bo.getKeyword().trim();
|
||||
lqw.and(w -> w.like(SysOaProject::getProjectName, kw)
|
||||
.or().like(SysOaProject::getProjectNum, kw)
|
||||
.or().like(SysOaProject::getProjectCode, kw));
|
||||
} else {
|
||||
lqw.like(StringUtils.isNotBlank(bo.getProjectName()), SysOaProject::getProjectName, bo.getProjectName());
|
||||
lqw.like(StringUtils.isNotBlank(bo.getProjectNum()), SysOaProject::getProjectNum, bo.getProjectNum());
|
||||
lqw.like(StringUtils.isNotBlank(bo.getProjectCode()), SysOaProject::getProjectCode, bo.getProjectCode());
|
||||
}
|
||||
//新增生产结项状态筛选
|
||||
lqw.eq(bo.getProductStatus() != null, SysOaProject::getProductStatus, bo.getProductStatus());
|
||||
lqw.eq(bo.getTradeType() != null, SysOaProject::getTradeType, bo.getTradeType());
|
||||
@@ -587,4 +611,37 @@ public class SysOaProjectServiceImpl implements ISysOaProjectService {
|
||||
return baseMapper.getMaxCodeProject(prefix);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OaProjectDashboardVo getProjectDashboard(Long projectId) {
|
||||
SysOaProjectVo project = queryById(projectId);
|
||||
if (project == null) {
|
||||
return null;
|
||||
}
|
||||
List<SysOaTaskVo> tasks = sysOaTaskService.listDashboardTasks(projectId);
|
||||
|
||||
OaProjectScheduleBo scheduleBo = new OaProjectScheduleBo();
|
||||
scheduleBo.setProjectId(projectId);
|
||||
List<OaProjectScheduleVo> schedules = oaProjectScheduleService.queryList(scheduleBo);
|
||||
|
||||
List<OaProjectScheduleStepVo> steps = new ArrayList<>();
|
||||
for (OaProjectScheduleVo sch : schedules) {
|
||||
List<OaProjectScheduleStepVo> part = oaProjectScheduleStepService.selectProjectScheduleStepList(sch.getScheduleId());
|
||||
if (part == null) {
|
||||
continue;
|
||||
}
|
||||
for (OaProjectScheduleStepVo st : part) {
|
||||
st.setProjectId(projectId);
|
||||
st.setProjectName(project.getProjectName());
|
||||
}
|
||||
steps.addAll(part);
|
||||
}
|
||||
|
||||
OaProjectDashboardVo vo = new OaProjectDashboardVo();
|
||||
vo.setProject(project);
|
||||
vo.setTasks(tasks);
|
||||
vo.setSchedules(schedules);
|
||||
vo.setSteps(steps);
|
||||
return vo;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -135,6 +135,11 @@ public class SysOaTaskServiceImpl implements ISysOaTaskService {
|
||||
return baseMapper.listDocumentProject(projectId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SysOaTaskVo> listDashboardTasks(Long projectId) {
|
||||
return baseMapper.selectDashboardTasksByProjectId(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增:自定义查询任务列表(Plus版)
|
||||
*/
|
||||
|
||||
@@ -204,8 +204,8 @@
|
||||
status,
|
||||
header
|
||||
from oa_project_schedule_step opss
|
||||
WHERE schedule_id = #{scheduleId}
|
||||
AND step_order = #{currentStep}
|
||||
WHERE opss.schedule_id = #{scheduleId}
|
||||
AND opss.step_order = #{currentStep}
|
||||
and use_flag = '1'
|
||||
AND del_flag = '0'
|
||||
</select>
|
||||
@@ -258,5 +258,29 @@
|
||||
${ew.customSqlSegment}
|
||||
</select>
|
||||
|
||||
<!--
|
||||
综合看板:按 schedule_id 查询步骤列表
|
||||
要求:
|
||||
- 删除 del_flag(表无该字段)
|
||||
- 不使用 opss.* 前缀(不定义别名)
|
||||
- 仅保留 WHERE schedule_id = #{scheduleId}
|
||||
-->
|
||||
<select id="selectProjectScheduleStepList"
|
||||
parameterType="com.ruoyi.oa.domain.OaProjectScheduleStep"
|
||||
resultMap="OaProjectScheduleStepResult">
|
||||
SELECT
|
||||
track_id, accessory, schedule_id, step_order, step_name,
|
||||
plan_start, plan_end, actual_start, actual_end, status,
|
||||
header, use_flag, batch_id, tab_node,
|
||||
first_level_node, second_level_node,
|
||||
start_time, original_end_time, end_time,
|
||||
node_header, related_docs, related_images,
|
||||
specification, sort_num, supplier_id,
|
||||
requirement_file, other,
|
||||
create_by, create_time, update_by, update_time
|
||||
FROM oa_project_schedule_step
|
||||
WHERE schedule_id = #{scheduleId}
|
||||
</select>
|
||||
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -474,6 +474,41 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
|
||||
</select>
|
||||
|
||||
<!-- 综合看板:LEFT JOIN 进度步骤 del_flag=0、use_flag=1(与步骤表一致即可展示,不再依赖进度主表 project 联表) -->
|
||||
<select id="selectDashboardTasksByProjectId" resultType="com.ruoyi.oa.domain.vo.SysOaTaskVo">
|
||||
SELECT
|
||||
sot.task_id AS taskId,
|
||||
sot.project_id AS projectId,
|
||||
sop.project_code AS projectCode,
|
||||
sot.task_title AS taskTitle,
|
||||
sot.task_type AS taskType,
|
||||
sot.task_grade AS taskGrade,
|
||||
su1.nick_name AS createUserNickName,
|
||||
su2.nick_name AS workerNickName,
|
||||
sot.state AS state,
|
||||
sot.finish_time AS finishTime,
|
||||
sot.track_id AS trackId,
|
||||
opss.tab_node AS tabNode,
|
||||
opss.first_level_node AS firstLevelNode,
|
||||
opss.second_level_node AS secondLevelNode,
|
||||
opss.step_name AS scheduleStepName,
|
||||
opss.status AS scheduleStatus,
|
||||
opss.plan_start AS schedulePlanStart,
|
||||
opss.plan_end AS schedulePlanEnd,
|
||||
opss.header AS scheduleHeader
|
||||
FROM sys_oa_task sot
|
||||
LEFT JOIN sys_user su1 ON su1.user_id = sot.create_user_id
|
||||
LEFT JOIN sys_user su2 ON su2.user_id = sot.worker_id
|
||||
LEFT JOIN sys_oa_project sop ON sop.project_id = sot.project_id
|
||||
LEFT JOIN oa_project_schedule_step opss
|
||||
ON opss.track_id = sot.track_id
|
||||
AND opss.del_flag = '0'
|
||||
AND opss.use_flag = 1
|
||||
WHERE sot.project_id = #{projectId}
|
||||
AND sot.del_flag = '0'
|
||||
ORDER BY sot.create_time DESC
|
||||
</select>
|
||||
|
||||
<select id="queryListPlus" parameterType="com.ruoyi.oa.domain.bo.SysOaTaskBo" resultType="com.ruoyi.oa.domain.vo.SysOaTaskVo">
|
||||
SELECT
|
||||
task_id AS taskId,
|
||||
|
||||
@@ -36,6 +36,14 @@ export function getProject (projectId) {
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/** 综合看板聚合数据:项目 + 任务 + 进度主表 + 步骤 */
|
||||
export function getProjectDashboard (projectId) {
|
||||
return request({
|
||||
url: '/oa/project/dashboard/' + projectId,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
// 查询项目管理详细
|
||||
export function projectData (date) {
|
||||
return request({
|
||||
|
||||
12
ruoyi-ui/src/api/oa/schedule.js
Normal file
12
ruoyi-ui/src/api/oa/schedule.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 查询进度主表 + 子表(项目综合看板使用)
|
||||
// 接口:/oa/schedule/list GET 入参:projectId
|
||||
export function listSchedule (query) {
|
||||
return request({
|
||||
url: '/oa/schedule/list',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import permission from './modules/permission'
|
||||
import settings from './modules/settings'
|
||||
import tagsView from './modules/tagsView'
|
||||
import user from './modules/user'
|
||||
import oaProjectDashboard2 from './modules/oaProjectDashboard2'
|
||||
|
||||
Vue.use(Vuex)
|
||||
|
||||
@@ -19,7 +20,8 @@ const store = new Vuex.Store({
|
||||
tagsView,
|
||||
permission,
|
||||
settings,
|
||||
meta
|
||||
meta,
|
||||
oaProjectDashboard2
|
||||
},
|
||||
getters
|
||||
})
|
||||
|
||||
121
ruoyi-ui/src/store/modules/oaProjectDashboard2.js
Normal file
121
ruoyi-ui/src/store/modules/oaProjectDashboard2.js
Normal file
@@ -0,0 +1,121 @@
|
||||
import { listProject, getProjectDashboard } from '@/api/oa/project'
|
||||
|
||||
function sleep (ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function retry (fn, times, delayMs) {
|
||||
let lastErr
|
||||
for (let i = 0; i < times; i++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (e) {
|
||||
lastErr = e
|
||||
if (i < times - 1) {
|
||||
await sleep(delayMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastErr
|
||||
}
|
||||
|
||||
const state = {
|
||||
currentProjectId: '',
|
||||
|
||||
projectQuery: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
keyword: ''
|
||||
},
|
||||
projectList: [],
|
||||
projectTotal: 0,
|
||||
|
||||
projectDetail: null,
|
||||
taskList: [],
|
||||
scheduleList: [],
|
||||
stepList: [],
|
||||
|
||||
taskQuery: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
projectCode: '',
|
||||
taskKeyword: ''
|
||||
},
|
||||
|
||||
loading: false
|
||||
}
|
||||
|
||||
const mutations = {
|
||||
SET_LOADING (state, val) {
|
||||
state.loading = !!val
|
||||
},
|
||||
SET_CURRENT_PROJECT (state, projectId) {
|
||||
state.currentProjectId = projectId || ''
|
||||
},
|
||||
|
||||
SET_PROJECT_QUERY (state, payload) {
|
||||
state.projectQuery = { ...state.projectQuery, ...(payload || {}) }
|
||||
},
|
||||
SET_PROJECT_LIST (state, payload) {
|
||||
state.projectList = payload.rows || []
|
||||
state.projectTotal = payload.total || 0
|
||||
},
|
||||
|
||||
SET_DASHBOARD (state, payload) {
|
||||
const p = payload || {}
|
||||
state.projectDetail = p.project || null
|
||||
state.taskList = p.tasks || []
|
||||
state.scheduleList = p.schedules || []
|
||||
state.stepList = p.steps || []
|
||||
},
|
||||
|
||||
CLEAR_RIGHT (state) {
|
||||
state.projectDetail = null
|
||||
state.taskList = []
|
||||
state.scheduleList = []
|
||||
state.stepList = []
|
||||
},
|
||||
|
||||
SET_TASK_QUERY (state, payload) {
|
||||
state.taskQuery = { ...state.taskQuery, ...(payload || {}) }
|
||||
}
|
||||
}
|
||||
|
||||
const actions = {
|
||||
async fetchProjectList ({ commit, state }, payload) {
|
||||
const merged = { ...state.projectQuery, ...(payload || {}) }
|
||||
const query = {
|
||||
pageNum: merged.pageNum,
|
||||
pageSize: merged.pageSize,
|
||||
keyword: (merged.keyword != null ? String(merged.keyword) : '').trim()
|
||||
}
|
||||
commit('SET_PROJECT_QUERY', query)
|
||||
commit('SET_LOADING', true)
|
||||
try {
|
||||
const res = await retry(() => listProject(query), 2, 600)
|
||||
commit('SET_PROJECT_LIST', { rows: res.rows || [], total: res.total || 0 })
|
||||
return res
|
||||
} finally {
|
||||
commit('SET_LOADING', false)
|
||||
}
|
||||
},
|
||||
|
||||
async selectProject ({ commit }, projectId) {
|
||||
commit('SET_CURRENT_PROJECT', projectId)
|
||||
if (!projectId) {
|
||||
commit('CLEAR_RIGHT')
|
||||
return
|
||||
}
|
||||
const res = await retry(() => getProjectDashboard(projectId), 2, 600)
|
||||
const data = res.data || {}
|
||||
commit('SET_DASHBOARD', data)
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
mutations,
|
||||
actions
|
||||
}
|
||||
894
ruoyi-ui/src/views/oa/project/dashboard2/index.vue
Normal file
894
ruoyi-ui/src/views/oa/project/dashboard2/index.vue
Normal file
@@ -0,0 +1,894 @@
|
||||
<!--
|
||||
前端路径:d:\RuoYI_workspace\fad_oa\ruoyi-ui\src\views\oa\project\dashboard2\index.vue
|
||||
后端聚合接口:GET /oa/project/dashboard/{projectId}
|
||||
-->
|
||||
<template>
|
||||
<div class="app-container dashboard2" v-loading="pageLoading">
|
||||
<div class="layout">
|
||||
<!-- 左侧 20%:项目列表 -->
|
||||
<div class="left">
|
||||
<div class="left-search">
|
||||
<el-input
|
||||
v-model="projectQuery.keyword"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="名称 / 编号 / 代号"
|
||||
class="left-keyword-input"
|
||||
prefix-icon="el-icon-search"
|
||||
@keyup.enter.native="getProjectList"
|
||||
/>
|
||||
<el-button type="primary" size="small" class="left-search-btn" @click="getProjectList">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<div class="project-list" v-loading="projectLoading">
|
||||
<div
|
||||
v-for="p in projectList"
|
||||
:key="p.projectId"
|
||||
class="project-item"
|
||||
:class="{ active: String(p.projectId) === String(currentProjectId) }"
|
||||
@click="handleSelectProject(p)"
|
||||
:title="p.projectName"
|
||||
>
|
||||
<div class="project-name text-ellipsis">{{ p.projectName }}</div>
|
||||
<div class="project-meta text-ellipsis">
|
||||
<span v-if="p.projectCode" class="code">{{ p.projectCode }}</span>
|
||||
<span class="time">{{ formatDate(p.beginTime) }} ~ {{ formatDate(p.finishTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!projectList || projectList.length === 0" class="left-empty">
|
||||
<el-empty :image-size="72" description="暂无项目"></el-empty>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="left-pager">
|
||||
<el-pagination
|
||||
small
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
:total="projectTotal"
|
||||
:current-page.sync="projectQuery.pageNum"
|
||||
:page-size.sync="projectQuery.pageSize"
|
||||
@current-change="getProjectList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧 80% -->
|
||||
<div class="right">
|
||||
<div v-if="!currentProjectId" class="right-empty">
|
||||
<el-empty description="请选择项目"></el-empty>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="panel task-panel">
|
||||
<div class="panel-header task-panel__toolbar">
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="taskQuery.projectCode"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="项目代号"
|
||||
style="width: 140px"
|
||||
/>
|
||||
<el-input
|
||||
v-model="taskQuery.taskKeyword"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="任务主题关键词"
|
||||
style="width: 160px"
|
||||
@keyup.enter.native="resetTaskPage"
|
||||
/>
|
||||
<el-button type="primary" size="mini" @click="resetTaskPage">搜索</el-button>
|
||||
<el-button size="mini" @click="resetTaskFilters">重置</el-button>
|
||||
<el-button size="mini" @click="refreshCurrent">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-body panel-body-flex">
|
||||
<div class="task-table-wrap">
|
||||
<vxe-table
|
||||
ref="taskTable"
|
||||
size="mini"
|
||||
class="task-vxe"
|
||||
border
|
||||
stripe
|
||||
show-overflow="tooltip"
|
||||
height="auto"
|
||||
:data="pagedTaskList"
|
||||
:row-config="{ isHover: true }"
|
||||
>
|
||||
<vxe-column field="projectCode" title="代号" width="72"></vxe-column>
|
||||
<vxe-column field="taskTitle" title="任务主题" min-width="96"></vxe-column>
|
||||
<vxe-column field="scheduleProgress" title="对应进度" min-width="112">
|
||||
<template #default="{ row }">
|
||||
<div v-if="scheduleProgressUnlinked(row)" class="schedule-progress-wrap">
|
||||
<el-tag type="info" effect="plain" size="mini">未关联进度</el-tag>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="schedule-progress-wrap schedule-progress-wrap--linked"
|
||||
:title="scheduleProgressTitle(row)"
|
||||
>
|
||||
<el-tag :type="scheduleStepTagType(row)" size="mini" class="schedule-progress-status-tag">
|
||||
{{ scheduleStepStatusLabel(row) }}
|
||||
</el-tag>
|
||||
<span class="schedule-progress-path">{{ scheduleProgressPath(row) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</vxe-column>
|
||||
<vxe-column field="taskType" title="工作类型" width="86">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :options="dict.type.sys_work_type" :value="row.taskType" />
|
||||
</template>
|
||||
</vxe-column>
|
||||
<vxe-column field="taskGrade" title="任务级别" width="78">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :options="dict.type.sys_sort_grade" :value="row.taskGrade" />
|
||||
</template>
|
||||
</vxe-column>
|
||||
<vxe-column field="workerNickName" title="执行人" width="76">
|
||||
<template #default="{ row }">{{ row.workerNickName || '-' }}</template>
|
||||
</vxe-column>
|
||||
<vxe-column field="createUserNickName" title="创建人" width="76">
|
||||
<template #default="{ row }">{{ row.createUserNickName || '-' }}</template>
|
||||
</vxe-column>
|
||||
<vxe-column field="state" title="状态" width="88">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStateTagType(row.state)" size="mini">{{ stateText(row.state) }}</el-tag>
|
||||
</template>
|
||||
</vxe-column>
|
||||
<vxe-column field="finishTime" title="预期结束" width="100">
|
||||
<template #default="{ row }">{{ formatDate(row.finishTime) }}</template>
|
||||
</vxe-column>
|
||||
</vxe-table>
|
||||
</div>
|
||||
|
||||
<div class="task-pager">
|
||||
<el-pagination
|
||||
small
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
:total="filteredTaskTotal"
|
||||
:current-page.sync="taskQuery.pageNum"
|
||||
:page-size.sync="taskQuery.pageSize"
|
||||
@current-change="noop"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下:进度板块 — Tab1 进度明细表(默认);Tab2 原思维导图 + 图例 -->
|
||||
<div class="panel schedule-panel">
|
||||
<el-tabs v-model="scheduleViewTab" class="schedule-panel-tabs">
|
||||
<el-tab-pane label="进度明细" name="list">
|
||||
<!-- 进度数据:表格自然撑开高度,由 .progress-table-scroll 单独承担纵向/横向滚动 -->
|
||||
<div class="progress-table-pane" v-loading="xmindLoading">
|
||||
<el-empty
|
||||
v-if="!stepList || stepList.length === 0"
|
||||
:image-size="72"
|
||||
description="暂无进度步骤数据"
|
||||
/>
|
||||
<div v-else class="progress-table-scroll">
|
||||
<el-table
|
||||
:data="scheduleStepsForTable"
|
||||
size="mini"
|
||||
border
|
||||
stripe
|
||||
class="progress-step-el-table"
|
||||
>
|
||||
<el-table-column prop="firstLevelNode" label="一级节点" min-width="112" show-overflow-tooltip />
|
||||
<el-table-column prop="secondLevelNode" label="二级节点" min-width="112" show-overflow-tooltip />
|
||||
<el-table-column prop="stepName" label="步骤名称" min-width="128" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="92" align="center">
|
||||
<template slot-scope="{ row }">
|
||||
<el-tag :type="stepStatusTagType(row)" size="mini">{{ stepStatusLabel(row) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="计划开始" width="108" align="center">
|
||||
<template slot-scope="{ row }">{{ formatStepPlanStart(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="计划结束" width="108" align="center">
|
||||
<template slot-scope="{ row }">{{ formatStepPlanEnd(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="负责人" min-width="96" show-overflow-tooltip>
|
||||
<template slot-scope="{ row }">{{ formatStepResponsible(row) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="进度导图" name="mind">
|
||||
<div class="schedule-tab-pane-inner schedule-tab-pane-inner--mind">
|
||||
<div class="schedule-mind">
|
||||
<div class="xmind-wrap" v-loading="xmindLoading">
|
||||
<div v-if="xmindEmpty" class="xmind-empty">
|
||||
<el-empty :image-size="80" description="暂无进度步骤数据"></el-empty>
|
||||
</div>
|
||||
<xmind
|
||||
v-else-if="scheduleViewTab === 'mind'"
|
||||
:list="stepList"
|
||||
height="100%"
|
||||
dashboard-mode
|
||||
@refresh="onXmindRefresh"
|
||||
/>
|
||||
</div>
|
||||
<aside v-if="scheduleViewTab === 'mind' && !xmindEmpty && stepList && stepList.length" class="mind-legend-aside">
|
||||
<div class="mind-legend-title">状态图例</div>
|
||||
<div class="mind-legend-row">
|
||||
<i class="lg-dot lg-dot--done" />
|
||||
<span>已完成</span>
|
||||
</div>
|
||||
<div class="mind-legend-row">
|
||||
<i class="lg-dot lg-dot--doing" />
|
||||
<span>进行中 / 待验收</span>
|
||||
</div>
|
||||
<div class="mind-legend-row">
|
||||
<i class="lg-dot lg-dot--todo" />
|
||||
<span>未开始 / 暂停</span>
|
||||
</div>
|
||||
<p class="mind-legend-tip">连线:已完成节点为绿色;可滚轮缩放、拖动画布。</p>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState } from 'vuex'
|
||||
import Xmind from '@/views/oa/project/pace/components/xmind.vue'
|
||||
|
||||
export default {
|
||||
name: 'OaProjectDashboard2',
|
||||
components: { Xmind },
|
||||
dicts: ['sys_work_type', 'sys_sort_grade'],
|
||||
data () {
|
||||
return {
|
||||
pageLoading: false,
|
||||
projectLoading: false,
|
||||
xmindLoading: false,
|
||||
/** 进度板块:默认进度明细表,切换为进度导图(原思维导图) */
|
||||
scheduleViewTab: 'list'
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('oaProjectDashboard2', [
|
||||
'currentProjectId',
|
||||
'projectQuery',
|
||||
'projectList',
|
||||
'projectTotal',
|
||||
'taskQuery',
|
||||
'taskList',
|
||||
'stepList'
|
||||
]),
|
||||
filteredTaskList () {
|
||||
const list = this.taskList || []
|
||||
const code = (this.taskQuery.projectCode || '').trim().toLowerCase()
|
||||
const kw = (this.taskQuery.taskKeyword || '').trim().toLowerCase()
|
||||
return list.filter(r => {
|
||||
const okCode = !code || String(r.projectCode || '').toLowerCase().indexOf(code) !== -1
|
||||
const title = String(r.taskTitle || '').toLowerCase()
|
||||
const okKw = !kw || title.indexOf(kw) !== -1
|
||||
return okCode && okKw
|
||||
})
|
||||
},
|
||||
filteredTaskTotal () {
|
||||
return (this.filteredTaskList || []).length
|
||||
},
|
||||
pagedTaskList () {
|
||||
const pageNum = Number(this.taskQuery.pageNum || 1)
|
||||
const pageSize = Number(this.taskQuery.pageSize || 10)
|
||||
const start = (pageNum - 1) * pageSize
|
||||
return (this.filteredTaskList || []).slice(start, start + pageSize)
|
||||
},
|
||||
xmindEmpty () {
|
||||
return !this.stepList || this.stepList.length === 0
|
||||
},
|
||||
/** 进度明细表:按步骤序号排序,便于阅读 */
|
||||
scheduleStepsForTable () {
|
||||
const list = this.stepList || []
|
||||
return [...list].sort((a, b) => {
|
||||
const oa = Number(a.stepOrder != null ? a.stepOrder : 0)
|
||||
const ob = Number(b.stepOrder != null ? b.stepOrder : 0)
|
||||
return oa - ob
|
||||
})
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.getProjectList()
|
||||
},
|
||||
methods: {
|
||||
async getProjectList () {
|
||||
this.projectLoading = true
|
||||
try {
|
||||
await this.$store.dispatch('oaProjectDashboard2/fetchProjectList', {
|
||||
pageNum: this.projectQuery.pageNum,
|
||||
pageSize: this.projectQuery.pageSize,
|
||||
keyword: this.projectQuery.keyword
|
||||
})
|
||||
} catch (e) {
|
||||
this.$message.error('项目列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
this.projectLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
async handleSelectProject (p) {
|
||||
const projectId = p && p.projectId
|
||||
if (!projectId) return
|
||||
this.scheduleViewTab = 'list'
|
||||
this.pageLoading = true
|
||||
this.xmindLoading = true
|
||||
try {
|
||||
this.$store.commit('oaProjectDashboard2/SET_TASK_QUERY', { pageNum: 1 })
|
||||
await this.$store.dispatch('oaProjectDashboard2/selectProject', projectId)
|
||||
} catch (e) {
|
||||
this.$message.error((e && e.message) || '加载项目数据失败,请稍后重试')
|
||||
} finally {
|
||||
this.pageLoading = false
|
||||
this.xmindLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
resetTaskPage () {
|
||||
this.$store.commit('oaProjectDashboard2/SET_TASK_QUERY', { pageNum: 1 })
|
||||
},
|
||||
|
||||
resetTaskFilters () {
|
||||
this.$store.commit('oaProjectDashboard2/SET_TASK_QUERY', {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
projectCode: '',
|
||||
taskKeyword: ''
|
||||
})
|
||||
},
|
||||
|
||||
async refreshCurrent () {
|
||||
if (!this.currentProjectId) return
|
||||
this.pageLoading = true
|
||||
this.xmindLoading = true
|
||||
try {
|
||||
await this.$store.dispatch('oaProjectDashboard2/selectProject', this.currentProjectId)
|
||||
} catch (e) {
|
||||
this.$message.error('刷新失败,请稍后重试')
|
||||
} finally {
|
||||
this.pageLoading = false
|
||||
this.xmindLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
/** 计划开始:库表 plan_start 可能为空,与进度页一致回退 start_time */
|
||||
formatStepPlanStart (row) {
|
||||
const raw = row && (row.planStart != null && row.planStart !== '' ? row.planStart : row.startTime)
|
||||
return this.formatDateFlexible(raw)
|
||||
},
|
||||
|
||||
formatStepPlanEnd (row) {
|
||||
const raw = row && (row.planEnd != null && row.planEnd !== '' ? row.planEnd : row.endTime)
|
||||
return this.formatDateFlexible(raw)
|
||||
},
|
||||
|
||||
formatDateFlexible (val) {
|
||||
if (val == null || val === '') return '-'
|
||||
if (Array.isArray(val) && val.length >= 3) {
|
||||
const y = val[0]
|
||||
const mo = String(val[1]).padStart(2, '0')
|
||||
const d = String(val[2]).padStart(2, '0')
|
||||
return `${y}-${mo}-${d}`
|
||||
}
|
||||
if (typeof val === 'string') {
|
||||
const s = val.trim()
|
||||
if (!s) return '-'
|
||||
if (s.length >= 10 && s[4] === '-' && s[7] === '-') return s.substring(0, 10)
|
||||
const m = s.match(/^(\d{4}-\d{2}-\d{2})/)
|
||||
return m ? m[1] : s
|
||||
}
|
||||
if (val instanceof Date && !isNaN(val.getTime())) {
|
||||
const y = val.getFullYear()
|
||||
const mo = String(val.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(val.getDate()).padStart(2, '0')
|
||||
return `${y}-${mo}-${d}`
|
||||
}
|
||||
return '-'
|
||||
},
|
||||
|
||||
/** 负责人:业务侧常用 node_header,兼容 header */
|
||||
formatStepResponsible (row) {
|
||||
if (!row) return '-'
|
||||
const h = row.header != null && String(row.header).trim() !== '' ? String(row.header).trim() : ''
|
||||
if (h) return h
|
||||
const n = row.nodeHeader != null && String(row.nodeHeader).trim() !== '' ? String(row.nodeHeader).trim() : ''
|
||||
return n || '-'
|
||||
},
|
||||
|
||||
async onXmindRefresh () {
|
||||
await this.refreshCurrent()
|
||||
},
|
||||
|
||||
noop () {},
|
||||
|
||||
/** 无 track_id 或无步骤名称 → 未关联(后端联表 oa_project_schedule_step,use_flag=1) */
|
||||
scheduleProgressUnlinked (row) {
|
||||
if (!row || row.trackId == null || row.trackId === '') return true
|
||||
const n = row.scheduleStepName
|
||||
return n == null || String(n).trim() === ''
|
||||
},
|
||||
scheduleStepTagType (row) {
|
||||
const s = row && row.scheduleStatus
|
||||
if (s === null || s === undefined) return 'info'
|
||||
const v = Number(s)
|
||||
if (v === 2) return 'success'
|
||||
if (v === 3) return 'warning'
|
||||
if (v === 1) return 'primary'
|
||||
if (v === 0) return 'info'
|
||||
return 'info'
|
||||
},
|
||||
scheduleStepStatusLabel (row) {
|
||||
const s = row && row.scheduleStatus
|
||||
if (s === null || s === undefined) return '—'
|
||||
const v = Number(s)
|
||||
if (v === 2) return '已完成'
|
||||
if (v === 1) return '进行中'
|
||||
if (v === 3) return '暂停'
|
||||
if (v === 0) return '未开始'
|
||||
return '—'
|
||||
},
|
||||
scheduleProgressPath (row) {
|
||||
if (!row) return ''
|
||||
const n = row.scheduleStepName
|
||||
return (n != null && String(n).trim() !== '') ? String(n).trim() : ''
|
||||
},
|
||||
scheduleProgressTitle (row) {
|
||||
return `${this.scheduleStepStatusLabel(row)} ${this.scheduleProgressPath(row)}`.trim()
|
||||
},
|
||||
|
||||
/** oa_project_schedule_step.status:与任务表「对应进度」标签一致 */
|
||||
stepStatusLabel (row) {
|
||||
return this.scheduleStepStatusLabel({ scheduleStatus: row && row.status })
|
||||
},
|
||||
stepStatusTagType (row) {
|
||||
return this.scheduleStepTagType({ scheduleStatus: row && row.status })
|
||||
},
|
||||
|
||||
formatDate (val) {
|
||||
if (!val) return '-'
|
||||
const s = String(val)
|
||||
return s.length >= 10 ? s.substring(0, 10) : s
|
||||
},
|
||||
|
||||
stateText (val) {
|
||||
if (val === null || val === undefined || val === '') return '-'
|
||||
const v = Number(val)
|
||||
if (v === 2) return '执行完成'
|
||||
if (v === 1) return '待验收'
|
||||
if (v === 15) return '延期申请中'
|
||||
if (v === 0) return '进行中'
|
||||
return '其他'
|
||||
},
|
||||
getStateTagType (val) {
|
||||
if (val === null || val === undefined || val === '') return 'info'
|
||||
const v = Number(val)
|
||||
if (v === 2) return 'success'
|
||||
if (v === 1) return 'warning'
|
||||
if (v === 15) return 'warning'
|
||||
if (v === 0) return 'info'
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard2 {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
height: calc(100vh - 120px);
|
||||
min-height: 0;
|
||||
max-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.left {
|
||||
width: 20%;
|
||||
flex: 0 0 20%;
|
||||
min-width: 220px;
|
||||
max-width: 300px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.left-search {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.left-keyword-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.left-search-btn {
|
||||
flex-shrink: 0;
|
||||
padding-left: 14px;
|
||||
padding-right: 14px;
|
||||
}
|
||||
|
||||
.project-list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.project-item {
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #eef0f3;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 2px;
|
||||
min-height: 42px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.project-item:hover {
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.project-item.active {
|
||||
background: #ecf5ff;
|
||||
border-left: 3px solid #409eff;
|
||||
padding-left: 5px;
|
||||
box-shadow: inset 0 0 0 1px rgba(64, 158, 255, 0.15);
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.project-meta {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
line-height: 1.35;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.project-meta .code {
|
||||
margin-right: 8px;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.left-pager {
|
||||
padding-top: 8px;
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.left-pager :deep(.el-pagination) {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.left-empty {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.right-empty {
|
||||
border: 1px dashed #dcdfe6;
|
||||
border-radius: 4px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
/* 原「任务列表」标题条去掉后,工具栏区沿用顶栏灰底,与下方表格白底区分不变 */
|
||||
.task-panel__toolbar {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.panel-body-flex {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-panel {
|
||||
flex: 4 1 0%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.schedule-panel {
|
||||
flex: 6 1 0%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.schedule-panel-tabs {
|
||||
flex: 1 1 0%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
}
|
||||
|
||||
/* Element Tabs 根节点参与纵向 flex,才能把剩余高度交给内容区 */
|
||||
.schedule-panel-tabs :deep(.el-tabs) {
|
||||
flex: 1 1 0%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.schedule-panel-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 0;
|
||||
padding: 0 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.schedule-panel-tabs :deep(.el-tabs__content) {
|
||||
flex: 1 1 0%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 当前激活的 pane 占满内容区高度,子元素才能算出可滚动区域 */
|
||||
.schedule-panel-tabs :deep(.el-tab-pane) {
|
||||
flex: 1 1 0%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.schedule-tab-pane-inner {
|
||||
flex: 1 1 0%;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.schedule-tab-pane-inner--mind {
|
||||
min-height: 280px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 进度明细 Tab:占满下方板块;内部仅滚动区参与滚动 */
|
||||
.progress-table-pane {
|
||||
flex: 1 1 0%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height:300px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 12px 10px;
|
||||
|
||||
}
|
||||
|
||||
.progress-table-scroll {
|
||||
flex: 1 1 0%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.progress-step-el-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.schedule-mind {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.task-table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-table-wrap :deep(.vxe-table) {
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-table-wrap :deep(.vxe-table--body-wrapper),
|
||||
.task-table-wrap :deep(.vxe-table--header-wrapper) {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.schedule-progress-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.schedule-progress-wrap--linked {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.schedule-progress-status-tag {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.schedule-progress-path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.task-pager {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.xmind-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.xmind-wrap :deep(.xmind-box--dashboard) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.xmind-wrap :deep(.xmind-container) {
|
||||
flex: 1;
|
||||
min-height: 300px !important;
|
||||
}
|
||||
|
||||
.mind-legend-aside {
|
||||
flex: 0 0 132px;
|
||||
padding: 10px 12px;
|
||||
border-left: 1px solid #ebeef5;
|
||||
background: #fafafa;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mind-legend-title {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mind-legend-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.lg-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lg-dot--done {
|
||||
background: #67c23a;
|
||||
}
|
||||
|
||||
.lg-dot--doing {
|
||||
background: #e6a23c;
|
||||
}
|
||||
|
||||
.lg-dot--todo {
|
||||
background: #909399;
|
||||
}
|
||||
|
||||
.mind-legend-tip {
|
||||
margin: 12px 0 0;
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.xmind-empty {
|
||||
height: 100%;
|
||||
min-height: 240px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed #ebeef5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.text-ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +1,13 @@
|
||||
<template>
|
||||
<div class="xmind-box">
|
||||
<div class='action-panel'>
|
||||
<!-- <el-button type="primary" icon="el-icon-plus" @click="handleAdd">新增</el-button> -->
|
||||
<el-button type="primary" icon="el-icon-refresh" @click="handleRefresh">刷新</el-button>
|
||||
<div class="xmind-box" :class="{ 'xmind-box--dashboard': dashboardMode }">
|
||||
<div class="action-panel">
|
||||
<el-button type="primary" :size="dashboardMode ? 'mini' : 'small'" icon="el-icon-refresh" @click="handleRefresh">刷新</el-button>
|
||||
<!-- <el-button type="primary" icon="el-icon-view" @click="handleRefresh">详情</el-button>
|
||||
<el-button type="primary" icon="el-icon-edit" @click="handleRefresh">编辑</el-button>
|
||||
<el-button type="primary" icon="el-icon-folder" @click="previewFiles(currentNode)">文件</el-button>
|
||||
<el-button type="primary" icon="el-icon-picture" @click="previewImages(currentNode)">图片</el-button> -->
|
||||
</div>
|
||||
<div class="xmind-container" ref="chart" style="width: 100%; height: 800px;"></div>
|
||||
<div class="xmind-container" ref="chart" :style="containerStyle"></div>
|
||||
<!-- 新增:三级节点点击弹窗-查看完整信息 -->
|
||||
<el-dialog title="节点详情信息" :visible.sync="dialogVisible" width="1200px" center append-to-body>
|
||||
<el-form>
|
||||
@@ -90,6 +89,25 @@ export default {
|
||||
list: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
/** 容器高度,综合看板等场景可传 100% 以撑满父级 */
|
||||
height: {
|
||||
type: String,
|
||||
default: '800px'
|
||||
},
|
||||
/** 综合看板:紧凑、防重叠、三色状态、小圆点 */
|
||||
dashboardMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
containerStyle () {
|
||||
return {
|
||||
width: '100%',
|
||||
height: this.height,
|
||||
minHeight: this.dashboardMode ? '300px' : '240px'
|
||||
}
|
||||
}
|
||||
},
|
||||
data () {
|
||||
@@ -115,7 +133,7 @@ export default {
|
||||
},
|
||||
mounted () {
|
||||
this.initChart();
|
||||
// 监听窗口大小变化,自适应重绘
|
||||
this.$nextTick(() => this.resizeChart());
|
||||
window.addEventListener('resize', this.resizeChart);
|
||||
},
|
||||
beforeDestroy () {
|
||||
@@ -175,53 +193,97 @@ export default {
|
||||
})
|
||||
},
|
||||
|
||||
// 核心方法:把扁平数组 转为 ECharts树图需要的嵌套树形结构
|
||||
/** 看板长标签按字折行(与折线图区域可读性一致) */
|
||||
wrapLabelText (text, maxCharsPerLine) {
|
||||
if (!text) return ''
|
||||
const max = Math.max(4, maxCharsPerLine || 16)
|
||||
if (text.length <= max) return text
|
||||
const lines = []
|
||||
for (let i = 0; i < text.length; i += max) {
|
||||
lines.push(text.slice(i, i + max))
|
||||
}
|
||||
return lines.join('\n')
|
||||
},
|
||||
|
||||
// Tab节点 → 一级节点 → 二级节点(叶子带业务数据)
|
||||
transformToTreeData (list) {
|
||||
if (!list.length) return { name: '暂无项目数据', children: [] };
|
||||
|
||||
// 1. 获取项目名称(所有数据是同一个项目,取第一条即可)
|
||||
const dm = this.dashboardMode
|
||||
const projectName = list[0].projectName || '项目进度树图';
|
||||
// 2. 构建层级Map,去重+归集子节点
|
||||
const levelMap = new Map();
|
||||
list.forEach(item => {
|
||||
const firstLevel = item.firstLevelNode || '未分类一级节点';
|
||||
const secondLevel = item.secondLevelNode || '未分类二级节点';
|
||||
// 状态映射:0=未开始(蓝色) 2=已完成(绿色) 其他=进行中(橙色),可根据业务调整
|
||||
const statusText = item.status === 0 ? '待开始' : item.status === 2 ? '✅已完成' : '🔵进行中';
|
||||
const statusColor = item.status === 0 ? '#409EFF' : item.status === 2 ? '#67C23A' : '#E6A23C';
|
||||
const tabMap = new Map();
|
||||
|
||||
list.forEach(item => {
|
||||
const tab = item.tabNode || '默认分组';
|
||||
const firstLevel = item.firstLevelNode || '未分类一级节点';
|
||||
const secondLevel = item.secondLevelNode || item.stepName || '未命名节点';
|
||||
const st = Number(item.status);
|
||||
let statusText = '进行中';
|
||||
let statusColor = '#E6A23C';
|
||||
let lineToNode = { color: '#dcdfe6', width: 1.2 };
|
||||
if (st === 2) {
|
||||
statusText = '已完成';
|
||||
statusColor = '#67C23A';
|
||||
lineToNode = { color: '#67C23A', width: 1.8 };
|
||||
} else if (st === 0) {
|
||||
statusText = '未开始';
|
||||
statusColor = '#909399';
|
||||
} else if (st === 1) {
|
||||
statusText = '待验收/进行中';
|
||||
statusColor = '#E6A23C';
|
||||
} else if (st === 3) {
|
||||
statusText = '暂停';
|
||||
statusColor = '#909399';
|
||||
}
|
||||
|
||||
// 组装节点数据:显示名称+业务信息+样式
|
||||
const nodeData = {
|
||||
name: secondLevel,
|
||||
itemStyle: { color: statusColor },
|
||||
/* 看板:小圆点 + 白边,贴近折线图主色风格 */
|
||||
itemStyle: dm
|
||||
? { color: statusColor, borderColor: '#ffffff', borderWidth: 1.25, shadowBlur: 3, shadowColor: 'rgba(0,0,0,0.12)' }
|
||||
: { color: statusColor, borderColor: statusColor },
|
||||
label: { color: statusColor },
|
||||
// 自定义业务数据,鼠标悬浮时显示
|
||||
lineStyle: lineToNode,
|
||||
value: {
|
||||
...item,
|
||||
负责人: item.nodeHeader || '无',
|
||||
状态: statusText,
|
||||
计划完成: item.planEnd || '无',
|
||||
说明: item.specification || '无'
|
||||
statusLabel: statusText
|
||||
}
|
||||
};
|
||||
|
||||
// 归集一级节点和二级节点
|
||||
if (!levelMap.has(firstLevel)) {
|
||||
levelMap.set(firstLevel, []);
|
||||
if (!tabMap.has(tab)) {
|
||||
tabMap.set(tab, new Map());
|
||||
}
|
||||
levelMap.get(firstLevel).push(nodeData);
|
||||
const firstMap = tabMap.get(tab);
|
||||
if (!firstMap.has(firstLevel)) {
|
||||
firstMap.set(firstLevel, []);
|
||||
}
|
||||
firstMap.get(firstLevel).push(nodeData);
|
||||
});
|
||||
|
||||
// 3. 组装最终的树形结构
|
||||
const treeChildren = Array.from(levelMap).map(([firstName, children]) => ({
|
||||
name: firstName,
|
||||
itemStyle: { color: '#303133' }, // 一级节点统一深灰色
|
||||
children: children
|
||||
const treeChildren = Array.from(tabMap).map(([tabName, firstMap]) => ({
|
||||
name: tabName,
|
||||
itemStyle: dm
|
||||
? { color: '#606266', borderColor: '#ffffff', borderWidth: 1, shadowBlur: 2, shadowColor: 'rgba(0,0,0,0.06)' }
|
||||
: { color: '#606266', borderColor: '#dcdfe6' },
|
||||
lineStyle: { color: '#c0c4cc', width: 1.2 },
|
||||
children: Array.from(firstMap).map(([firstName, children]) => ({
|
||||
name: firstName,
|
||||
itemStyle: dm
|
||||
? { color: '#303133', borderColor: '#ffffff', borderWidth: 1, shadowBlur: 2, shadowColor: 'rgba(0,0,0,0.06)' }
|
||||
: { color: '#303133', borderColor: '#dcdfe6' },
|
||||
lineStyle: { color: '#c0c4cc', width: 1.2 },
|
||||
children
|
||||
}))
|
||||
}));
|
||||
|
||||
return {
|
||||
name: projectName,
|
||||
itemStyle: { color: '#1890FF' }, // 根节点(项目名)蓝色高亮
|
||||
symbolSize: dm ? 7 : undefined,
|
||||
itemStyle: dm
|
||||
? { color: '#409eff', borderColor: '#ffffff', borderWidth: 1.5, shadowBlur: 4, shadowColor: 'rgba(64,158,255,0.35)' }
|
||||
: { color: '#409eff', borderColor: '#409eff' },
|
||||
lineStyle: { color: '#a0cfff', width: 1.5 },
|
||||
label: dm ? { distance: 8, fontSize: 11 } : undefined,
|
||||
children: treeChildren
|
||||
};
|
||||
},
|
||||
@@ -238,68 +300,136 @@ export default {
|
||||
}
|
||||
// 转换数据格式
|
||||
const treeData = this.transformToTreeData(this.list);
|
||||
// 设置图表配置项
|
||||
const dm = this.dashboardMode;
|
||||
const escapeHtml = (s) => String(s == null ? '' : s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
const option = {
|
||||
backgroundColor: dm ? 'transparent' : undefined,
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: ({ data }) => {
|
||||
// 鼠标悬浮展示完整业务信息
|
||||
let tip = `<div style="font-size:14px"><b>${data.name}</b></div>`;
|
||||
if (data.value) {
|
||||
// Object.keys(data.value).forEach(key => {
|
||||
// tip += `<div>${key}:${data.value[key]}</div>`;
|
||||
// });
|
||||
tip += `<div>负责人:${data.value.nodeHeader || '无'}</div>`;
|
||||
tip += `<div>规格需求:${data.value.specification || '无'}</div>`;
|
||||
tip += `<div>任务状态:${data.value.statusText || '无'}</div>`;
|
||||
tip += `<div>计划完成:${data.value.planEnd || '无'}</div>`;
|
||||
enterable: true,
|
||||
confine: true,
|
||||
extraCssText: 'max-width:420px;white-space:normal;word-break:break-word;border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,0.08);',
|
||||
formatter: (params) => {
|
||||
const data = params.data
|
||||
const title = (data && data.name) != null ? data.name : (params.name || '')
|
||||
let tip = `<div style="font-size:13px;font-weight:600;margin-bottom:6px;line-height:1.45;color:#303133">${escapeHtml(title)}</div>`
|
||||
if (data && data.value) {
|
||||
const v = data.value
|
||||
tip += `<div style="font-size:12px;line-height:1.55;color:#606266">负责人:${v.nodeHeader != null && v.nodeHeader !== '' ? escapeHtml(v.nodeHeader) : '无'}</div>`
|
||||
tip += `<div style="font-size:12px;line-height:1.55;color:#606266">规格需求:${v.specification != null && v.specification !== '' ? escapeHtml(v.specification) : '无'}</div>`
|
||||
tip += `<div style="font-size:12px;line-height:1.55;color:#606266">状态:${v.statusLabel != null && v.statusLabel !== '' ? escapeHtml(v.statusLabel) : '无'}</div>`
|
||||
tip += `<div style="font-size:12px;line-height:1.55;color:#606266">计划完成:${v.planEnd != null && v.planEnd !== '' ? escapeHtml(v.planEnd) : '无'}</div>`
|
||||
}
|
||||
return tip;
|
||||
return tip
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'tree', // 树图核心类型
|
||||
type: 'tree',
|
||||
data: [treeData],
|
||||
symbol: 'circle', // 节点形状:圆点
|
||||
symbolSize: 6, // 节点大小
|
||||
orient: 'LR', // 树图展开方向:LR=从左到右(脑图样式),可选 TB(从上到下)
|
||||
initialTreeDepth: 2, // 默认展开层级:2级
|
||||
roam: true, // 开启鼠标拖拽+滚轮缩放
|
||||
/* 与折线图区域一致:留白、白底在容器上 */
|
||||
...(dm ? { left: '1%', right: '5%', top: '2%', bottom: '2%' } : {}),
|
||||
symbol: 'circle',
|
||||
...(dm ? {} : { symbolSize: 6 }),
|
||||
edgeShape: dm ? 'polyline' : 'curve',
|
||||
edgeForkPosition: dm ? '74%' : '50%',
|
||||
orient: 'LR',
|
||||
initialTreeDepth: 4,
|
||||
roam: true,
|
||||
scaleLimit: dm ? { min: 0.22, max: 5 } : undefined,
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
position: 'left', // 文字在节点左侧
|
||||
verticalAlign: 'middle'
|
||||
fontSize: dm ? 11 : 12,
|
||||
fontWeight: 400,
|
||||
position: 'left',
|
||||
verticalAlign: 'middle',
|
||||
...(dm ? { align: 'right' } : {}),
|
||||
distance: 8,
|
||||
overflow: 'none',
|
||||
lineHeight: dm ? 15 : 14,
|
||||
color: dm ? '#606266' : undefined
|
||||
},
|
||||
/*
|
||||
* levels[i]:根下一层起 Tab / 分类 / 叶;缩小状态圆点,叶节点标签放右侧防挤压
|
||||
*/
|
||||
levels: dm
|
||||
? [
|
||||
{
|
||||
symbolSize: 6,
|
||||
itemStyle: { borderWidth: 1, borderColor: '#fff' },
|
||||
label: {
|
||||
position: 'left',
|
||||
distance: 8,
|
||||
fontSize: 11,
|
||||
width: 118,
|
||||
overflow: 'break',
|
||||
lineHeight: 14,
|
||||
padding: [2, 6, 2, 6]
|
||||
}
|
||||
},
|
||||
{
|
||||
symbolSize: 5,
|
||||
itemStyle: { borderWidth: 1, borderColor: '#fff' },
|
||||
label: {
|
||||
position: 'left',
|
||||
distance: 10,
|
||||
fontSize: 11,
|
||||
width: 160,
|
||||
overflow: 'break',
|
||||
lineHeight: 14,
|
||||
padding: [2, 8, 2, 6]
|
||||
}
|
||||
},
|
||||
{
|
||||
symbolSize: 4,
|
||||
itemStyle: { borderWidth: 1, borderColor: '#fff', shadowBlur: 2, shadowColor: 'rgba(0,0,0,0.1)' },
|
||||
label: {
|
||||
position: 'right',
|
||||
verticalAlign: 'middle',
|
||||
align: 'left',
|
||||
distance: 12,
|
||||
fontSize: 11,
|
||||
width: 232,
|
||||
overflow: 'break',
|
||||
lineHeight: 15,
|
||||
padding: [2, 8, 2, 8],
|
||||
formatter: (p) => this.wrapLabelText(p.name, 17)
|
||||
}
|
||||
}
|
||||
]
|
||||
: [
|
||||
{ symbolSize: 10 },
|
||||
{ symbolSize: 7 },
|
||||
{ symbolSize: 5 },
|
||||
{ symbolSize: 3 }
|
||||
],
|
||||
lineStyle: {
|
||||
width: 1.2,
|
||||
curveness: 0.3, // 连接线曲率,0=直线,0.3=轻微曲线
|
||||
color: '#ccc'
|
||||
width: dm ? 1 : 1.2,
|
||||
curveness: dm ? 0.1 : 0.3,
|
||||
color: dm ? '#e4e7ed' : '#ccc'
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'descendant' // 鼠标悬浮时高亮当前节点及子节点
|
||||
focus: 'descendant',
|
||||
lineStyle: { width: 2, color: '#409eff' },
|
||||
itemStyle: dm ? { shadowBlur: 6, shadowColor: 'rgba(64,158,255,0.45)' } : undefined
|
||||
},
|
||||
expandAndCollapse: true, // 开启节点折叠/展开功能
|
||||
animationDuration: 300 // 展开折叠动画时长
|
||||
expandAndCollapse: true,
|
||||
animationDuration: 280
|
||||
}
|
||||
]
|
||||
};
|
||||
// 渲染图表
|
||||
this.chartInstance?.setOption(option, true);
|
||||
|
||||
// ========== 核心新增:绑定ECharts点击事件,只对三级节点生效 ==========
|
||||
this.clickEvent = (params) => {
|
||||
console.log(params);
|
||||
const { data, treeAncestors } = params;
|
||||
// ✅ 核心判断:treeAncestors是当前节点的「所有上级节点数组」
|
||||
// 根节点(项目名) → 一级节点 → 三级节点 :treeAncestors.length = 2 → 精准匹配第三级节点
|
||||
// 层级对应关系:根节点(0级) → 一级分类(1级) → 业务节点(3级/你要的三级)
|
||||
if (treeAncestors.length === 4) {
|
||||
console.log(data);
|
||||
this.currentNode = { ...data.value }; // 深拷贝当前节点完整数据
|
||||
this.dialogVisible = true; // 打开弹窗
|
||||
const data = params.data;
|
||||
if (data && data.value && data.value.trackId) {
|
||||
this.currentNode = { ...data.value };
|
||||
this.dialogVisible = true;
|
||||
}
|
||||
};
|
||||
// 绑定点击事件
|
||||
@@ -326,6 +456,23 @@ export default {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.xmind-box--dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 与综合看板折线图区域一致:白底、细边框、轻圆角 */
|
||||
.xmind-box--dashboard .xmind-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 6px;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
/* 新增:弹窗内部样式美化 */
|
||||
:deep(.dialog-content) {
|
||||
padding: 10px 0;
|
||||
|
||||
Reference in New Issue
Block a user