Compare commits

..

13 Commits

Author SHA1 Message Date
0400398361 feat(geolocation): 完善工作地点获取功能
- 新增高德地图逆地理编码接口,支持根据浏览器定位自动获取工作地点。
- 在项目报工页面中实现工作地点的自动更新,用户可通过按钮重新获取定位。
- 添加加载状态和错误提示,提升用户体验。
- 优化相关方法以处理地理位置获取的异步操作。
2026-04-17 13:57:27 +08:00
edca68136c feat(geolocation): 添加工作地点自动获取功能
- 在项目报工页面中新增工作地点自动获取功能,用户可通过点击按钮重新获取定位。
- 添加了工作地点加载状态和错误提示,提升用户体验。
- 优化了相关方法以处理地理位置获取的异步操作。
2026-04-17 13:06:39 +08:00
1584d7e06d 提交app同步后端 2026-04-17 12:05:15 +08:00
7c261c3028 feat: 完善高德地图地点搜索功能 2026-04-17 10:36:18 +08:00
5d0c056449 feat: 完成出差目的地高德地图选择功能(2) 2026-04-16 16:22:05 +08:00
fd72c18d48 feat: 完成出差目的地高德地图选择功能 2026-04-16 15:37:08 +08:00
dfd912bf07 feat(security): 新增高德逆地理编码接口并配置RestTemplate超时时间 2026-04-16 11:05:02 +08:00
8b627c000f Merge branch 'main' of http://49.232.154.205:10100/liujingchao/fad_oa 2026-04-15 18:39:01 +08:00
56f7a6abb9 feat:完成出差申请提前结束功能 2026-04-15 18:32:59 +08:00
ebb57f4f26 fix(oa): 修复项目进度步骤查询遗漏删除标记过滤条件
- 在步骤表公共筛选方法中添加删除标记等于0的过滤条件
- 确保查询结果不包含已删除的步骤记录
- 修复因缺少软删除过滤导致的数据查询异常问题
2026-04-15 18:05:23 +08:00
365fc08b62 Merge branch 'main' of http://49.232.154.205:10100/liujingchao/fad_oa into main 2026-04-15 17:26:25 +08:00
50f3f15f48 feat(项目看板): 新增项目综合看板功能
新增项目综合看板功能,聚合展示项目、任务、进度主表和步骤数据
- 新增后端聚合接口 GET /oa/project/dashboard/{projectId}
- 新增前端看板页面,包含项目列表、任务表格和进度导图
- 优化思维导图组件,支持看板模式下的紧凑展示
- 新增进度明细表格视图和状态图例
- 实现任务与进度步骤的关联展示
- 添加项目模糊搜索功能
2026-04-15 17:19:56 +08:00
9c64dd8451 修改办公模块预览组件,屏蔽通过条件下依然可以文件上传问题 2026-04-15 16:52:27 +08:00
51 changed files with 2751 additions and 290 deletions

View File

@@ -1,6 +1,7 @@
package com.ruoyi.hrm.controller;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.AjaxResult;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.PageQuery;
import com.ruoyi.common.core.domain.R;
@@ -10,6 +11,7 @@ import com.ruoyi.hrm.domain.bo.HrmTravelReqBo;
import com.ruoyi.hrm.domain.vo.HrmTravelReqVo;
import com.ruoyi.hrm.service.IHrmTravelReqService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@@ -28,6 +30,13 @@ import java.util.List;
public class HrmTravelReqController extends BaseController {
private final IHrmTravelReqService service;
private final IHrmTravelReqService hrmTravelReqService;
@Value("${fad.amap.webkey}")
private String amapKey;
@Value("${fad.amap.securitykey}")
private String amapSecurityKey;
@GetMapping("/list")
public TableDataInfo<HrmTravelReqVo> list(HrmTravelReqBo bo, PageQuery pageQuery) {
@@ -56,6 +65,20 @@ public class HrmTravelReqController extends BaseController {
public R<Void> remove(@PathVariable @NotEmpty Long[] bizIds) {
return toAjax(service.deleteWithValidByIds(Arrays.asList(bizIds), true));
}
@PutMapping("/earlyEnd/{bizId}")
public R<Void> earlyEnd(@PathVariable Long bizId) {
return toAjax(hrmTravelReqService.earlyEnd(bizId));
}
@GetMapping("/amapKey")
public R<String> getAmapKey() {
return R.ok(amapKey);
}
@GetMapping("/amapSecurityKey")
public R<String> getAmapSecurityKey() {
return R.ok(amapSecurityKey);
}
@GetMapping("/all")
public R<List<HrmTravelReqVo>> all(HrmTravelReqBo bo) {

View File

@@ -32,6 +32,7 @@ public class HrmTravelReq extends BaseEntity {
private String bankName;
private String bankAccount;
private String remark;
private Date actualEndTime;
@TableLogic
private Integer delFlag;
}

View File

@@ -22,5 +22,7 @@ public interface IHrmTravelReqService {
Boolean updateByBo(HrmTravelReqBo bo);
int earlyEnd(Long bizId);
Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid);
}

View File

@@ -13,10 +13,12 @@ import com.ruoyi.hrm.domain.*;
import com.ruoyi.hrm.domain.bo.HrmFlowTaskBo;
import com.ruoyi.hrm.domain.bo.HrmSealStampBo;
import com.ruoyi.hrm.domain.vo.HrmFlowTaskVo;
import com.ruoyi.hrm.domain.vo.HrmEmployeeVo;
import com.ruoyi.hrm.mapper.*;
import com.ruoyi.hrm.service.IHrmFlowTaskService;
import com.ruoyi.hrm.service.IHrmSealReqService;
import com.ruoyi.hrm.service.IHrmFlowCcService;
import com.ruoyi.hrm.service.IHrmEmployeeService;
import com.ruoyi.hrm.domain.bo.HrmFlowCcBo;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -47,6 +49,7 @@ public class HrmFlowTaskServiceImpl implements IHrmFlowTaskService {
private final HrmAppropriationReqMapper appropriationReqMapper;
private final ObjectMapper objectMapper; // Spring Boot 默认提供
private final UserService userService;
private final IHrmEmployeeService employeeService;
@Override
public HrmFlowTaskVo queryById(Long taskId) {
@@ -74,7 +77,7 @@ public class HrmFlowTaskServiceImpl implements IHrmFlowTaskService {
return tasks;
}
private void fillBizData(List<HrmFlowTaskVo> tasks) {
private void fillBizData(List<HrmFlowTaskVo> tasks) {
// 1. 按 bizType 分组,并收集 bizId
Map<String, List<Long>> bizIdsByType = tasks.stream()
.filter(t -> t.getBizType() != null && t.getBizId() != null)
@@ -90,19 +93,39 @@ public class HrmFlowTaskServiceImpl implements IHrmFlowTaskService {
if (bizIds.isEmpty()) return;
switch (bizType) {
case "leave":
leaveReqMapper.selectBatchIds(bizIds).forEach(d -> bizDataMap.put("leave_" + d.getBizId(), d));
leaveReqMapper.selectBatchIds(bizIds).forEach(d -> {
Map<String, Object> dataMap = objectMapper.convertValue(d, Map.class);
fillEmpName(dataMap, d.getEmpId());
bizDataMap.put("leave_" + d.getBizId(), dataMap);
});
break;
case "travel":
travelReqMapper.selectBatchIds(bizIds).forEach(d -> bizDataMap.put("travel_" + d.getBizId(), d));
travelReqMapper.selectBatchIds(bizIds).forEach(d -> {
Map<String, Object> dataMap = objectMapper.convertValue(d, Map.class);
fillEmpName(dataMap, d.getEmpId());
bizDataMap.put("travel_" + d.getBizId(), dataMap);
});
break;
case "seal":
sealReqMapper.selectBatchIds(bizIds).forEach(d -> bizDataMap.put("seal_" + d.getBizId(), d));
sealReqMapper.selectBatchIds(bizIds).forEach(d -> {
Map<String, Object> dataMap = objectMapper.convertValue(d, Map.class);
fillEmpName(dataMap, d.getEmpId());
bizDataMap.put("seal_" + d.getBizId(), dataMap);
});
break;
case "reimburse":
reimburseReqMapper.selectBatchIds(bizIds).forEach(d -> bizDataMap.put("reimburse_" + d.getBizId(), d));
reimburseReqMapper.selectBatchIds(bizIds).forEach(d -> {
Map<String, Object> dataMap = objectMapper.convertValue(d, Map.class);
fillEmpName(dataMap, d.getEmpId());
bizDataMap.put("reimburse_" + d.getBizId(), dataMap);
});
break;
case "appropriation":
appropriationReqMapper.selectBatchIds(bizIds).forEach(d -> bizDataMap.put("appropriation_" + d.getBizId(), d));
appropriationReqMapper.selectBatchIds(bizIds).forEach(d -> {
Map<String, Object> dataMap = objectMapper.convertValue(d, Map.class);
fillEmpName(dataMap, d.getEmpId());
bizDataMap.put("appropriation_" + d.getBizId(), dataMap);
});
break;
}
});
@@ -112,13 +135,20 @@ public class HrmFlowTaskServiceImpl implements IHrmFlowTaskService {
String key = task.getBizType() + "_" + task.getBizId();
Object data = bizDataMap.get(key);
if (data != null) {
// 将实体对象转换为 Map<String, Object>,方便前端使用
Map<String, Object> dataMap = objectMapper.convertValue(data, Map.class);
task.setBizData(dataMap);
task.setBizData((Map<String, Object>) data);
}
});
}
private void fillEmpName(Map<String, Object> dataMap, Long empId) {
if (empId != null) {
HrmEmployeeVo emp = employeeService.queryById(empId);
if (emp != null && emp.getEmpName() != null) {
dataMap.put("empName", emp.getEmpName());
}
}
}
@Override
@Transactional(rollbackFor = Exception.class)
@@ -416,6 +446,11 @@ public class HrmFlowTaskServiceImpl implements IHrmFlowTaskService {
if (result.getRecords() != null && !result.getRecords().isEmpty()) {
fillBizData(result.getRecords());
result.getRecords().forEach(task -> {
if (task.getAssigneeUserId() != null) {
task.setAssigneeNickName(userService.selectNickNameById(task.getAssigneeUserId()));
}
});
}
return TableDataInfo.build(result);

View File

@@ -22,6 +22,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.List;
import java.util.Date;
@RequiredArgsConstructor
@Service
@@ -128,4 +129,36 @@ public class HrmTravelReqServiceImpl implements IHrmTravelReqService {
private String defaultStatus(String status) {
return status == null ? "draft" : status;
}
@Override
@Transactional(rollbackFor = Exception.class)
public int earlyEnd(Long bizId) {
HrmTravelReq travelReq = baseMapper.selectById(bizId);
if (travelReq == null) {
throw new RuntimeException("出差申请不存在");
}
String status = travelReq.getStatus();
if (!"approved".equals(status) && !"in_progress".equals(status)) {
throw new RuntimeException("当前状态不能提前结束,只有已通过或进行中的出差才能提前结束");
}
// 3. 检查是否已经提前结束过
if (travelReq.getActualEndTime() != null) {
throw new RuntimeException("该出差已经提前结束过了");
}
// 4. 更新实际结束时间为当前时间
travelReq.setActualEndTime(new Date());
// 5. 可选:更新状态为已完成
travelReq.setStatus("completed");
// 6. 执行更新
int result = baseMapper.updateById(travelReq);
if (result <= 0) {
throw new RuntimeException("提前结束失败");
}
return result;
}
}

View File

@@ -51,12 +51,13 @@
<sql id="selectVo">
SELECT
e.emp_id, e.user_id, e.emp_no, e.emp_name, e.gender, e.mobile, e.email, e.id_no,
e.hire_date, e.employment_type, e.status, e.dept_id, e.post_id, e.remark,
e.hire_date, e.employment_type, e.status, su.dept_id, e.post_id, e.remark,
e.create_by, e.create_time, e.update_by, e.update_time,
d.dept_name,
p.post_name
FROM hrm_employee e
LEFT JOIN sys_dept d ON e.dept_id = d.dept_id AND d.del_flag = '0'
left join sys_user su on su.user_id = e.user_id
LEFT JOIN sys_dept d ON su.dept_id = d.dept_id AND d.del_flag = 0
LEFT JOIN sys_post p ON e.post_id = p.post_id
WHERE e.del_flag = 0
</sql>

View File

@@ -170,6 +170,8 @@ security:
- /oa/attendanceRecord/**
- /oa/oaWarehouse/**
- /oa/oaWarehouseMaster/**
# 高德逆地理(经纬度转城市等,供前端/H5 调用)
- /oa/amap/**
# MyBatisPlus配置
# https://baomidou.com/config/
@@ -317,8 +319,12 @@ flowable:
check-process-definitions: false
# 关闭历史任务定时任务job
async-history-executor-activate: false
fad:
amap:
key: 978ae5bc551f57d172d3e397af5a6f67
# 留作后端接口调用(服务 API的 Key
key: 978ae5bc551f57d172d3e397af5a6f67
# 新增的前端 Web 端使用的 Key 和安全密钥
webKey: 34bf20d1db5b183558b9bb85d6eed783
securityKey: 6f9171724396deb5f8c42ef256b3cbc5

View File

@@ -2,13 +2,20 @@ package com.ruoyi.framework.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
@Configuration
public class RestTemplateConfig {
private static final int CONNECT_TIMEOUT_MS = 5_000;
private static final int READ_TIMEOUT_MS = 15_000;
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(CONNECT_TIMEOUT_MS);
factory.setReadTimeout(READ_TIMEOUT_MS);
return new RestTemplate(factory);
}
}

View File

@@ -0,0 +1,46 @@
package com.ruoyi.oa.controller;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.oa.domain.vo.AmapCityNameVo;
import com.ruoyi.oa.service.IOaAmapGeocodeService;
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 javax.validation.constraints.DecimalMax;
import javax.validation.constraints.DecimalMin;
import javax.validation.constraints.NotNull;
/**
* 高德地图:经纬度逆地理编码(城市名等)
*/
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/oa/amap")
public class OaAmapController extends BaseController {
private final IOaAmapGeocodeService oaAmapGeocodeService;
/**
* 根据经纬度获取城市名称(高德逆地理编码)
*/
@GetMapping("/city")
public R<AmapCityNameVo> cityByLocation(
@NotNull(message = "经度不能为空")
@DecimalMin(value = "-180.0", message = "经度范围无效")
@DecimalMax(value = "180.0", message = "经度范围无效")
@RequestParam Double longitude,
@NotNull(message = "纬度不能为空")
@DecimalMin(value = "-90.0", message = "纬度范围无效")
@DecimalMax(value = "90.0", message = "纬度范围无效")
@RequestParam Double latitude
) {
AmapCityNameVo vo = oaAmapGeocodeService.reverseGeocodeCity(longitude, latitude);
return R.ok(vo);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
/**
* 获取项目管理详细信息
*

View File

@@ -201,6 +201,12 @@ public class SysOaProjectBo extends BaseEntity {
private Long customerId;
//客户名称
private String customerName;
/**
* 综合模糊查询(名称 / 编号 / 代号 任一匹配),与 projectName、projectNum、projectCode 互斥:有值时优先使用本字段
*/
private String keyword;
//是否置顶
private Integer isTop;

View File

@@ -0,0 +1,23 @@
package com.ruoyi.oa.domain.vo;
import lombok.Data;
import java.io.Serializable;
/**
* 高德逆地理编码:城市名称(及可选行政区信息,便于展示/调试)
*/
@Data
public class AmapCityNameVo implements Serializable {
private static final long serialVersionUID = 1L;
/** 城市名(直辖市、省直辖等场景可能为省名或区名,与高德 addressComponent 一致) */
private String cityName;
/** 省 */
private String province;
/** 区/县 */
private String district;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 集合

View File

@@ -46,6 +46,11 @@ public interface SysOaTaskMapper extends BaseMapperPlus<SysOaTaskMapper, SysOaTa
List<SysOaTaskVo> listDocumentProject(Long projectId);
/**
* 综合看板:某项目下任务(一行一条任务)
*/
List<SysOaTaskVo> selectDashboardTasksByProjectId(@Param("projectId") Long projectId);
/**
* 新增自定义查询任务列表Plus版
*/

View File

@@ -0,0 +1,18 @@
package com.ruoyi.oa.service;
import com.ruoyi.oa.domain.vo.AmapCityNameVo;
/**
* 高德地图逆地理编码(经纬度 → 城市等)
*/
public interface IOaAmapGeocodeService {
/**
* 根据经纬度解析城市名称等信息
*
* @param longitude 经度
* @param latitude 纬度
* @return 非 null解析失败时 cityName 等可能为空
*/
AmapCityNameVo reverseGeocodeCity(Double longitude, Double latitude);
}

View File

@@ -38,6 +38,11 @@ public interface IOaProjectScheduleStepService{
*/
List<OaProjectScheduleStepVo> queryList(OaProjectScheduleStepBo bo);
/**
* 按 scheduleId 查询进度步骤列表(综合看板聚合接口使用)
*/
List<OaProjectScheduleStepVo> selectProjectScheduleStepList(Long scheduleId);
/**
* 新增项目进度步骤跟踪
*/

View File

@@ -103,4 +103,9 @@ public interface ISysOaProjectService {
Boolean postponeProject(SysOaProject bo);
SysOaProjectVo getMaxCode(String prefix);
/**
* 综合看板:项目详情 + 任务 + 进度主表 + 进度步骤(一次返回)
*/
OaProjectDashboardVo getProjectDashboard(Long projectId);
}

View File

@@ -109,6 +109,11 @@ public interface ISysOaTaskService {
* @return
*/
List<SysOaTaskVo> listDocumentProject(Long projectId);
/**
* 综合看板任务列表(完整字段,无 task_item 重复行)
*/
List<SysOaTaskVo> listDashboardTasks(Long projectId);
/**
* 新增自定义查询任务列表Plus版
*/

View File

@@ -0,0 +1,118 @@
package com.ruoyi.oa.service.impl;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.oa.domain.vo.AmapCityNameVo;
import com.ruoyi.oa.service.IOaAmapGeocodeService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
/**
* 高德逆地理编码restapi.amap.com/v3/geocode/regeo
* <p>
* 配置项:{@code fad.amap.key}(与 {@code application.yml} 中一致)
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OaAmapGeocodeServiceImpl implements IOaAmapGeocodeService {
private static final String AMAP_REGEO_URL = "https://restapi.amap.com/v3/geocode/regeo";
private final RestTemplate restTemplate;
@Value("${fad.amap.key:}")
private String amapKey;
@Override
public AmapCityNameVo reverseGeocodeCity(Double longitude, Double latitude) {
AmapCityNameVo vo = new AmapCityNameVo();
if (longitude == null || latitude == null) {
return vo;
}
if (!StringUtils.hasText(amapKey)) {
log.warn("fad.amap.key 未配置,无法调用高德逆地理编码");
return vo;
}
try {
// 高德要求location = 经度,纬度
String location = longitude + "," + latitude;
String url = UriComponentsBuilder.fromHttpUrl(AMAP_REGEO_URL)
.queryParam("key", amapKey)
.queryParam("location", location)
.queryParam("extensions", "base")
.queryParam("batch", "false")
.queryParam("output", "JSON")
.build(true)
.toUriString();
String body = restTemplate.getForObject(url, String.class);
if (!StringUtils.hasText(body)) {
log.warn("高德逆地理编码响应为空, location={}", location);
return vo;
}
JSONObject response = JSONObject.parseObject(body);
if (response == null) {
log.warn("高德逆地理编码 JSON 解析失败, location={}", location);
return vo;
}
if (!"1".equals(response.getString("status"))) {
String info = response.getString("info");
log.warn("高德逆地理编码失败: status={}, info={}, infocode={}",
response.getString("status"), info, response.getString("infocode"));
return vo;
}
JSONObject regeocode = response.getJSONObject("regeocode");
if (regeocode == null) {
return vo;
}
JSONObject addressComponent = regeocode.getJSONObject("addressComponent");
if (addressComponent == null) {
return vo;
}
String province = nullToEmpty(addressComponent.getString("province"));
String district = nullToEmpty(addressComponent.getString("district"));
vo.setProvince(province);
vo.setDistrict(district);
String cityName = resolveCityName(addressComponent, province);
vo.setCityName(cityName);
return vo;
} catch (Exception e) {
log.warn("高德逆地理编码异常, longitude={}, latitude={}, err={}",
longitude, latitude, e.getMessage(), e);
return vo;
}
}
/**
* 城市字段:普通城市为字符串;部分省直辖为 [];直辖市可能用省名表示
*/
private static String resolveCityName(JSONObject addressComponent, String province) {
Object cityRaw = addressComponent.get("city");
if (cityRaw instanceof JSONArray) {
JSONArray arr = (JSONArray) cityRaw;
if (!arr.isEmpty()) {
return nullToEmpty(arr.getString(0));
}
return province;
}
String city = addressComponent.getString("city");
if (StringUtils.hasText(city) && !"[]".equals(city)) {
return city.trim();
}
return province;
}
private static String nullToEmpty(String s) {
return s == null ? "" : s.trim();
}
}

View File

@@ -68,7 +68,8 @@ public class OaProjectScheduleServiceImpl implements IOaProjectScheduleService {
*/
@Override
public TableDataInfo<OaProjectScheduleVo> queryPageList(OaProjectScheduleBo bo, PageQuery pageQuery) {
QueryWrapper<OaProjectSchedule> lqw = buildQueryWrapper(bo);
// 分页列表走自定义 Join SQLXML 中存在 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 默认 SQLFROM oa_project_schedule
* 仅使用 oa_project_schedule 表字段,严格对齐表结构:
* - oa_project_scheduleproject_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;
}
/**
* 新增项目进度
*/

View File

@@ -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,42 @@ 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 trueXML 中带 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(stepCol(alias, "del_flag"), 0);
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());
}
/**
* 新增项目进度步骤跟踪
*/

View File

@@ -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;
}
}

View File

@@ -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版
*/

View File

@@ -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>

View File

@@ -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,

View File

@@ -5,7 +5,7 @@ VUE_APP_TITLE = 福安德综合办公系统
ENV = 'development'
# 若依管理系统/开发环境
VUE_APP_BASE_API = '/dev-api'
VUE_APP_BASE_API = '/dev-api'
# VUE_APP_BASE_API = 'http://110.41.139.73:8080'
# 应用访问路径 例如使用前缀 /admin/

View File

@@ -37,6 +37,7 @@
"url": "https://gitee.com/KonBAI-Q/ruoyi-flowable-plus.git"
},
"dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@babel/parser": "7.7.4",
"@handsontable/vue": "^15.3.0",
"@jiaminghi/data-view": "^2.10.0",

View File

@@ -10,6 +10,7 @@
<title>
<%= webpackConfig.name %>
</title>
<!--[if lt IE 11]><script>window.location.href='/html/ie.html';</script><![endif]-->
<style>
html,
@@ -18,7 +19,7 @@
height: 100%;
margin: 0px;
padding: 0px;
}
.chromeframe {
margin: 0.2em 0;
@@ -216,4 +217,4 @@
</div>
</body>
</html>
</html>

View File

@@ -254,9 +254,4 @@ export function listAssignTask (instId) {
url: `/hrm/flow/instance/tasks/${instId}`,
method: 'get'
})
/**
* 查询当前用户的审批历史
*/
}

View File

@@ -46,3 +46,22 @@ export function allTravelReq(query) {
params: query
})
}
export function earlyEndTravel(bizId) {
return request({
url: `/hrm/travel/earlyEnd/${bizId}`,
method: 'put'
})
}
export function getAmapKey() {
return request({
url: '/hrm/travel/amapKey',
method: 'get'
})
}
export function getAmapSecurityKey() {
return request({
url: '/hrm/travel/amapSecurityKey',
method: 'get'
})
}

View File

@@ -0,0 +1,15 @@
import request from '@/utils/request'
/**
* 根据经纬度逆地理编码获取城市等信息(后端转发高德)
* @param {number} longitude 经度
* @param {number} latitude 纬度
*/
export function getCityByLocation (longitude, latitude) {
return request({
url: '/oa/amap/city',
method: 'get',
params: { longitude, latitude },
timeout: 15000
})
}

View File

@@ -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({

View 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
})
}

View File

@@ -0,0 +1,371 @@
<template>
<div class="amap-city-select">
<el-input
v-model="cityName"
:placeholder="placeholder"
@focus="openMapDialog"
clearable
readonly
>
<i slot="prefix" class="el-icon-location" style="color: #409eff"></i>
</el-input>
<el-dialog
title="选择出差城市 / 地点"
:visible.sync="dialogVisible"
width="900px"
:append-to-body="true"
@opened="onDialogOpened"
>
<div class="map-selector">
<div class="city-sidebar">
<div class="search-box">
<el-input
v-model="searchKeyword"
placeholder="输入城市或具体地点搜索"
size="small"
prefix-icon="el-icon-search"
@keyup.enter.native="searchLocation"
clearable
@clear="searchKeyword = ''"
>
<el-button slot="append" @click="searchLocation">搜索</el-button>
</el-input>
</div>
<!-- 热门城市模块 -->
<div class="hot-cities" v-if="!searchKeyword || searchResults.length === 0">
<div class="section-title">热门城市</div>
<div class="city-list">
<span
v-for="city in hotCities"
:key="city"
class="city-item"
@click="selectHotCity(city)"
>
{{ city }}
</span>
</div>
</div>
<!-- 搜索结果模块 -->
<div class="search-results" v-if="searchResults.length > 0">
<div class="section-title">搜索结果</div>
<div class="result-list">
<div
v-for="(result, index) in searchResults"
:key="index"
class="result-item"
@click="selectLocation(result)"
>
<i class="el-icon-location-outline"></i>
<div class="result-info">
<div class="poi-name">{{ result.name }}</div>
<div class="poi-address">{{ result.address }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="map-container">
<div id="amapContainer" class="amap-wrapper"></div>
<div class="map-tip">
<i class="el-icon-info"></i> 点击地图上的位置或搜索地点自动识别归属城市
</div>
</div>
</div>
<div slot="footer" class="dialog-footer">
<div class="selected-info" v-if="selectedCity">
识别城市<span class="selected-city">{{ selectedCity }}</span>
<span v-if="selectedPoi" style="margin-left: 10px; color: #909399; font-size: 12px;">
( {{ selectedPoi }} )
</span>
</div>
<div v-else></div>
<div>
<el-button @click="closeDialog">取消</el-button>
<el-button type="primary" @click="confirmCity" :disabled="!selectedCity">确定</el-button>
</div>
</div>
</el-dialog>
</div>
</template>
<script>
import AMapLoader from '@amap/amap-jsapi-loader'
import { getAmapKey, getAmapSecurityKey } from '@/api/hrm/travel'
export default {
name: 'AmapCitySelect',
props: {
value: { type: String, default: '' },
placeholder: { type: String, default: '请选择出差地点' }
},
data() {
return {
cityName: '',
dialogVisible: false,
searchKeyword: '',
searchResults: [],
selectedCity: '', // 最终表单需要的城市名
selectedPoi: '', // 具体的地点名(仅用于展示)
map: null,
geocoder: null,
placeSearch: null, // 新增地点搜索对象
marker: null,
AMap: null,
hotCities: [
'北京市', '上海市', '广州市', '深圳市',
'杭州市', '南京市', '成都市', '武汉市',
'重庆市', '天津市', '苏州市', '西安市'
]
}
},
watch: {
value: {
immediate: true,
handler(val) { this.cityName = val || '' }
}
},
methods: {
async onDialogOpened() {
const AMap = await this.loadAMap()
if (!AMap) return
this.$nextTick(() => {
this.initMap()
})
},
async loadAMap() {
if (this.AMap && window._AMapSecurityConfig) return this.AMap
try {
const [keyRes, securityRes] = await Promise.all([getAmapKey(), getAmapSecurityKey()])
const amapkey = keyRes.data || keyRes.msg
const securityKey = securityRes.data || securityRes.msg
if (!amapkey) throw new Error('未获取到高德地图 Key')
window._AMapSecurityConfig = { securityJsCode: securityKey }
this.AMap = await AMapLoader.load({
key: amapkey,
version: '2.0',
// 加入 PlaceSearch 插件
plugins: ['AMap.Geocoder', 'AMap.PlaceSearch']
})
return this.AMap
} catch (error) {
console.error('地图加载失败:', error)
this.$message.error('地图加载失败,请重试')
return null
}
},
initMap() {
const container = document.getElementById('amapContainer')
if (!container) {
setTimeout(() => this.initMap(), 100)
return
}
if (this.map) {
this.map.destroy()
this.map = null
}
this.map = new this.AMap.Map('amapContainer', {
zoom: 12,
center: [116.397428, 39.90923], // 默认北京
resizeEnable: true
})
this.geocoder = new this.AMap.Geocoder()
// 初始化地点搜索插件
this.placeSearch = new this.AMap.PlaceSearch({
pageSize: 15, // 单页显示结果条数
pageIndex: 1, // 页码
autoFitView: false // 禁用自动调整视图,我们自己控制
})
// 监听地图点击
this.map.on('click', (e) => {
const lng = e.lnglat.getLng()
const lat = e.lnglat.getLat()
this.selectedPoi = '地图选点'
this.getCityByLngLat(lng, lat, true)
})
},
// 通过经纬度逆解析城市
getCityByLngLat(lng, lat, setCenter = false) {
this.geocoder.getAddress([lng, lat], (status, result) => {
if (status === 'complete' && result.regeocode) {
const addrComp = result.regeocode.addressComponent
let city = addrComp.city
// 直辖市的 city 可能是空的,取 province
if (!city || city === '[]' || city.length === 0) {
city = addrComp.province
}
this.selectedCity = city
this.addMarker(lng, lat)
if(setCenter) {
this.map.setCenter([lng, lat])
}
}
})
},
// 在地图上打点
addMarker(lng, lat) {
if (this.marker) {
this.marker.setMap(null)
}
this.marker = new this.AMap.Marker({
position: [lng, lat],
map: this.map,
animation: 'AMAP_ANIMATION_DROP' // 加上掉落动画
})
},
openMapDialog() {
this.dialogVisible = true
this.selectedCity = this.value || ''
this.selectedPoi = ''
this.searchKeyword = ''
this.searchResults = []
},
// 搜索地点核心方法
async searchLocation() {
if (!this.searchKeyword.trim()) {
this.searchResults = []
return
}
await this.loadAMap()
// 使用 PlaceSearch 搜索具体地点
this.placeSearch.search(this.searchKeyword, (status, result) => {
if (status === 'complete' && result.info === 'OK') {
// 提取返回的 POI 列表
const pois = result.poiList.pois
this.searchResults = pois.map(poi => ({
id: poi.id,
name: poi.name,
address: poi.address && typeof poi.address === 'string' ? poi.address : poi.adname,
location: poi.location, // 包含 lng/lat 的对象
city: poi.cityname || poi.pname // 城市名
}))
} else {
this.searchResults = []
this.$message.info('未找到相关地点,请尝试更换关键词')
}
})
},
// 选中左侧搜索结果列表的具体地点
selectLocation(poi) {
if (!poi.location) {
this.$message.warning('该地点缺少坐标信息')
return
}
const lng = poi.location.lng
const lat = poi.location.lat
// 设置地图中心点并放大(层级15看街道)
this.map.setZoomAndCenter(15, [lng, lat])
this.addMarker(lng, lat)
// 更新选择数据
this.selectedCity = poi.city
this.selectedPoi = poi.name
this.$message.success(`已定位到:${poi.name}`)
},
// 选中热门城市
async selectHotCity(city) {
await this.loadAMap()
this.selectedCity = city
this.selectedPoi = city
// 获取城市中心点坐标
this.geocoder.getLocation(city, (status, result) => {
if (status === 'complete' && result.geocodes.length) {
const loc = result.geocodes[0].location
this.map.setZoomAndCenter(11, [loc.lng, loc.lat])
this.addMarker(loc.lng, loc.lat)
}
})
},
confirmCity() {
if (this.selectedCity) {
this.cityName = this.selectedCity
// 组件 v-model 抛出城市名
this.$emit('input', this.selectedCity)
// 如果你需要具体的地点名,可以额外抛出一个事件
this.$emit('poi-change', { city: this.selectedCity, poi: this.selectedPoi })
this.closeDialog()
}
},
closeDialog() {
this.dialogVisible = false
}
}
}
</script>
<style lang="scss" scoped>
.amap-city-select { width: 100%; }
.map-selector { display: flex; gap: 20px; min-height: 450px;
.city-sidebar { width: 300px; flex-shrink: 0; display: flex; flex-direction: column;
.search-box { margin-bottom: 16px; }
.section-title { font-size: 14px; font-weight: bold; color: #303133; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #ebeef5; }
.hot-cities .city-list { display: flex; flex-wrap: wrap; gap: 10px;
.city-item { padding: 6px 14px; background: #f4f4f5; color: #606266; border-radius: 4px; font-size: 13px; cursor: pointer; transition: all 0.2s;
&:hover { background: #409eff; color: white; }
}
}
/* 搜索结果列表样式升级 */
.search-results {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.result-list {
flex: 1; overflow-y: auto; padding-right: 5px;
/* 自定义滚动条 */
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-thumb { background: #dcdfe6; border-radius: 4px; }
.result-item {
padding: 12px 10px; cursor: pointer; display: flex; align-items: flex-start; gap: 10px; border-bottom: 1px solid #ebeef5; transition: background 0.2s;
&:hover { background: #f0f7ff; }
i { color: #409eff; margin-top: 3px; font-size: 16px; }
.result-info {
flex: 1; overflow: hidden;
.poi-name { font-size: 14px; color: #303133; font-weight: 500; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.poi-address { font-size: 12px; color: #909399; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
}
}
}
}
.map-container { flex: 1; display: flex; flex-direction: column;
.amap-wrapper { flex: 1; width: 100%; border: 1px solid #dcdfe6; border-radius: 6px; overflow: hidden; }
.map-tip { margin-top: 10px; font-size: 12px; color: #909399; text-align: center; i { margin-right: 4px; color: #e6a23c; } }
}
}
.dialog-footer { display: flex; justify-content: space-between; align-items: center;
.selected-info { font-size: 14px; color: #606266; .selected-city { color: #409eff; font-weight: bold; font-size: 15px; } }
}
</style>

View File

@@ -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
})

View 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
}

View File

@@ -0,0 +1,98 @@
import { getCityByLocation } from '@/api/oa/amap'
const GEO_OPTIONS = {
enableHighAccuracy: false,
timeout: 20000,
maximumAge: 120000
}
export const EMPTY_GEOCODE = 'EMPTY_GEOCODE'
/**
* 将 AmapCityNameVo 格式化为展示用工作地点字符串
*/
export function formatWorkPlaceFromAmap (vo) {
if (!vo) {
return ''
}
const province = (vo.province || '').trim()
const cityName = (vo.cityName || '').trim()
const district = (vo.district || '').trim()
const parts = []
if (province) {
parts.push(province)
}
if (cityName && cityName !== province) {
parts.push(cityName)
}
if (district) {
parts.push(district)
}
const joined = parts.join(' ').trim()
return joined || cityName || province || ''
}
function getCurrentPositionAsync () {
return new Promise((resolve, reject) => {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
reject(new Error('BROWSER_UNSUPPORTED'))
return
}
navigator.geolocation.getCurrentPosition(
position => resolve(position),
err => reject(err),
GEO_OPTIONS
)
})
}
/**
* 浏览器定位 + 后端逆地理 → 工作地点字符串
* @returns {Promise<string>}
*/
export async function resolveWorkPlaceFromBrowser () {
const position = await getCurrentPositionAsync()
const { longitude, latitude } = position.coords
const res = await getCityByLocation(longitude, latitude)
const vo = res && res.data
const text = formatWorkPlaceFromAmap(vo)
if (!text) {
const err = new Error(EMPTY_GEOCODE)
console.warn('[workPlace] 逆地理结果为空', { longitude, latitude, vo })
throw err
}
return text
}
/**
* 将定位/逆地理错误转换为用户可读文案
*/
export function geolocationUserMessage (err) {
if (!err) {
return '获取工作地点失败'
}
if (err.message === 'BROWSER_UNSUPPORTED') {
return '当前浏览器不支持定位,请更换浏览器或使用 HTTPS 访问后重试'
}
if (err.message === EMPTY_GEOCODE) {
return '无法根据当前位置解析城市请稍后重试若持续失败请联系管理员检查高德地图配置fad.amap.key'
}
const code = err.code
if (code === 1) {
return '您已拒绝定位权限,请在浏览器设置中允许本站点定位后点击「重新获取定位」'
}
if (code === 2) {
return '暂时无法获取位置信息,请到信号较好处点击「重新获取定位」重试'
}
if (code === 3) {
return '定位请求超时,请检查网络后点击「重新获取定位」重试'
}
const msg = err.message || ''
if (msg.includes('timeout') || msg.includes('超时')) {
return '接口请求超时,请稍后点击「重新获取定位」重试'
}
if (msg.includes('Network Error') || msg.includes('网络')) {
return '网络异常,请检查连接后点击「重新获取定位」重试'
}
return '获取工作地点失败,请点击「重新获取定位」重试'
}

View File

@@ -4,14 +4,13 @@
<div slot="header" class="card-header">
<span>{{ bizTitle }}</span>
<div class="actions">
<el-button size="mini" icon="el-icon-arrow-left" @click="$router.back()">返回</el-button>
<el-button v-if="!preview" size="mini" icon="el-icon-arrow-left" @click="$router.back()">返回</el-button>
<el-button size="mini" icon="el-icon-refresh" @click="loadDetail">刷新</el-button>
<!-- 审批操作按钮 -->
<el-button v-if="canApprove" type="success" size="mini" :loading="actionLoading" @click="handleApprove">
<el-button v-if="!preview && canApprove" type="success" size="mini" :loading="actionLoading" @click="handleApprove">
通过
</el-button>
<el-button v-if="canApprove" type="danger" size="mini" :loading="actionLoading" @click="handleReject">
<el-button v-if="!preview && canApprove" type="danger" size="mini" :loading="actionLoading" @click="handleReject">
驳回
</el-button>
</div>
@@ -54,20 +53,21 @@
<ProjectInfo :info="detail" />
</el-card>
<div class="block-title">审批操作</div>
<el-card class="inner-card" shadow="never">
<div class="hint-text">系统将自动识别你在该单据上的当前待办任务若你不是当前办理人将不会显示办理按钮</div>
<div v-if="currentTask" class="btn-row">
<el-input v-model="actionRemark" type="textarea" :rows="3" placeholder="填写审批意见(可选)" />
<div class="btn-row mt10">
<el-button type="success" v-if="canApprove" :loading="actionSubmitting"
@click="submitTaskAction('approve')">通过</el-button>
<el-button type="danger" v-if="canApprove" :loading="actionSubmitting"
@click="submitTaskAction('reject')">驳回</el-button>
<div v-if="!preview">
<div class="block-title">审批操作</div>
<el-card class="inner-card" shadow="never">
<div v-if="currentTask" class="btn-row">
<el-input v-model="actionRemark" type="textarea" :rows="3" placeholder="填写审批意见(可选)" />
<div class="btn-row mt10">
<el-button type="success" v-if="canApprove" :loading="actionSubmitting"
@click="submitTaskAction('approve')">通过</el-button>
<el-button type="danger" v-if="canApprove" :loading="actionSubmitting"
@click="submitTaskAction('reject')">驳回</el-button>
</div>
</div>
</div>
<div v-else class="empty">当前无待办任务可能已处理完成或你不是当前审批人</div>
</el-card>
<div v-else class="empty">当前无待办任务可能已处理完成或你不是当前审批人</div>
</el-card>
</div>
</el-card>
<el-card class="report-card" shadow="never">
@@ -75,7 +75,7 @@
<span>操作汇报</span>
</div>
<div class="comment-form">
<div v-if="!preview" class="comment-form">
<editor v-model="commentForm.commentContent" placeholder="填写操作汇报(可选)" />
<file-upload v-model="commentForm.attachments" />
<div class="form-actions">
@@ -89,7 +89,7 @@
<div class="comment-meta">
<span class="comment-operator">{{ item.createByName }}</span>
<span class="comment-time">{{ item.createTime }}</span>
<el-button v-if="isSelf(item)" type="danger" size="mini" @click="handleDeleteComment(item.commentId)"
<el-button v-if="!preview && isSelf(item)" type="danger" size="mini" @click="handleDeleteComment(item.commentId)"
:loading="buttonLoading">删除</el-button>
</div>
</div>
@@ -132,7 +132,8 @@ export default {
name: 'BizDetailContainer',
props: {
bizId: { type: String, required: true },
bizType: { type: String, required: true }
bizType: { type: String, required: true },
preview: { type: Boolean, default: false }
},
components: {
FilePreview,
@@ -414,7 +415,7 @@ export default {
async exportComment () {
try {
this.buttonLoading = true
const res = await addFlowComment({
await addFlowComment({
instId: this.flowInstance.instId,
commentContent: this.commentForm.commentContent,
attachments: this.commentForm.attachments

View File

@@ -1,81 +1,68 @@
<template>
<div class="hrm-page">
<div class="flow-task-layout">
<!-- 任务列表 -->
<el-card class="metal-panel left" shadow="hover">
<el-card class="metal-panel left" shadow="never">
<div slot="header" class="panel-header">
<div class="header-title">
<span>审批历史</span>
<span class="sub">面向办理人只看已完成不可操作</span>
</div>
<div class="actions-inline">
<el-button size="mini" icon="el-icon-refresh" @click="fetchList">刷新</el-button>
<el-button size="mini" plain icon="el-icon-refresh" @click="fetchList">刷新</el-button>
</div>
</div>
<el-table :data="list" v-loading="loading" height="680" stripe highlight-current-row @row-click="openDetail">
<el-table-column label="状态" min-width="100">
<el-table
:data="list"
v-loading="loading"
height="680"
stripe
highlight-current-row
@row-click="openDetail"
>
<el-table-column label="业务" min-width="140">
<template slot-scope="scope">
<div class="biz-cell">
<span class="biz-type">{{ bizTypeText(scope.row.bizType) }}</span>
<span class="biz-title">{{ scope.row.title || scope.row.remark }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="审批状态" min-width="100">
<template slot-scope="scope">
<el-tag :type="statusType(scope.row.status)" size="mini">{{ statusText(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="业务" min-width="120">
<el-table-column label="审批时间" min-width="160">
<template slot-scope="scope">
<el-tag size="mini" type="info">{{ bizTypeText(scope.row.bizType) }}</el-tag>
<span class="muted" v-if="scope.row.bizId"> #{{ scope.row.bizId }}</span>
<span>{{ formatDate(scope.row.updateTime || scope.row.createTime) || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" show-overflow-tooltip />
<el-table-column label="操作" width="100" fixed="right">
<template slot-scope="scope">
<el-button link type="primary" @click="openDetail(scope.row)">查看详情</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 详情区 -->
<el-card class="metal-panel right" shadow="hover">
<div slot="header" class="panel-header">
<span>任务详情</span>
<div class="actions-inline">
<el-button size="mini" icon="el-icon-document-copy" :disabled="!detailTask"
@click="copyTaskInfo">复制关键信息</el-button>
</div>
</div>
<el-card class="metal-panel right" shadow="never">
<div slot="header" class="panel-header"></div>
<div v-if="!detailTask" class="placeholder">
<div class="p-title">请在左侧选择一条任务</div>
<div class="p-sub">将展示业务信息表单字段流转历史</div>
<div class="p-title">请在左侧选择一条审批记录</div>
<div class="p-sub">右侧将展示表单内容和流转历史</div>
</div>
<div v-else class="detail-wrap">
<div class="detail-summary">
<div class="ds-left">
<div class="ds-title">{{ bizTypeText(detailTask.bizType) }} · 任务 #{{ detailTask.taskId }}</div>
<div class="ds-title">{{ bizTypeText(detailTask.bizType) }} · {{ detailTask.title || '审批详情' }}</div>
<div class="ds-sub">
<el-tag size="mini" :type="statusType(detailTask.status)">{{ statusText(detailTask.status) }}</el-tag>
<span class="muted">实例 {{ detailTask.instId }} · 节点 {{ detailTask.nodeId }}</span>
</div>
</div>
<div class="ds-right">
<div class="ds-item">
<div class="k">办理人</div>
<div class="v">{{ formatUser(detailTask.assigneeUserId, 'userId') }}</div>
</div>
<div class="ds-item">
<div class="k">到期</div>
<div class="v">{{ formatDate(detailTask.expireTime) || '-' }}</div>
<span class="muted">审批时间 {{ formatDate(detailTask.updateTime || detailTask.createTime) || '-' }}</span>
</div>
</div>
</div>
<el-tabs value="form">
<el-tab-pane label="表单数据" name="form">
<LeaveDetail v-if="detailTask && detailTask.bizType === 'leave'" :biz-id="detailTask.bizId" :embedded="true" />
<TravelDetail v-else-if="detailTask && detailTask.bizType === 'travel'" :biz-id="detailTask.bizId" :embedded="true" />
<SealDetail v-else-if="detailTask && detailTask.bizType === 'seal'" :biz-id="detailTask.bizId" :embedded="true" />
<ReimburseDetail v-else-if="detailTask && detailTask.bizType === 'reimburse'" :biz-id="detailTask.bizId" :embedded="true" />
<LeaveDetail v-if="detailTask && detailTask.bizType === 'leave'" :biz-id="detailTask.bizId" :embedded="true" :preview="true" />
<TravelDetail v-else-if="detailTask && detailTask.bizType === 'travel'" :biz-id="detailTask.bizId" :embedded="true" :preview="true" />
<SealDetail v-else-if="detailTask && detailTask.bizType === 'seal'" :biz-id="detailTask.bizId" :embedded="true" :preview="true" />
<ReimburseDetail v-else-if="detailTask && detailTask.bizType === 'reimburse'" :biz-id="detailTask.bizId" :embedded="true" :preview="true" />
<div v-else>
<el-table :data="formData" v-loading="formLoading" size="mini" height="260">
@@ -88,7 +75,12 @@
<el-tab-pane label="流转历史" name="history">
<el-timeline v-loading="actionLoading" v-if="actionList.length">
<el-timeline-item v-for="(a, idx) in actionList" :key="idx" :timestamp="formatDate(a.createTime)" :type="actionTagType(a.action)">
<el-timeline-item
v-for="(a, idx) in actionList"
:key="idx"
:timestamp="formatDate(a.createTime)"
:type="actionTagType(a.action)"
>
<div class="timeline-row">
<div class="t-main">
<span class="t-action">{{ actionText(a.action) }}</span>
@@ -109,10 +101,7 @@
<script>
import { listHistoryFlowTask, listFlowAction, listFlowFormData } from '@/api/hrm/flow'
import { listByIds } from '@/api/system/oss'
import { listUser } from '@/api/system/user'
import LeaveDetail from '@/views/hrm/requests/leaveDetail.vue'
import ReimburseDetail from '@/views/hrm/requests/reimburseDetail.vue'
import SealDetail from '@/views/hrm/requests/sealDetail.vue'
@@ -126,7 +115,7 @@ export default {
return {
query: { status: undefined, pageNum: 1, pageSize: 50 },
list: [],
total: 0,
total: 0,
loading: false,
detailTask: null,
actionList: [],
@@ -148,56 +137,34 @@ export default {
actionText(action) { const map = { submit: '提交', approve: '通过', reject: '驳回', withdraw: '撤回', cancel: '撤销', stamp: '盖章', transfer: '转发' }; return map[action] || action || '-' },
actionTagType(action) { const map = { submit: 'primary', approve: 'success', reject: 'danger', withdraw: 'info', cancel: 'info', stamp: 'primary', transfer: 'warning' }; return map[action] || 'info' },
async loadAllUsers() { try { const res = await listUser({ pageNum: 1, pageSize: 1000 }); this.allUsers = res.rows || [] } catch (e) { this.allUsers = [] } },
formatUser(userId, fieldName) { if (!userId) return '-'; const user = this.allUsers.find(u => u[fieldName] === userId); return user ? `${user.nickName || user.userName}` : `ID:${userId}` },
formatUser(userId, fieldName) { if (!userId) return '-'; const user = this.allUsers.find(u => u[fieldName] === userId); return user ? `${user.nickName || user.userName}` : '-' },
fetchList() {
this.loading = true
listHistoryFlowTask(this.query).then(res => {
this.list = res.rows || []
this.total = res.total || 0
if (!this.detailTask && this.list.length) {
this.openDetail(this.list[0])
}
}).finally(() => {
this.loading = false
})
if (!this.detailTask && this.list.length) this.openDetail(this.list[0])
}).finally(() => { this.loading = false })
},
async openDetail(row) {
openDetail(row) {
if (!row) return
this.detailTask = row
this.loadActions(row)
this.loadFormData(row)
},
loadActions(row) {
if (!row || !row.instId) return;
this.actionLoading = true;
listFlowAction({ instId: row.instId }).then(res => {
this.actionList = res.rows || []
}).finally(() => {
this.actionLoading = false
})
loadActions(row) {
if (!row || !row.instId) return
this.actionLoading = true
listFlowAction({ instId: row.instId }).then(res => { this.actionList = res.rows || [] }).finally(() => { this.actionLoading = false })
},
loadFormData(row) {
if (!row || !row.instId) return;
this.formLoading = true;
listFlowFormData({ instId: row.instId }).then(res => {
this.formData = res.rows || []
}).finally(() => {
this.formLoading = false
})
},
copyTaskInfo() {
if (!this.detailTask) return;
const t = this.detailTask;
const text = `任务ID:${t.taskId}\n实例:${t.instId}\n业务:${t.bizType}${t.bizId ? `#${t.bizId}` : ''}\n节点:${t.nodeId}\n状态:${t.status}`;
navigator.clipboard.writeText(text).then(() => this.$message.success('已复制'))
loadFormData(row) {
if (!row || !row.instId) return
this.formLoading = true
listFlowFormData({ instId: row.instId }).then(res => { this.formData = res.rows || [] }).finally(() => { this.formLoading = false })
}
}
}
@@ -205,32 +172,38 @@ export default {
<style lang="scss" scoped>
.hrm-page {
padding: 16px 20px 32px;
background: #f8f9fb;
padding: 0;
background: transparent;
}
.flow-task-layout {
display: grid;
grid-template-columns: 520px 1fr;
gap: 14px;
gap: 12px;
align-items: start;
}
.metal-panel { border: 1px solid #d7d9df; border-radius: 12px; background: #fff; }
.panel-header { display: flex; justify-content: space-between; align-items: center; font-weight: 800; color: #2b2f36; }
.metal-panel {
border: 1px solid rgba(215, 217, 223, 0.55);
border-radius: 8px;
background: #fff;
box-shadow: none;
}
.panel-header { display: flex; justify-content: space-between; align-items: center; font-weight: 700; color: #2b2f36; padding: 0; min-height: 0; }
.header-title { display: flex; flex-direction: column; }
.header-title .sub { font-size: 12px; color: #8a8f99; margin-top: 2px; font-weight: 500; }
.actions-inline { display: flex; gap: 8px; align-items: center; }
.biz-cell { display: flex; flex-direction: column; gap: 2px; }
.biz-type { font-size: 12px; color: #8a8f99; }
.biz-title { font-weight: 500; color: #2b2f36; line-height: 1.2; }
.muted { color: #8a8f99; font-size: 12px; }
.placeholder { padding: 18px 14px; border: 1px dashed #e6e8ed; border-radius: 12px; background: #fafbfc; }
.p-title { font-weight: 900; color: #2b2f36; }
.placeholder { padding: 18px 14px; border: 1px dashed #eef0f3; border-radius: 8px; background: #fff; }
.p-title { font-weight: 700; color: #2b2f36; }
.p-sub { margin-top: 6px; color: #8a8f99; font-size: 13px; }
.detail-wrap { padding-right: 4px; }
.detail-summary { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid #e6e8ed; border-radius: 12px; background: #fff; }
.ds-title { font-weight: 900; color: #2b2f36; }
.detail-wrap { padding-right: 2px; }
.detail-summary { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid rgba(230, 232, 237, 0.65); border-radius: 8px; background: #fff; }
.ds-title { font-weight: 700; color: #2b2f36; }
.ds-sub { margin-top: 6px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.ds-right { display: flex; gap: 16px; }
.ds-item .k { font-size: 12px; color: #8a8f99; }
.ds-item .v { margin-top: 2px; font-weight: 800; color: #2b2f36; }
/* removed extra right-side summary metadata */
.empty { padding: 12px; color: #8a8f99; }
.timeline-row .t-main { font-weight: 600; color: #2b2f36; }
.timeline-row .t-remark { margin-top: 4px; color: #606266; font-size: 13px; }
</style>
</style>

View File

@@ -1,5 +1,5 @@
<template>
<BizDetailContainer :bizId="currentBizId" bizType="appropriation">
<BizDetailContainer :bizId="currentBizId" bizType="appropriation" :preview="preview">
<template slot-scope="{ detail }">
<div>
<!-- 拨款金额信息 -->
@@ -59,7 +59,8 @@ export default {
name: 'AppropriationDetail',
props: {
bizId: { type: [String, Number], default: null },
embedded: { type: Boolean, default: false }
embedded: { type: Boolean, default: false },
preview: { type: Boolean, default: false }
},
components: {
FilePreview,
@@ -114,8 +115,7 @@ export default {
return this.detail.status === 'pending' && (this.currentTask?.assigneeUserName === this.$store.getters.name || this.currentTask?.assigneeUserId === this.$store.getters.id)
},
canWithdraw () {
return false;
return this.detail.status === 'pending' && this.detail.createBy === this.$store.getters.name
return false
}
},
created () {

View File

@@ -1,5 +1,5 @@
<template>
<BizDetailContainer :bizId="currentBizId" bizType="leave">
<BizDetailContainer :bizId="currentBizId" bizType="leave" :preview="preview">
<template slot-scope="{ detail }">
<div>
<!-- 请假日期信息 -->
@@ -50,7 +50,8 @@ export default {
name: 'LeaveDetail',
props: {
bizId: { type: [String, Number], default: null },
embedded: { type: Boolean, default: false }
embedded: { type: Boolean, default: false },
preview: { type: Boolean, default: false }
},
components: {
FilePreview,

View File

@@ -1,5 +1,5 @@
<template>
<BizDetailContainer :bizId="currentBizId" bizType="reimburse">
<BizDetailContainer :bizId="currentBizId" bizType="reimburse" :preview="preview">
<template slot-scope="{ detail }">
<div>
<!-- 报销金额信息 -->
@@ -47,7 +47,8 @@ export default {
name: 'ReimburseDetail',
props: {
bizId: { type: [String, Number], default: null },
embedded: { type: Boolean, default: false }
embedded: { type: Boolean, default: false },
preview: { type: Boolean, default: false }
},
components: {
BizDetailContainer,

View File

@@ -65,8 +65,8 @@
</el-row>
<el-form-item label="目的地" prop="destination">
<el-input v-model="form.destination" placeholder="城市/地址/项目现场" />
<div class="hint-text">填写具体目的地便于审批人判断出差必要性</div>
<amap-city-select v-model="form.destination" placeholder="请选择出差城市" />
<div class="hint-text">通过地图或搜索选择具体城市便于审批人判断出差必要性</div>
</el-form-item>
<div class="block-title">出差说明</div>
@@ -223,13 +223,15 @@ import { ccFlowTask, listFlowNode, listFlowTemplate } from '@/api/hrm/flow'
import FileUpload from '@/components/FileUpload'
import UserMultiSelect from '@/components/UserSelect/multi.vue'
import UserSelect from '@/components/UserSelect/single.vue'
import AmapCitySelect from '@/components/AmapCitySelect/index.vue'
export default {
name: 'HrmTravelRequest',
components: {
UserSelect,
FileUpload,
UserMultiSelect
UserMultiSelect,
AmapCitySelect
},
data () {
return {

View File

@@ -1,7 +1,31 @@
<template>
<BizDetailContainer :bizId="currentBizId" bizType="travel">
<BizDetailContainer :bizId="currentBizId" bizType="travel" :preview="preview">
<template slot-scope="{ detail }">
<div>
<!-- ===== 新增提前结束按钮区域 ===== -->
<div class="action-buttons" v-if="showEarlyEndButton(detail)">
<el-button
type="warning"
size="small"
icon="el-icon-finished"
:loading="earlyEndLoading"
@click="handleEarlyEnd"
>
提前结束
</el-button>
<span class="hint-text">提前结束将把当前时间记录为实际结束时间</span>
</div>
<!-- ===== 新增显示已提前结束的信息 ===== -->
<div v-if="detail.actualEndTime" class="early-end-info">
<el-alert
type="info"
:closable="false"
show-icon>
<template slot="default">
该出差已于 {{ formatDate(detail.actualEndTime) }} 提前结束
</template>
</el-alert>
</div>
<!-- 出差时间与行程 -->
<div class="block-title">出差时间与行程</div>
<el-card class="inner-card" shadow="never">
@@ -41,18 +65,24 @@
<script>
import FilePreview from "@/components/FilePreview/index.vue";
import BizDetailContainer from '@/views/hrm/components/BizDetailContainer/index.vue';
import { earlyEndTravel } from '@/api/hrm/travel'
export default {
name: 'TravelDetail',
props: {
bizId: { type: [String, Number], default: '' },
embedded: { type: Boolean, default: false }
embedded: { type: Boolean, default: false },
preview: { type: Boolean, default: false }
},
components: {
BizDetailContainer,
FilePreview
},
name: 'HrmTravelDetail',
data() {
return {
earlyEndLoading: false
}
},
computed: {
currentBizId () {
return this.bizId || this.$route?.params?.bizId || this.$route?.query?.bizId || this.$route?.params?.id
@@ -65,7 +95,41 @@ export default {
const p = n => (n < 10 ? `0${n}` : n)
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
},
// 新增:处理提前结束
showEarlyEndButton(detail) {
if (!detail) return false
// 已经提前结束的不显示
if (detail.actualEndTime) return false
// 只有已通过或进行中状态才显示
const status = detail.status
return status === 'approved' || status === 'in_progress'
},
handleEarlyEnd() {
this.$confirm('确认提前结束本次出差吗?结束后的实际时间将记录为当前时间。', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
this.earlyEndLoading = true
try {
const bizId = this.currentBizId
await earlyEndTravel(bizId)
this.$message.success('提前结束成功')
// 刷新页面
setTimeout(() => {
location.reload()
}, 1000)
} catch (error) {
this.$message.error(error.message || '提前结束失败')
} finally {
this.earlyEndLoading = false
}
}).catch(() => {})
},
}
}
</script>
@@ -374,4 +438,17 @@ export default {
display: none;
}
}
.action-buttons {
margin-bottom: 16px;
padding: 12px;
background: #fdf6ec;
border-radius: 8px;
display: flex;
align-items: center;
gap: 12px;
}
.early-end-info {
margin-bottom: 16px;
}
</style>

View 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_stepuse_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>

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
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;

View File

@@ -87,7 +87,19 @@
</el-alert>
<el-form ref="form" :model="form" :rules="rules" label-width="96px">
<el-form-item label="工作地点" prop="workPlace">
<el-input v-model="form.workPlace" placeholder="请输入工作地点" />
<el-input
v-model="form.workPlace"
readonly
:placeholder="workPlaceLoading ? '正在获取定位…' : '请点击「重新获取定位」获取工作地点(不可手动输入)'"
>
<el-button
slot="append"
icon="el-icon-location-outline"
:loading="workPlaceLoading"
@click="refreshWorkPlace"
>重新获取定位</el-button>
</el-input>
<div v-if="workPlaceLocateError" class="work-place-error">{{ workPlaceLocateError }}</div>
</el-form-item>
<el-form-item label="是否出差" prop="isTrip">
<el-radio-group v-model="form.isTrip">
@@ -148,12 +160,19 @@ import { listDept } from "@/api/system/dept";
import { listUser } from "@/api/system/user";
import ProjectSelect from "@/components/fad-service/ProjectSelect";
import ProjectReportDetail from "@/views/oa/project/report/components/ProjectReportDetail.vue";
import {
EMPTY_GEOCODE,
geolocationUserMessage,
resolveWorkPlaceFromBrowser
} from "@/utils/geolocationWorkPlace";
export default {
name: "ProjectReport",
components: { ProjectReportDetail, ProjectSelect },
data () {
return {
workPlaceLoading: false,
workPlaceLocateError: "",
// 按钮loading
buttonLoading: false,
detailVisible: false,
@@ -230,6 +249,50 @@ export default {
this.getUserList();
},
methods: {
/**
* @param {{ silent?: boolean, force?: boolean }} options
*/
async syncWorkPlaceFromGeolocation (options = {}) {
const silent = !!options.silent;
const force = !!options.force;
if (!force && this.form && this.form.reportId) {
return;
}
this.workPlaceLoading = true;
this.workPlaceLocateError = "";
try {
const text = await resolveWorkPlaceFromBrowser();
this.$set(this.form, "workPlace", text);
if (!silent) {
this.$modal.msgSuccess("已根据定位更新工作地点");
}
} catch (e) {
console.error("[projectReport] 工作地点定位失败", e);
const msg = geolocationUserMessage(e);
this.workPlaceLocateError = msg;
this.$set(this.form, "workPlace", undefined);
const isBrowserGeoError =
e &&
(e.code === 1 ||
e.code === 2 ||
e.code === 3 ||
e.message === "BROWSER_UNSUPPORTED" ||
e.message === EMPTY_GEOCODE);
if (isBrowserGeoError) {
this.$modal.msgWarning(msg);
}
} finally {
this.workPlaceLoading = false;
this.$nextTick(() => {
if (this.$refs.form) {
this.$refs.form.validateField("workPlace");
}
});
}
},
refreshWorkPlace () {
this.syncWorkPlaceFromGeolocation({ silent: false, force: true });
},
/** 格式化日期为 yyyy-MM-dd 格式 */
formatDate (date) {
const year = date.getFullYear();
@@ -258,26 +321,31 @@ export default {
this.reset();
this.open = true;
this.title = "补录项目报工";
this.$nextTick(() => {
this.syncWorkPlaceFromGeolocation({ silent: true, force: false });
});
},
/** 检查今日报工 */
/** 检查今日报工(须返回 Promise供新增时 .finally 打开弹窗) */
checkTodayReport () {
getTodayProjectReport().then(response => {
if (response.data && response.data.reportId) {
this.hasTodayReport = true;
this.todayReportId = response.data.reportId;
this.form = {
...this.form,
...response.data
};
} else {
return getTodayProjectReport()
.then(response => {
if (response.data && response.data.reportId) {
this.hasTodayReport = true;
this.todayReportId = response.data.reportId;
this.form = {
...this.form,
...response.data
};
} else {
this.hasTodayReport = false;
this.todayReportId = null;
}
})
.catch(() => {
this.hasTodayReport = false;
this.todayReportId = null;
}
}).catch(() => {
this.hasTodayReport = false;
this.todayReportId = null;
});
});
},
getDeptList () {
@@ -313,6 +381,8 @@ export default {
},
// 表单重置
reset () {
this.workPlaceLoading = false;
this.workPlaceLocateError = "";
this.form = {
reportId: undefined,
handler: undefined,
@@ -359,9 +429,15 @@ export default {
handleAdd () {
this.reset();
this.suppVisible = false;
this.checkTodayReport();
this.open = true;
this.title = "添加项目报工";
this.checkTodayReport().finally(() => {
this.open = true;
this.$nextTick(() => {
if (!this.form.reportId) {
this.syncWorkPlaceFromGeolocation({ silent: true, force: false });
}
});
});
},
/** 修改按钮操作 */
handleUpdate (row) {
@@ -473,4 +549,11 @@ export default {
display: block;
margin-top: 2px;
}
.work-place-error {
color: #f56c6c;
font-size: 12px;
line-height: 1.5;
margin-top: 4px;
}
</style>