添加了项目前景 绩效 审批配置做了一部分有点晕 我换换脑子继续这个 还有说明菜单
This commit is contained in:
@@ -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.common.helper.LoginHelper;
|
||||
import com.ruoyi.oa.service.IOaPerformanceService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/oa/performance")
|
||||
public class OaPerformanceController extends BaseController {
|
||||
|
||||
private final IOaPerformanceService service;
|
||||
|
||||
/** 我的绩效(默认本月) */
|
||||
@GetMapping("/mine")
|
||||
public R<Map<String, Object>> mine(@RequestParam(required = false) String period) {
|
||||
return R.ok(service.myScore(LoginHelper.getUserId(), period));
|
||||
}
|
||||
|
||||
/** 全员排名 */
|
||||
@GetMapping("/rank")
|
||||
public R<List<Map<String, Object>>> rank(@RequestParam(required = false) String period,
|
||||
@RequestParam(required = false) String nameLike) {
|
||||
return R.ok(service.rank(period, nameLike));
|
||||
}
|
||||
|
||||
/** 看任意用户的绩效(管理员) */
|
||||
@GetMapping("/of/{userId}")
|
||||
public R<Map<String, Object>> of(@PathVariable Long userId,
|
||||
@RequestParam(required = false) String period) {
|
||||
return R.ok(service.myScore(userId, period));
|
||||
}
|
||||
|
||||
/** 高总打主观分(0-40),权限由前端按 role/userId 控制 */
|
||||
@PostMapping("/subjective")
|
||||
public R<Void> subjective(@RequestParam Long userId,
|
||||
@RequestParam(required = false) String userName,
|
||||
@RequestParam(required = false) String period,
|
||||
@RequestParam Integer score) {
|
||||
if (score == null) return R.fail("score 必填");
|
||||
service.setSubjectiveScore(userId, userName, period, score);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/** 手动加分/扣分(高总或 admin)。points 正=扣,负=加 */
|
||||
@PostMapping("/manual")
|
||||
public R<Void> manual(@RequestParam Long userId,
|
||||
@RequestParam(required = false) String userName,
|
||||
@RequestParam Integer points,
|
||||
@RequestParam(required = false) String reason) {
|
||||
if (points == null || points == 0) return R.fail("points 必填且 ≠ 0");
|
||||
// dedupKey 用时间戳+用户避免冲突
|
||||
String key = "manual_" + userId + "_" + System.currentTimeMillis();
|
||||
service.addDeduction(userId, userName, "manual", null, null,
|
||||
points, reason == null ? "手动调整" : reason, key);
|
||||
return R.ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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.OaPostponeBo;
|
||||
import com.ruoyi.oa.service.IOaPostponeService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 顺延强制管理
|
||||
* 进系统就拉一次 /mine,有数据前端就强制弹窗
|
||||
*/
|
||||
@Validated
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/oa/postpone")
|
||||
public class OaPostponeController extends BaseController {
|
||||
|
||||
private final IOaPostponeService service;
|
||||
|
||||
/** 我名下所有未处理的超期 */
|
||||
@GetMapping("/mine")
|
||||
public R<List<Map<String, Object>>> mine() {
|
||||
return R.ok(service.listMine());
|
||||
}
|
||||
|
||||
@PostMapping("/complete")
|
||||
public R<Void> complete(@NotBlank @RequestParam String businessType,
|
||||
@NotNull @RequestParam Long businessId) {
|
||||
service.markComplete(businessType, businessId);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/cancel")
|
||||
public R<Void> cancel(@NotBlank @RequestParam String businessType,
|
||||
@NotNull @RequestParam Long businessId,
|
||||
@NotBlank @RequestParam String reason) {
|
||||
service.markCancel(businessType, businessId, reason);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/postpone")
|
||||
public R<Map<String, Object>> postpone(@Validated @RequestBody OaPostponeBo bo) {
|
||||
return R.ok(service.postpone(bo));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ruoyi.oa.controller;
|
||||
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.R;
|
||||
import com.ruoyi.oa.service.IOaProjectOverviewService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 项目全景页 API。
|
||||
* 一次拉完项目的所有维度,避免前端 N 次轮询。
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/oa/project/overview")
|
||||
public class OaProjectOverviewController extends BaseController {
|
||||
|
||||
private final IOaProjectOverviewService service;
|
||||
|
||||
@GetMapping("/{projectId}")
|
||||
public R<Map<String, Object>> overview(@PathVariable Long projectId) {
|
||||
return R.ok(service.overview(projectId));
|
||||
}
|
||||
|
||||
/** 集团驾驶舱:全公司项目一览 */
|
||||
@GetMapping("/dashboard")
|
||||
public R<Map<String, Object>> dashboard(
|
||||
@org.springframework.web.bind.annotation.RequestParam(required = false) String status,
|
||||
@org.springframework.web.bind.annotation.RequestParam(required = false) String nameLike,
|
||||
@org.springframework.web.bind.annotation.RequestParam(required = false, defaultValue = "50") Integer limit) {
|
||||
return R.ok(service.dashboard(status, nameLike, limit));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ruoyi.oa.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
@TableName("oa_performance_deduction")
|
||||
public class OaPerformanceDeduction implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id")
|
||||
private Long id;
|
||||
|
||||
private Long userId;
|
||||
private String userName;
|
||||
private String period; // YYYY-MM
|
||||
|
||||
/** postpone / overdue30 / manual */
|
||||
private String source;
|
||||
/** task / step / requirement */
|
||||
private String sourceType;
|
||||
private Long sourceId;
|
||||
|
||||
/** 正=扣 负=加 */
|
||||
private Integer points;
|
||||
private String reason;
|
||||
|
||||
/** 去重 key */
|
||||
private String dedupKey;
|
||||
|
||||
private Date createTime;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.ruoyi.oa.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
@TableName("oa_performance_score")
|
||||
public class OaPerformanceScore implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id")
|
||||
private Long id;
|
||||
|
||||
private Long userId;
|
||||
private String userName;
|
||||
private String period;
|
||||
|
||||
private Integer baseScore;
|
||||
private Integer deduction;
|
||||
private Integer bonus;
|
||||
/** 客观自动加分(完成任务/步骤、出差、全勤) */
|
||||
private Integer reward;
|
||||
private Integer totalScore;
|
||||
private String grade;
|
||||
/** 高总是否已打主观分:0未打(兜底用客观分等比放大100) 1已打 */
|
||||
private Integer subjectiveSet;
|
||||
|
||||
private Date createTime;
|
||||
private Date updateTime;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ruoyi.oa.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.ruoyi.common.core.domain.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 顺延记录 oa_postpone_record
|
||||
* 三类业务统一存:task / step / requirement
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("oa_postpone_record")
|
||||
public class OaPostponeRecord extends BaseEntity {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id")
|
||||
private Long id;
|
||||
|
||||
private String businessType;
|
||||
private Long businessId;
|
||||
private String businessTitle;
|
||||
|
||||
private Long ownerId;
|
||||
private String ownerName;
|
||||
|
||||
private Date originalDeadline;
|
||||
private Date newDeadline;
|
||||
private String reason;
|
||||
|
||||
/** 这是第几次顺延(含本次) */
|
||||
private Integer postponeSeq;
|
||||
/** 0直接生效 1需要审批 */
|
||||
private Integer needApproval;
|
||||
private Long approvalInstanceId;
|
||||
|
||||
/** 0待审批 1已生效 2被驳回 3已撤回 */
|
||||
private Integer status;
|
||||
|
||||
private Integer delFlag;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ruoyi.oa.domain.bo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
public class OaPostponeBo implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@NotBlank
|
||||
private String businessType;
|
||||
@NotNull
|
||||
private Long businessId;
|
||||
/** 提交时的标题(仅展示用) */
|
||||
private String businessTitle;
|
||||
@NotNull
|
||||
private Date newDeadline;
|
||||
@NotBlank
|
||||
private String reason;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ruoyi.oa.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ruoyi.oa.domain.OaPerformanceDeduction;
|
||||
|
||||
public interface OaPerformanceDeductionMapper extends BaseMapper<OaPerformanceDeduction> {
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.ruoyi.oa.mapper;
|
||||
|
||||
import com.ruoyi.common.core.mapper.BaseMapperPlus;
|
||||
import com.ruoyi.oa.domain.OaPerformanceDeduction;
|
||||
import com.ruoyi.oa.domain.OaPerformanceScore;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public interface OaPerformanceMapper {
|
||||
|
||||
// 不强行用 BaseMapperPlus —— 这俩表用法相对独立,单独写更清晰
|
||||
@Select("SELECT * FROM oa_performance_score WHERE user_id = #{uid} AND period = #{period} LIMIT 1")
|
||||
OaPerformanceScore findScore(@Param("uid") Long uid, @Param("period") String period);
|
||||
|
||||
@Select("SELECT * FROM oa_performance_deduction WHERE user_id = #{uid} AND period = #{period} ORDER BY id DESC")
|
||||
List<OaPerformanceDeduction> listDeductions(@Param("uid") Long uid, @Param("period") String period);
|
||||
|
||||
/**
|
||||
* 列全员:以 sys_user 为主表 LEFT JOIN 绩效;
|
||||
* 没记录的员工返回默认值(base=60, ded=0, bonus=40, subjective_set=0, total=100, grade='A+')
|
||||
*/
|
||||
@Select("<script>SELECT CAST(u.user_id AS CHAR) AS user_id, u.nick_name AS user_nick, u.user_name, " +
|
||||
" COALESCE(s.period, #{period}) AS period, " +
|
||||
" COALESCE(s.base_score, 60) AS base_score, " +
|
||||
" COALESCE(s.deduction, 0) AS deduction, " +
|
||||
" COALESCE(s.reward, 0) AS reward, " +
|
||||
" COALESCE(s.bonus, 0) AS bonus, " +
|
||||
" COALESCE(s.subjective_set, 0) AS subjective_set, " +
|
||||
" COALESCE(s.total_score, 80) AS total_score, " +
|
||||
" COALESCE(s.grade, 'B+') AS grade " +
|
||||
"FROM sys_user u " +
|
||||
"LEFT JOIN oa_performance_score s ON s.user_id = u.user_id AND s.period = #{period} " +
|
||||
"WHERE u.del_flag = '0' AND u.status = '0' " +
|
||||
"<if test='nameLike != null and nameLike != \"\"'> AND (u.nick_name LIKE CONCAT('%', #{nameLike}, '%') OR u.user_name LIKE CONCAT('%', #{nameLike}, '%'))</if>" +
|
||||
" ORDER BY COALESCE(s.total_score, 80) DESC, u.user_id ASC</script>")
|
||||
List<Map<String, Object>> listAllScores(@Param("period") String period,
|
||||
@Param("nameLike") String nameLike);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ruoyi.oa.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ruoyi.oa.domain.OaPerformanceScore;
|
||||
|
||||
public interface OaPerformanceScoreMapper extends BaseMapper<OaPerformanceScore> {
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.ruoyi.oa.mapper;
|
||||
|
||||
import com.ruoyi.common.core.mapper.BaseMapperPlus;
|
||||
import com.ruoyi.oa.domain.OaPostponeRecord;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public interface OaPostponeRecordMapper extends BaseMapperPlus<OaPostponeRecordMapper, OaPostponeRecord, OaPostponeRecord> {
|
||||
|
||||
/**
|
||||
* 列出当前 owner 名下所有 "已超期且没有正在走流程的顺延" 的业务条目。
|
||||
* 三类合并:sys_oa_task / oa_project_schedule_step / oa_requirements
|
||||
*
|
||||
* 排除规则:
|
||||
* - 已存在 status=0 的顺延记录(说明在等审批)
|
||||
* - 已存在 status=1 且 new_deadline > NOW() 的顺延(已经顺延过且新日期未到)
|
||||
*/
|
||||
@Select("SELECT * FROM (" +
|
||||
// task
|
||||
" SELECT 'task' AS business_type, t.task_id AS business_id, t.task_title AS business_title, " +
|
||||
" t.finish_time AS deadline, t.create_user_id AS owner_id, t.worker_id AS owner_id2, " +
|
||||
" t.project_id AS project_id " +
|
||||
" FROM sys_oa_task t " +
|
||||
" WHERE (t.del_flag = 0 OR t.del_flag IS NULL) " +
|
||||
" AND t.state IN (0,15) AND t.finish_time IS NOT NULL AND t.finish_time < NOW() " +
|
||||
" UNION ALL " +
|
||||
// schedule step:进度负责人 steward 存的是「中文昵称」,需反查 sys_user;
|
||||
// 取不到则回退到项目负责人 functionary(也是昵称)
|
||||
" SELECT 'step' AS business_type, s.track_id AS business_id, s.step_name AS business_title, " +
|
||||
" COALESCE(s.end_time, s.plan_end) AS deadline, " +
|
||||
" COALESCE(" +
|
||||
" (SELECT user_id FROM sys_user WHERE nick_name = sch.steward AND status='0' LIMIT 1)," +
|
||||
" (SELECT user_id FROM sys_user WHERE nick_name = p.functionary AND status='0' LIMIT 1)" +
|
||||
" ) AS owner_id, " +
|
||||
" NULL AS owner_id2, sch.project_id AS project_id " +
|
||||
" FROM oa_project_schedule_step s " +
|
||||
" JOIN oa_project_schedule sch ON sch.schedule_id = s.schedule_id " +
|
||||
" LEFT JOIN sys_oa_project p ON p.project_id = sch.project_id " +
|
||||
" WHERE s.del_flag = '0' AND (s.status IS NULL OR s.status != 2) " +
|
||||
" AND COALESCE(s.end_time, s.plan_end) IS NOT NULL " +
|
||||
" AND COALESCE(s.end_time, s.plan_end) < NOW() " +
|
||||
" UNION ALL " +
|
||||
// requirement: create_by 存的是「登录名」,反查 sys_user.user_name;owner_id 已是 user_id
|
||||
" SELECT 'requirement' AS business_type, r.requirement_id AS business_id, r.title AS business_title, " +
|
||||
" r.deadline, " +
|
||||
" (SELECT user_id FROM sys_user WHERE user_name = r.create_by AND status='0' LIMIT 1) AS owner_id, " +
|
||||
" r.owner_id AS owner_id2, r.project_id AS project_id " +
|
||||
" FROM oa_requirements r " +
|
||||
" WHERE r.del_flag = 0 AND r.status IN (0,1) AND r.deadline < NOW() " +
|
||||
") x " +
|
||||
"WHERE (x.owner_id = #{uid} OR x.owner_id2 = #{uid}) " +
|
||||
" AND NOT EXISTS (SELECT 1 FROM oa_postpone_record p " +
|
||||
" WHERE p.business_type = x.business_type AND p.business_id = x.business_id " +
|
||||
" AND p.del_flag = 0 AND p.status = 0) " +
|
||||
" AND NOT EXISTS (SELECT 1 FROM oa_postpone_record p2 " +
|
||||
" WHERE p2.business_type = x.business_type AND p2.business_id = x.business_id " +
|
||||
" AND p2.del_flag = 0 AND p2.status = 1 AND p2.new_deadline > NOW()) " +
|
||||
"ORDER BY x.deadline ASC")
|
||||
List<Map<String, Object>> listOverdueForUser(@Param("uid") Long uid);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM oa_postpone_record " +
|
||||
"WHERE business_type = #{type} AND business_id = #{bid} AND del_flag = 0 AND status IN (0,1)")
|
||||
Integer countSeq(@Param("type") String type, @Param("bid") Long bid);
|
||||
|
||||
/** 定时任务用:全员超期清单(不按 owner 过滤),逻辑同 listOverdueForUser */
|
||||
@Select("SELECT * FROM (" +
|
||||
" SELECT 'task' AS business_type, t.task_id AS business_id, t.task_title AS business_title, " +
|
||||
" t.finish_time AS deadline, t.create_user_id AS owner_id, t.worker_id AS owner_id2, " +
|
||||
" t.project_id AS project_id " +
|
||||
" FROM sys_oa_task t " +
|
||||
" WHERE (t.del_flag = 0 OR t.del_flag IS NULL) " +
|
||||
" AND t.state IN (0,15) AND t.finish_time IS NOT NULL AND t.finish_time < NOW() " +
|
||||
" UNION ALL " +
|
||||
" SELECT 'step', s.track_id, s.step_name, COALESCE(s.end_time, s.plan_end), " +
|
||||
" COALESCE(" +
|
||||
" (SELECT user_id FROM sys_user WHERE nick_name = sch.steward AND status='0' LIMIT 1)," +
|
||||
" (SELECT user_id FROM sys_user WHERE nick_name = p.functionary AND status='0' LIMIT 1)" +
|
||||
" ), NULL, sch.project_id " +
|
||||
" FROM oa_project_schedule_step s " +
|
||||
" JOIN oa_project_schedule sch ON sch.schedule_id = s.schedule_id " +
|
||||
" LEFT JOIN sys_oa_project p ON p.project_id = sch.project_id " +
|
||||
" WHERE s.del_flag = '0' AND (s.status IS NULL OR s.status != 2) " +
|
||||
" AND COALESCE(s.end_time, s.plan_end) IS NOT NULL " +
|
||||
" AND COALESCE(s.end_time, s.plan_end) < NOW() " +
|
||||
" UNION ALL " +
|
||||
" SELECT 'requirement', r.requirement_id, r.title, r.deadline, " +
|
||||
" (SELECT user_id FROM sys_user WHERE user_name = r.create_by AND status='0' LIMIT 1), " +
|
||||
" r.owner_id, r.project_id " +
|
||||
" FROM oa_requirements r " +
|
||||
" WHERE r.del_flag = 0 AND r.status IN (0,1) AND r.deadline < NOW() " +
|
||||
") x " +
|
||||
"WHERE NOT EXISTS (SELECT 1 FROM oa_postpone_record p WHERE p.business_type=x.business_type AND p.business_id=x.business_id AND p.del_flag=0 AND p.status=0) " +
|
||||
" AND NOT EXISTS (SELECT 1 FROM oa_postpone_record p2 WHERE p2.business_type=x.business_type AND p2.business_id=x.business_id AND p2.del_flag=0 AND p2.status=1 AND p2.new_deadline > NOW()) " +
|
||||
"ORDER BY x.deadline ASC")
|
||||
List<Map<String, Object>> scanAllOverdue();
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.ruoyi.oa.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 项目全景页专用聚合查询 Mapper。
|
||||
* 集中存放所有跨域统计 SQL,避免到处建 entity。
|
||||
* 注意:各表 del_flag 类型不一致(char(1) vs tinyint),SQL 里手工处理。
|
||||
*/
|
||||
public interface OaProjectOverviewMapper {
|
||||
|
||||
// ============ 头信息 ============
|
||||
@Select("SELECT CAST(p.project_id AS CHAR) AS project_id, " +
|
||||
" CAST(p.customer_id AS CHAR) AS customer_id, " +
|
||||
" CAST(p.contract_id AS CHAR) AS contract_id, " +
|
||||
" p.project_name, p.project_num, p.project_code, p.project_type, p.address, " +
|
||||
" p.funds, p.functionary, p.begin_time, p.finish_time, p.original_finish_time, " +
|
||||
" p.delivery, p.guarantee, p.introduction, p.project_grade, p.project_status, " +
|
||||
" p.product_status, p.is_postpone, p.postpone_reason, p.postpone_time, p.postpone_count, " +
|
||||
" p.color, p.trade_type, p.pre_pay, p.is_top, p.create_time, " +
|
||||
" COALESCE((SELECT nick_name FROM sys_user WHERE nick_name = p.functionary LIMIT 1), p.functionary) AS functionary_nick " +
|
||||
"FROM sys_oa_project p WHERE p.project_id = #{pid}")
|
||||
Map<String, Object> getProject(@Param("pid") Long pid);
|
||||
|
||||
@Select("SELECT CAST(customer_id AS CHAR) AS id, name, mobile, telephone, email, level, source " +
|
||||
"FROM oa_customer WHERE customer_id = #{cid} AND del_flag = 0")
|
||||
Map<String, Object> getCustomer(@Param("cid") Long cid);
|
||||
|
||||
// ============ 合同 ============
|
||||
@Select("SELECT CAST(c.contract_id AS CHAR) AS contract_id, c.contract_num, c.contract_name, " +
|
||||
"c.contract_price, c.contract_status, c.sign_time, c.create_time, " +
|
||||
"i.status AS approval_status, CAST(i.id AS CHAR) AS approval_instance_id " +
|
||||
"FROM sys_oa_contract c " +
|
||||
"LEFT JOIN (SELECT business_id, MAX(id) max_id FROM oa_approval_instance " +
|
||||
" WHERE business_type='contract' GROUP BY business_id) m ON m.business_id=c.contract_id " +
|
||||
"LEFT JOIN oa_approval_instance i ON i.id=m.max_id " +
|
||||
"WHERE c.project_id = #{pid} ORDER BY c.create_time DESC")
|
||||
List<Map<String, Object>> listContracts(@Param("pid") Long pid);
|
||||
|
||||
// ============ 进度 ============
|
||||
@Select("SELECT CAST(schedule_id AS CHAR) AS schedule_id, CAST(template_id AS CHAR) AS template_id, " +
|
||||
"current_step, start_time, end_time, status, steward " +
|
||||
"FROM oa_project_schedule WHERE project_id = #{pid} AND del_flag = '0' " +
|
||||
"ORDER BY create_time DESC LIMIT 1")
|
||||
Map<String, Object> getSchedule(@Param("pid") Long pid);
|
||||
|
||||
@Select("SELECT CAST(track_id AS CHAR) AS track_id, step_order, step_name, status, plan_start, plan_end, " +
|
||||
"actual_start, actual_end, original_end_time, end_time " +
|
||||
"FROM oa_project_schedule_step WHERE schedule_id = #{sid} AND del_flag = '0' " +
|
||||
"ORDER BY step_order ASC")
|
||||
List<Map<String, Object>> listSteps(@Param("sid") Long sid);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM oa_project_schedule_delay opsd " +
|
||||
"JOIN oa_project_schedule_step opss ON opss.track_id = opsd.track_id " +
|
||||
"WHERE opss.schedule_id = #{sid} AND opsd.del_flag = 0")
|
||||
Integer countDelays(@Param("sid") Long sid);
|
||||
|
||||
// ============ 采购需求 + 到货 ============
|
||||
@Select("SELECT status, COUNT(*) c FROM oa_requirements " +
|
||||
"WHERE project_id = #{pid} AND del_flag = 0 GROUP BY status")
|
||||
List<Map<String, Object>> requirementsByStatus(@Param("pid") Long pid);
|
||||
|
||||
@Select("SELECT CAST(r.requirement_id AS CHAR) AS requirement_id, title, r.status, deadline, r.create_time, " +
|
||||
"i.status AS approval_status, CAST(i.id AS CHAR) AS approval_instance_id " +
|
||||
"FROM oa_requirements r " +
|
||||
"LEFT JOIN (SELECT business_id, MAX(id) max_id FROM oa_approval_instance " +
|
||||
" WHERE business_type='purchase_req' GROUP BY business_id) m ON m.business_id=r.requirement_id " +
|
||||
"LEFT JOIN oa_approval_instance i ON i.id=m.max_id " +
|
||||
"WHERE r.project_id = #{pid} AND r.del_flag = 0 " +
|
||||
"ORDER BY r.create_time DESC LIMIT 20")
|
||||
List<Map<String, Object>> recentRequirements(@Param("pid") Long pid);
|
||||
|
||||
@Select("SELECT detail_status, COUNT(*) c FROM oa_arrival_detail " +
|
||||
"WHERE project_id = #{pid} AND del_flag = 0 GROUP BY detail_status")
|
||||
List<Map<String, Object>> arrivalsByStatus(@Param("pid") Long pid);
|
||||
|
||||
// ============ 库房申请 ============
|
||||
@Select("SELECT status, COUNT(*) c FROM sys_oa_warehouse_request " +
|
||||
"WHERE project_id = #{pid} AND del_flag = 0 GROUP BY status")
|
||||
List<Map<String, Object>> warehouseByStatus(@Param("pid") Long pid);
|
||||
|
||||
@Select("SELECT request_id, item_name, quantity, status, owner, create_time " +
|
||||
"FROM sys_oa_warehouse_request WHERE project_id = #{pid} AND del_flag = 0 " +
|
||||
"ORDER BY create_time DESC LIMIT 10")
|
||||
List<Map<String, Object>> recentWarehouseRequests(@Param("pid") Long pid);
|
||||
|
||||
// ============ 财务 ============
|
||||
@Select("SELECT COALESCE(SUM(amount), 0) FROM oa_payment_progress " +
|
||||
"WHERE project_id = #{pid} AND del_flag = 0 AND complete = 1")
|
||||
BigDecimal sumPaymentDone(@Param("pid") Long pid);
|
||||
|
||||
@Select("SELECT COALESCE(SUM(amount), 0) FROM oa_payment_progress " +
|
||||
"WHERE project_id = #{pid} AND del_flag = 0")
|
||||
BigDecimal sumPaymentTotal(@Param("pid") Long pid);
|
||||
|
||||
@Select("SELECT finance_type, COALESCE(SUM(CASE WHEN make_price REGEXP '^-?[0-9]+(\\\\.[0-9]+)?$' " +
|
||||
"THEN CAST(make_price AS DECIMAL(20,2)) ELSE 0 END), 0) total " +
|
||||
"FROM sys_oa_finance WHERE project_id = #{pid} GROUP BY finance_type")
|
||||
List<Map<String, Object>> financeByType(@Param("pid") Long pid);
|
||||
|
||||
@Select("SELECT COALESCE(SUM(cost), 0) FROM sys_oa_cost " +
|
||||
"WHERE project_id = #{pid} AND (del_flag = 0 OR del_flag IS NULL)")
|
||||
BigDecimal sumCost(@Param("pid") Long pid);
|
||||
|
||||
// ============ 任务 ============
|
||||
@Select("SELECT state, COUNT(*) c FROM sys_oa_task " +
|
||||
"WHERE project_id = #{pid} AND (del_flag = 0 OR del_flag IS NULL) " +
|
||||
"GROUP BY state")
|
||||
List<Map<String, Object>> tasksByState(@Param("pid") Long pid);
|
||||
|
||||
@Select("SELECT CAST(t.task_id AS CHAR) AS task_id, t.task_title, t.state, " +
|
||||
"t.begin_time, t.finish_time, t.completed_time, " +
|
||||
"u.nick_name AS worker_nick " +
|
||||
"FROM sys_oa_task t LEFT JOIN sys_user u ON u.user_id = t.worker_id " +
|
||||
"WHERE t.project_id = #{pid} AND (t.del_flag = 0 OR t.del_flag IS NULL) " +
|
||||
"ORDER BY t.create_time DESC LIMIT 20")
|
||||
List<Map<String, Object>> recentTasks(@Param("pid") Long pid);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM sys_oa_task " +
|
||||
"WHERE project_id = #{pid} AND (del_flag = 0 OR del_flag IS NULL) " +
|
||||
"AND state IN (0,15) AND finish_time IS NOT NULL AND finish_time < NOW()")
|
||||
Integer countOverdueTasks(@Param("pid") Long pid);
|
||||
|
||||
@Select("SELECT DISTINCT worker_id FROM sys_oa_task " +
|
||||
"WHERE project_id = #{pid} AND (del_flag = 0 OR del_flag IS NULL) AND worker_id IS NOT NULL")
|
||||
List<Long> taskWorkers(@Param("pid") Long pid);
|
||||
|
||||
@Select("<script>SELECT user_id, nick_name FROM sys_user WHERE user_id IN " +
|
||||
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
|
||||
List<Map<String, Object>> usersByIds(@Param("ids") List<Long> ids);
|
||||
|
||||
// ============ 报告 / 会议 / 操作日志 ============
|
||||
@Select("SELECT r.report_id id, r.user_id, u.nick_name AS user_name, " +
|
||||
"r.work_place, r.content, r.create_time " +
|
||||
"FROM oa_project_report r LEFT JOIN sys_user u ON u.user_id = r.user_id " +
|
||||
"WHERE r.project_id = #{pid} AND r.del_flag = 0 " +
|
||||
"ORDER BY r.create_time DESC LIMIT 8")
|
||||
List<Map<String, Object>> recentReports(@Param("pid") Long pid);
|
||||
|
||||
@Select("SELECT id, meeting_date, subject, location, meeting_type " +
|
||||
"FROM oa_meeting_minutes WHERE project_id = #{pid} AND del_flag = '0' " +
|
||||
"ORDER BY meeting_date DESC LIMIT 5")
|
||||
List<Map<String, Object>> recentMeetings(@Param("pid") Long pid);
|
||||
|
||||
@Select("SELECT log_id id, target_type, target_name, operation_type, operation_desc, " +
|
||||
"operator, operate_time FROM oa_project_operation_log " +
|
||||
"WHERE project_id = #{pid} AND (del_flag = 0 OR del_flag IS NULL) " +
|
||||
"ORDER BY operate_time DESC LIMIT 20")
|
||||
List<Map<String, Object>> recentOpLog(@Param("pid") Long pid);
|
||||
|
||||
// ============ 审批时间线(跨业务汇总) ============
|
||||
@Select("<script>SELECT CAST(i.id AS CHAR) AS id, i.business_type, CAST(i.business_id AS CHAR) AS business_id, " +
|
||||
"i.business_title, i.apply_user_name, i.apply_time, i.status, i.finish_time, i.sign_type " +
|
||||
"FROM oa_approval_instance i WHERE i.del_flag = 0 AND " +
|
||||
"<foreach collection='filters' item='f' separator=' OR ' open='(' close=')'>" +
|
||||
"(i.business_type = #{f.type} AND i.business_id IN " +
|
||||
"<foreach collection='f.ids' item='bid' open='(' separator=',' close=')'>#{bid}</foreach>)" +
|
||||
"</foreach> ORDER BY i.id DESC LIMIT 30</script>")
|
||||
List<Map<String, Object>> approvalTimeline(@Param("filters") List<Map<String, Object>> filters);
|
||||
|
||||
// ============ fad_rm 制造主线(轧机厂三张关键表) ============
|
||||
@Select("SELECT progress_id, item_name, status, plan_start, plan_end, " +
|
||||
"actual_start, actual_end, delay_reason " +
|
||||
"FROM fad_rm_install_progress WHERE project_id = #{pid} AND del_flag = '0' " +
|
||||
"ORDER BY plan_start ASC")
|
||||
List<Map<String, Object>> installProgress(@Param("pid") Long pid);
|
||||
|
||||
@Select("SELECT record_id, record_date, record_type, param_name, param_value, result, issue_desc " +
|
||||
"FROM fad_rm_commissioning_record WHERE project_id = #{pid} AND del_flag = '0' " +
|
||||
"ORDER BY record_date DESC LIMIT 10")
|
||||
List<Map<String, Object>> commissioningRecords(@Param("pid") Long pid);
|
||||
|
||||
@Select("SELECT check_id, item_text, is_checked, sort_order " +
|
||||
"FROM fad_rm_acceptance_checklist WHERE project_id = #{pid} AND del_flag = '0' " +
|
||||
"ORDER BY sort_order ASC")
|
||||
List<Map<String, Object>> acceptanceChecklist(@Param("pid") Long pid);
|
||||
|
||||
@Select("SELECT accept_item_id, item_name, standard, result, inspector, inspect_date " +
|
||||
"FROM fad_rm_acceptance_item WHERE project_id = #{pid} AND del_flag = '0' " +
|
||||
"ORDER BY inspect_date DESC")
|
||||
List<Map<String, Object>> acceptanceItems(@Param("pid") Long pid);
|
||||
|
||||
// ============ 集团驾驶舱:项目列表 + 健康度信号 ============
|
||||
// 注意:BIGINT 在 Map 里序列化可能丢精度,CAST 为字符串
|
||||
@Select("<script>SELECT CAST(p.project_id AS CHAR) AS project_id, " +
|
||||
"p.project_name, p.project_code, p.project_status, " +
|
||||
"p.funds, p.begin_time, p.finish_time, p.postpone_count, p.functionary, " +
|
||||
"p.functionary AS functionary_nick, " +
|
||||
"(SELECT COUNT(*) FROM sys_oa_contract c WHERE c.project_id = p.project_id) AS contract_count, " +
|
||||
"(SELECT COALESCE(SUM(amount),0) FROM oa_payment_progress " +
|
||||
" WHERE project_id = p.project_id AND del_flag=0 AND complete=1) AS payment_done, " +
|
||||
"(SELECT COALESCE(SUM(cost),0) FROM sys_oa_cost " +
|
||||
" WHERE project_id = p.project_id AND (del_flag=0 OR del_flag IS NULL)) AS cost, " +
|
||||
"(SELECT COUNT(*) FROM sys_oa_task " +
|
||||
" WHERE project_id = p.project_id AND (del_flag=0 OR del_flag IS NULL) " +
|
||||
" AND state IN (0,15) AND finish_time IS NOT NULL AND finish_time < NOW()) AS overdue_tasks, " +
|
||||
"(SELECT COUNT(*) FROM oa_approval_instance " +
|
||||
" WHERE del_flag=0 AND status=0 AND " +
|
||||
" ((business_type='contract' AND business_id IN (SELECT contract_id FROM sys_oa_contract WHERE project_id=p.project_id)) OR " +
|
||||
" (business_type='purchase_req' AND business_id IN (SELECT requirement_id FROM oa_requirements WHERE project_id=p.project_id AND del_flag=0)))) AS pending_approvals " +
|
||||
"FROM sys_oa_project p " +
|
||||
"WHERE 1=1 " +
|
||||
"<if test='status != null and status != \"\"'> AND p.project_status = #{status}</if>" +
|
||||
"<if test='nameLike != null and nameLike != \"\"'> AND (p.project_name LIKE CONCAT('%',#{nameLike},'%') OR p.project_code LIKE CONCAT('%',#{nameLike},'%'))</if>" +
|
||||
" ORDER BY p.is_top DESC, p.create_time DESC LIMIT #{limit}</script>")
|
||||
List<Map<String, Object>> dashboardProjects(@Param("status") String status,
|
||||
@Param("nameLike") String nameLike,
|
||||
@Param("limit") int limit);
|
||||
|
||||
@Select("SELECT " +
|
||||
"(SELECT COUNT(*) FROM sys_oa_project WHERE project_status='0') AS running, " +
|
||||
"(SELECT COUNT(*) FROM sys_oa_project WHERE project_status='1') AS finished, " +
|
||||
"(SELECT COUNT(*) FROM sys_oa_project WHERE finish_time < NOW() AND project_status='0') AS overdue_running, " +
|
||||
"(SELECT COUNT(*) FROM oa_approval_instance WHERE del_flag=0 AND status=0) AS total_pending_approvals, " +
|
||||
"(SELECT COUNT(*) FROM sys_oa_task WHERE (del_flag=0 OR del_flag IS NULL) AND state IN (0,15) AND finish_time IS NOT NULL AND finish_time < NOW()) AS total_overdue_tasks, " +
|
||||
"(SELECT COALESCE(SUM(funds),0) FROM sys_oa_project WHERE project_status='0') AS running_funds")
|
||||
Map<String, Object> dashboardSummary();
|
||||
|
||||
// 同样的 functionary 是昵称问题,在 dashboardProjects 里 nick_name 直接用 p.functionary
|
||||
// 但这里实际效果跟 dashboardProjects 里的 functionary_nick 字段保持兼容:
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.ruoyi.oa.service;
|
||||
|
||||
import com.ruoyi.oa.domain.OaPerformanceDeduction;
|
||||
import com.ruoyi.oa.domain.OaPerformanceScore;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public interface IOaPerformanceService {
|
||||
|
||||
/**
|
||||
* 扣分(已有同 dedup_key 则跳过;不存在则插入并刷新当月汇总)。
|
||||
* @param dedupKey 同一类扣分的唯一键;postpone 用 "postpone_{type}_{id}_seq{n}" 不冲突;overdue30 用 "overdue30_{type}_{id}" 同业务唯一
|
||||
* @return true=扣成功 false=已扣过/跳过
|
||||
*/
|
||||
boolean addDeduction(Long userId, String userName, String source, String sourceType,
|
||||
Long sourceId, int points, String reason, String dedupKey);
|
||||
|
||||
/** 我的某月得分 + 流水 */
|
||||
Map<String, Object> myScore(Long userId, String period);
|
||||
|
||||
/** 全员某月排名 */
|
||||
List<Map<String, Object>> rank(String period, String nameLike);
|
||||
|
||||
/** 当前周期 YYYY-MM */
|
||||
String currentPeriod();
|
||||
|
||||
/** 等级换算 */
|
||||
String gradeOf(int total);
|
||||
|
||||
/** 高总给某员工某周期手动打主观分(0-40) */
|
||||
boolean setSubjectiveScore(Long userId, String userName, String period, int score);
|
||||
|
||||
/**
|
||||
* 尝试加分(受月度封顶限制)
|
||||
* @param points 正数(加多少分);自动转成 -points 写到流水里
|
||||
* @param capPerPeriod 该 source 当月封顶
|
||||
* @return 实际加成的分数(0 表示已达封顶)
|
||||
*/
|
||||
int tryReward(Long userId, String userName, String source,
|
||||
String sourceType, Long sourceId, int points,
|
||||
String reason, String dedupKey, int capPerPeriod);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.ruoyi.oa.service;
|
||||
|
||||
import com.ruoyi.oa.domain.OaPostponeRecord;
|
||||
import com.ruoyi.oa.domain.bo.OaPostponeBo;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public interface IOaPostponeService {
|
||||
|
||||
/** 当前用户的待处理超期清单 */
|
||||
List<Map<String, Object>> listMine();
|
||||
|
||||
/**
|
||||
* 直接完成:把业务记录的 status 置为完成
|
||||
*/
|
||||
void markComplete(String businessType, Long businessId);
|
||||
|
||||
/**
|
||||
* 直接取消:把业务记录的 status 置为取消
|
||||
*/
|
||||
void markCancel(String businessType, Long businessId, String reason);
|
||||
|
||||
/**
|
||||
* 申请顺延:
|
||||
* - 第 1、2 次:直接写 oa_postpone_record + 立即应用到业务表(new_deadline)。返回 needApproval=false
|
||||
* - 第 3 次及以后:写 oa_postpone_record(status=0, need_approval=1) + 提交审批。返回 needApproval=true
|
||||
* 返回 map: { needApproval: bool, postponeSeq: N, recordId, instanceId? }
|
||||
*/
|
||||
Map<String, Object> postpone(OaPostponeBo bo);
|
||||
|
||||
/**
|
||||
* 由审批回调触发:根据已通过的 instanceId 找到对应 record,把 new_deadline 应用到业务表
|
||||
*/
|
||||
void applyByInstance(Long instanceId);
|
||||
|
||||
/**
|
||||
* 由审批回调触发:驳回时把 record 标记为 status=2
|
||||
*/
|
||||
void rejectByInstance(Long instanceId);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ruoyi.oa.service;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 项目全景页聚合 service。
|
||||
* 返回一个大 Map(每个 key 对应一个前端卡片所需的数据),让前端一次拉完。
|
||||
*/
|
||||
public interface IOaProjectOverviewService {
|
||||
|
||||
/**
|
||||
* 聚合一个项目的所有维度数据。
|
||||
* @param projectId 项目主键
|
||||
* @return 顶层 keys: header / customer / contracts / schedule / requirements / arrivals /
|
||||
* warehouse / finance / tasks / team / reports / meetings / approvals / operationLog
|
||||
*/
|
||||
Map<String, Object> overview(Long projectId);
|
||||
|
||||
/** 集团驾驶舱:所有项目一览 + 全局汇总 */
|
||||
Map<String, Object> dashboard(String status, String nameLike, Integer limit);
|
||||
}
|
||||
@@ -21,9 +21,12 @@ import com.ruoyi.oa.mapper.OaApprovalConfigMapper;
|
||||
import com.ruoyi.oa.mapper.OaApprovalInstanceMapper;
|
||||
import com.ruoyi.oa.mapper.OaApprovalRecordMapper;
|
||||
import com.ruoyi.oa.service.IOaApprovalService;
|
||||
import com.ruoyi.oa.service.IOaPostponeService;
|
||||
import com.ruoyi.common.core.domain.entity.SysUser;
|
||||
import com.ruoyi.system.service.ISysUserService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -39,6 +42,11 @@ public class OaApprovalServiceImpl implements IOaApprovalService {
|
||||
private final OaApprovalRecordMapper recordMapper;
|
||||
private final ISysUserService userService;
|
||||
|
||||
// 用 @Lazy 打破循环:postpone -> approval -> postpone
|
||||
@Autowired
|
||||
@Lazy
|
||||
private IOaPostponeService postponeService;
|
||||
|
||||
// ===================== 配置 =====================
|
||||
|
||||
@Override
|
||||
@@ -156,6 +164,12 @@ public class OaApprovalServiceImpl implements IOaApprovalService {
|
||||
inst.setStatus(finalStatus);
|
||||
inst.setFinishTime(new Date());
|
||||
instanceMapper.updateById(inst);
|
||||
// 顺延审批的回调:通过 → 应用 new_deadline;驳回 → 标记 record
|
||||
String bt = inst.getBusinessType();
|
||||
if (bt != null && bt.endsWith("_postpone")) {
|
||||
if (finalStatus == 1) postponeService.applyByInstance(inst.getId());
|
||||
else if (finalStatus == 2) postponeService.rejectByInstance(inst.getId());
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
package com.ruoyi.oa.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.ruoyi.oa.domain.OaPerformanceDeduction;
|
||||
import com.ruoyi.oa.domain.OaPerformanceScore;
|
||||
import com.ruoyi.oa.mapper.OaPerformanceDeductionMapper;
|
||||
import com.ruoyi.oa.mapper.OaPerformanceMapper;
|
||||
import com.ruoyi.oa.mapper.OaPerformanceScoreMapper;
|
||||
import com.ruoyi.oa.service.IOaPerformanceService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.*;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OaPerformanceServiceImpl implements IOaPerformanceService {
|
||||
|
||||
public static final int BASE_SCORE = 80;
|
||||
public static final int SUBJECTIVE_MIN = -20;
|
||||
public static final int SUBJECTIVE_MAX = 20;
|
||||
public static final int TOTAL_CAP = 100;
|
||||
|
||||
private final OaPerformanceMapper queryMapper;
|
||||
private final OaPerformanceScoreMapper scoreMapper;
|
||||
private final OaPerformanceDeductionMapper dedMapper;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean addDeduction(Long userId, String userName, String source, String sourceType,
|
||||
Long sourceId, int points, String reason, String dedupKey) {
|
||||
if (userId == null) return false;
|
||||
String period = currentPeriod();
|
||||
|
||||
// dedup_key 命中过就直接放弃
|
||||
if (dedupKey != null && !dedupKey.isEmpty()) {
|
||||
Long exist = dedMapper.selectCount(Wrappers.<OaPerformanceDeduction>lambdaQuery()
|
||||
.eq(OaPerformanceDeduction::getDedupKey, dedupKey));
|
||||
if (exist != null && exist > 0) return false;
|
||||
}
|
||||
|
||||
OaPerformanceDeduction d = new OaPerformanceDeduction();
|
||||
d.setUserId(userId);
|
||||
d.setUserName(userName);
|
||||
d.setPeriod(period);
|
||||
d.setSource(source);
|
||||
d.setSourceType(sourceType);
|
||||
d.setSourceId(sourceId);
|
||||
d.setPoints(points);
|
||||
d.setReason(reason);
|
||||
d.setDedupKey(dedupKey);
|
||||
try {
|
||||
dedMapper.insert(d);
|
||||
} catch (DuplicateKeyException e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 刷新当月汇总
|
||||
refreshScore(userId, userName, period);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void refreshScore(Long userId, String userName, String period) {
|
||||
OaPerformanceScore s = queryMapper.findScore(userId, period);
|
||||
boolean isNew = s == null;
|
||||
if (isNew) {
|
||||
s = new OaPerformanceScore();
|
||||
s.setUserId(userId);
|
||||
s.setUserName(userName);
|
||||
s.setPeriod(period);
|
||||
s.setBaseScore(BASE_SCORE);
|
||||
s.setBonus(0); // 高总主观分默认 0(未评价)
|
||||
s.setSubjectiveSet(0);
|
||||
}
|
||||
List<OaPerformanceDeduction> all = queryMapper.listDeductions(userId, period);
|
||||
int dedSum = 0, rewardSum = 0;
|
||||
for (OaPerformanceDeduction x : all) {
|
||||
if (x.getPoints() == null) continue;
|
||||
String src = x.getSource();
|
||||
if ("manual_subjective".equals(src)) continue;
|
||||
// auto reward 类(按 source 区分):reward_task / reward_step / reward_trip / reward_attendance
|
||||
if (src != null && src.startsWith("reward_")) {
|
||||
rewardSum += Math.abs(x.getPoints());
|
||||
} else if (x.getPoints() >= 0) {
|
||||
dedSum += x.getPoints();
|
||||
} else {
|
||||
// 负点数(manual 加分)→ 计入 reward
|
||||
rewardSum += -x.getPoints();
|
||||
}
|
||||
}
|
||||
s.setDeduction(dedSum);
|
||||
s.setReward(rewardSum);
|
||||
int bonus = s.getBonus() == null ? 0 : s.getBonus();
|
||||
boolean subSet = s.getSubjectiveSet() != null && s.getSubjectiveSet() == 1;
|
||||
int total = computeTotal(BASE_SCORE, dedSum, rewardSum, bonus, subSet);
|
||||
s.setTotalScore(total);
|
||||
s.setGrade(gradeOf(total));
|
||||
if (s.getId() == null) scoreMapper.insert(s);
|
||||
else scoreMapper.updateById(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* 总分 = clamp(base - 扣 + 加 + 主观, 0, 100)
|
||||
* base=80; 主观分范围 -20~+20,默认 0;初始总分=80=B+
|
||||
*/
|
||||
public static int computeTotal(int base, int ded, int reward, int bonus, boolean subjectiveSet) {
|
||||
int total = base - ded + reward + bonus;
|
||||
return Math.max(0, Math.min(TOTAL_CAP, total));
|
||||
}
|
||||
|
||||
/**
|
||||
* 高总(或管理员)给某员工某月手动打主观分 (-20 ~ +20)
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean setSubjectiveScore(Long userId, String userName, String period, int score) {
|
||||
if (score < SUBJECTIVE_MIN || score > SUBJECTIVE_MAX) {
|
||||
throw new com.ruoyi.common.exception.ServiceException(
|
||||
"主观分必须在 " + SUBJECTIVE_MIN + " ~ " + SUBJECTIVE_MAX + " 之间");
|
||||
}
|
||||
if (period == null) period = currentPeriod();
|
||||
OaPerformanceScore s = queryMapper.findScore(userId, period);
|
||||
if (s == null) {
|
||||
s = new OaPerformanceScore();
|
||||
s.setUserId(userId);
|
||||
s.setUserName(userName);
|
||||
s.setPeriod(period);
|
||||
s.setBaseScore(BASE_SCORE);
|
||||
s.setDeduction(0);
|
||||
}
|
||||
s.setBonus(score);
|
||||
s.setSubjectiveSet(1);
|
||||
int total = computeTotal(BASE_SCORE,
|
||||
s.getDeduction() == null ? 0 : s.getDeduction(),
|
||||
s.getReward() == null ? 0 : s.getReward(),
|
||||
score, true);
|
||||
s.setTotalScore(total);
|
||||
s.setGrade(gradeOf(total));
|
||||
if (s.getId() == null) scoreMapper.insert(s);
|
||||
else scoreMapper.updateById(s);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> myScore(Long userId, String period) {
|
||||
if (period == null) period = currentPeriod();
|
||||
Map<String, Object> r = new LinkedHashMap<>();
|
||||
OaPerformanceScore s = queryMapper.findScore(userId, period);
|
||||
if (s == null) {
|
||||
// 没记录 = 默认满分(未打分用兜底,等比放大 = 100)
|
||||
OaPerformanceScore empty = new OaPerformanceScore();
|
||||
empty.setUserId(userId);
|
||||
empty.setPeriod(period);
|
||||
empty.setBaseScore(BASE_SCORE);
|
||||
empty.setDeduction(0);
|
||||
empty.setBonus(0); // 未评价 = 主观 0
|
||||
empty.setReward(0);
|
||||
empty.setSubjectiveSet(0);
|
||||
int total = BASE_SCORE; // 初始 80 = B+
|
||||
empty.setTotalScore(total);
|
||||
empty.setGrade(gradeOf(total));
|
||||
r.put("score", empty);
|
||||
} else {
|
||||
r.put("score", s);
|
||||
}
|
||||
r.put("deductions", queryMapper.listDeductions(userId, period));
|
||||
return r;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> rank(String period, String nameLike) {
|
||||
if (period == null) period = currentPeriod();
|
||||
return queryMapper.listAllScores(period, nameLike);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String currentPeriod() {
|
||||
return new SimpleDateFormat("yyyy-MM").format(new Date());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int tryReward(Long userId, String userName, String source,
|
||||
String sourceType, Long sourceId, int points,
|
||||
String reason, String dedupKey, int capPerPeriod) {
|
||||
if (userId == null || points <= 0) return 0;
|
||||
String period = currentPeriod();
|
||||
// 该 source 当月已加多少
|
||||
Long existSum = dedMapper.selectCount(Wrappers.<OaPerformanceDeduction>lambdaQuery()
|
||||
.eq(OaPerformanceDeduction::getUserId, userId)
|
||||
.eq(OaPerformanceDeduction::getPeriod, period)
|
||||
.eq(OaPerformanceDeduction::getSource, source));
|
||||
// 用 SQL 拿到 sum 更准,但实现简单先用 count*points 估计;这里用 selectList 再 sum
|
||||
java.util.List<OaPerformanceDeduction> exist = dedMapper.selectList(Wrappers.<OaPerformanceDeduction>lambdaQuery()
|
||||
.eq(OaPerformanceDeduction::getUserId, userId)
|
||||
.eq(OaPerformanceDeduction::getPeriod, period)
|
||||
.eq(OaPerformanceDeduction::getSource, source));
|
||||
int currentSum = 0;
|
||||
for (OaPerformanceDeduction d : exist) currentSum += Math.abs(d.getPoints() == null ? 0 : d.getPoints());
|
||||
int remain = capPerPeriod - currentSum;
|
||||
if (remain <= 0) return 0;
|
||||
int actual = Math.min(points, remain);
|
||||
boolean ok = addDeduction(userId, userName, source, sourceType, sourceId,
|
||||
-actual, reason, dedupKey);
|
||||
return ok ? actual : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String gradeOf(int total) {
|
||||
if (total >= 95) return "A+";
|
||||
if (total >= 90) return "A";
|
||||
if (total >= 85) return "A-";
|
||||
if (total >= 80) return "B+";
|
||||
if (total >= 75) return "B";
|
||||
if (total >= 70) return "B-";
|
||||
if (total >= 65) return "C+";
|
||||
if (total >= 60) return "C";
|
||||
if (total >= 55) return "C-";
|
||||
return "D";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package com.ruoyi.oa.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.helper.LoginHelper;
|
||||
import com.ruoyi.oa.domain.OaPostponeRecord;
|
||||
import com.ruoyi.oa.domain.OaRequirements;
|
||||
import com.ruoyi.oa.domain.OaProjectScheduleStep;
|
||||
import com.ruoyi.oa.domain.SysOaTask;
|
||||
import com.ruoyi.oa.domain.bo.OaPostponeBo;
|
||||
import com.ruoyi.oa.mapper.OaPostponeRecordMapper;
|
||||
import com.ruoyi.oa.mapper.OaProjectScheduleStepMapper;
|
||||
import com.ruoyi.oa.mapper.OaRequirementsMapper;
|
||||
import com.ruoyi.oa.mapper.SysOaTaskMapper;
|
||||
import com.ruoyi.oa.service.IOaApprovalService;
|
||||
import com.ruoyi.oa.service.IOaPerformanceService;
|
||||
import com.ruoyi.oa.service.IOaPostponeService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OaPostponeServiceImpl implements IOaPostponeService {
|
||||
|
||||
public static final int APPROVAL_THRESHOLD = 3;
|
||||
|
||||
private final OaPostponeRecordMapper postponeMapper;
|
||||
private final SysOaTaskMapper taskMapper;
|
||||
private final OaProjectScheduleStepMapper stepMapper;
|
||||
private final OaRequirementsMapper requirementsMapper;
|
||||
|
||||
// 用 @Lazy 打破和 OaApprovalServiceImpl 的循环依赖
|
||||
@Autowired
|
||||
@Lazy
|
||||
private IOaApprovalService approvalService;
|
||||
|
||||
@Autowired
|
||||
private IOaPerformanceService performanceService;
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> listMine() {
|
||||
Long uid = LoginHelper.getUserId();
|
||||
if (uid == null) return java.util.Collections.emptyList();
|
||||
return postponeMapper.listOverdueForUser(uid);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void markComplete(String businessType, Long businessId) {
|
||||
ensureType(businessType);
|
||||
switch (businessType) {
|
||||
case "task":
|
||||
SysOaTask task = taskMapper.selectById(businessId);
|
||||
if (task == null) throw new ServiceException("任务不存在");
|
||||
task.setState(2L);
|
||||
task.setCompletedTime(new Date());
|
||||
taskMapper.updateById(task);
|
||||
break;
|
||||
case "step":
|
||||
OaProjectScheduleStep step = stepMapper.selectById(businessId);
|
||||
if (step == null) throw new ServiceException("步骤不存在");
|
||||
step.setStatus(2L);
|
||||
step.setActualEnd(new Date());
|
||||
stepMapper.updateById(step);
|
||||
break;
|
||||
case "requirement":
|
||||
OaRequirements req = requirementsMapper.selectById(businessId);
|
||||
if (req == null) throw new ServiceException("采购需求不存在");
|
||||
req.setStatus(2);
|
||||
requirementsMapper.updateById(req);
|
||||
break;
|
||||
default:
|
||||
throw new ServiceException("不支持的业务类型");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void markCancel(String businessType, Long businessId, String reason) {
|
||||
ensureType(businessType);
|
||||
switch (businessType) {
|
||||
case "task":
|
||||
SysOaTask task = taskMapper.selectById(businessId);
|
||||
if (task == null) throw new ServiceException("任务不存在");
|
||||
// 任务没有"取消"状态,用 state=2(完成) + remark 体现
|
||||
task.setState(2L);
|
||||
task.setRemark(appendReason(task.getRemark(), "[取消] " + reason));
|
||||
taskMapper.updateById(task);
|
||||
break;
|
||||
case "step":
|
||||
OaProjectScheduleStep step = stepMapper.selectById(businessId);
|
||||
if (step == null) throw new ServiceException("步骤不存在");
|
||||
step.setStatus(2L);
|
||||
step.setActualEnd(new Date());
|
||||
stepMapper.updateById(step);
|
||||
break;
|
||||
case "requirement":
|
||||
OaRequirements req = requirementsMapper.selectById(businessId);
|
||||
if (req == null) throw new ServiceException("采购需求不存在");
|
||||
req.setStatus(3); // 3=取消
|
||||
req.setRemark(appendReason(req.getRemark(), "[取消] " + reason));
|
||||
requirementsMapper.updateById(req);
|
||||
break;
|
||||
default:
|
||||
throw new ServiceException("不支持的业务类型");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> postpone(OaPostponeBo bo) {
|
||||
ensureType(bo.getBusinessType());
|
||||
if (bo.getNewDeadline() == null) throw new ServiceException("新截止日期必填");
|
||||
if (bo.getNewDeadline().before(new Date())) throw new ServiceException("新截止日期不能早于今天");
|
||||
|
||||
Date originalDeadline = readOriginalDeadline(bo.getBusinessType(), bo.getBusinessId());
|
||||
Integer prevSeq = postponeMapper.countSeq(bo.getBusinessType(), bo.getBusinessId());
|
||||
int newSeq = (prevSeq == null ? 0 : prevSeq) + 1;
|
||||
boolean needApproval = newSeq >= APPROVAL_THRESHOLD;
|
||||
|
||||
// 建 record
|
||||
OaPostponeRecord rec = new OaPostponeRecord();
|
||||
rec.setBusinessType(bo.getBusinessType());
|
||||
rec.setBusinessId(bo.getBusinessId());
|
||||
rec.setBusinessTitle(bo.getBusinessTitle());
|
||||
rec.setOwnerId(LoginHelper.getUserId());
|
||||
rec.setOwnerName(LoginHelper.getNickName());
|
||||
rec.setOriginalDeadline(originalDeadline);
|
||||
rec.setNewDeadline(bo.getNewDeadline());
|
||||
rec.setReason(bo.getReason());
|
||||
rec.setPostponeSeq(newSeq);
|
||||
rec.setNeedApproval(needApproval ? 1 : 0);
|
||||
rec.setStatus(needApproval ? 0 : 1);
|
||||
postponeMapper.insert(rec);
|
||||
|
||||
Map<String, Object> r = new HashMap<>();
|
||||
r.put("recordId", rec.getId());
|
||||
r.put("postponeSeq", newSeq);
|
||||
r.put("needApproval", needApproval);
|
||||
|
||||
if (needApproval) {
|
||||
String approvalBizType = mapToApprovalType(bo.getBusinessType());
|
||||
// 用 record.id 作为审批的 business_id,确保 1 条审批单对应 1 条 postpone
|
||||
Long instId = approvalService.submit(approvalBizType, rec.getId(),
|
||||
"[" + label(bo.getBusinessType()) + "] " + (bo.getBusinessTitle() == null ? "" : bo.getBusinessTitle())
|
||||
+ " 第 " + newSeq + " 次顺延");
|
||||
rec.setApprovalInstanceId(instId);
|
||||
postponeMapper.updateById(rec);
|
||||
r.put("instanceId", instId);
|
||||
} else {
|
||||
// 直接应用到业务表
|
||||
applyNewDeadline(bo.getBusinessType(), bo.getBusinessId(), bo.getNewDeadline());
|
||||
}
|
||||
|
||||
// 绩效扣分(每次顺延 -1,dedupKey 含序号避免误判重复)
|
||||
String dedupKey = "postpone_" + bo.getBusinessType() + "_" + bo.getBusinessId() + "_seq" + newSeq;
|
||||
performanceService.addDeduction(
|
||||
LoginHelper.getUserId(), LoginHelper.getNickName(),
|
||||
"postpone", bo.getBusinessType(), bo.getBusinessId(),
|
||||
1,
|
||||
"第 " + newSeq + " 次顺延:" + (bo.getBusinessTitle() == null ? "" : bo.getBusinessTitle()),
|
||||
dedupKey);
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void applyByInstance(Long instanceId) {
|
||||
OaPostponeRecord rec = postponeMapper.selectOne(
|
||||
Wrappers.<OaPostponeRecord>lambdaQuery()
|
||||
.eq(OaPostponeRecord::getApprovalInstanceId, instanceId)
|
||||
.last("LIMIT 1"));
|
||||
if (rec == null || rec.getStatus() != 0) return;
|
||||
rec.setStatus(1);
|
||||
postponeMapper.updateById(rec);
|
||||
applyNewDeadline(rec.getBusinessType(), rec.getBusinessId(), rec.getNewDeadline());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void rejectByInstance(Long instanceId) {
|
||||
OaPostponeRecord rec = postponeMapper.selectOne(
|
||||
Wrappers.<OaPostponeRecord>lambdaQuery()
|
||||
.eq(OaPostponeRecord::getApprovalInstanceId, instanceId)
|
||||
.last("LIMIT 1"));
|
||||
if (rec == null || rec.getStatus() != 0) return;
|
||||
rec.setStatus(2);
|
||||
postponeMapper.updateById(rec);
|
||||
}
|
||||
|
||||
// ============ 内部 ============
|
||||
|
||||
private void ensureType(String t) {
|
||||
if (!"task".equals(t) && !"step".equals(t) && !"requirement".equals(t)) {
|
||||
throw new ServiceException("不支持的业务类型: " + t);
|
||||
}
|
||||
}
|
||||
|
||||
private String mapToApprovalType(String businessType) {
|
||||
switch (businessType) {
|
||||
case "task": return "task_postpone";
|
||||
case "step": return "step_postpone";
|
||||
case "requirement": return "req_postpone";
|
||||
default: throw new ServiceException("未知业务类型");
|
||||
}
|
||||
}
|
||||
|
||||
private String label(String businessType) {
|
||||
switch (businessType) {
|
||||
case "task": return "任务";
|
||||
case "step": return "进度步骤";
|
||||
case "requirement": return "采购需求";
|
||||
default: return businessType;
|
||||
}
|
||||
}
|
||||
|
||||
private Date readOriginalDeadline(String businessType, Long businessId) {
|
||||
switch (businessType) {
|
||||
case "task": {
|
||||
SysOaTask t = taskMapper.selectById(businessId);
|
||||
return t == null ? null : t.getFinishTime();
|
||||
}
|
||||
case "step": {
|
||||
OaProjectScheduleStep s = stepMapper.selectById(businessId);
|
||||
if (s == null) return null;
|
||||
// endTime 是 LocalDateTime,planEnd 是 Date,转换后统一
|
||||
if (s.getEndTime() != null) {
|
||||
return Date.from(s.getEndTime().atZone(ZoneId.systemDefault()).toInstant());
|
||||
}
|
||||
return s.getPlanEnd();
|
||||
}
|
||||
case "requirement": {
|
||||
OaRequirements r = requirementsMapper.selectById(businessId);
|
||||
return r == null ? null : r.getDeadline();
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void applyNewDeadline(String businessType, Long businessId, Date newDeadline) {
|
||||
switch (businessType) {
|
||||
case "task": {
|
||||
SysOaTask t = taskMapper.selectById(businessId);
|
||||
if (t == null) return;
|
||||
t.setFinishTime(newDeadline);
|
||||
Long p = t.getPostponements();
|
||||
t.setPostponements((p == null ? 0L : p) + 1L);
|
||||
taskMapper.updateById(t);
|
||||
break;
|
||||
}
|
||||
case "step": {
|
||||
OaProjectScheduleStep s = stepMapper.selectById(businessId);
|
||||
if (s == null) return;
|
||||
LocalDateTime ldt = LocalDateTime.ofInstant(newDeadline.toInstant(), ZoneId.systemDefault());
|
||||
s.setEndTime(ldt);
|
||||
s.setPlanEnd(newDeadline);
|
||||
stepMapper.updateById(s);
|
||||
break;
|
||||
}
|
||||
case "requirement": {
|
||||
OaRequirements r = requirementsMapper.selectById(businessId);
|
||||
if (r == null) return;
|
||||
r.setDeadline(newDeadline);
|
||||
requirementsMapper.updateById(r);
|
||||
break;
|
||||
}
|
||||
default: // ignore
|
||||
}
|
||||
}
|
||||
|
||||
private String appendReason(String existing, String add) {
|
||||
if (existing == null || existing.isEmpty()) return add;
|
||||
return existing + " | " + add;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package com.ruoyi.oa.service.impl;
|
||||
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.oa.mapper.OaProjectOverviewMapper;
|
||||
import com.ruoyi.oa.service.IOaProjectOverviewService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 项目全景页聚合实现。
|
||||
* 设计原则:宁可多查几次也保持代码简单,因为这个接口请求量很低(用户主动打开项目页才触发)。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OaProjectOverviewServiceImpl implements IOaProjectOverviewService {
|
||||
|
||||
private final OaProjectOverviewMapper mapper;
|
||||
|
||||
@Override
|
||||
public Map<String, Object> overview(Long projectId) {
|
||||
if (projectId == null) throw new ServiceException("projectId 必填");
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
|
||||
// 1. 头信息 + 客户
|
||||
Map<String, Object> header = mapper.getProject(projectId);
|
||||
if (header == null) throw new ServiceException("项目不存在:" + projectId);
|
||||
result.put("header", header);
|
||||
Long cid = parseLong(header.get("customer_id"));
|
||||
if (cid != null) {
|
||||
result.put("customer", mapper.getCustomer(cid));
|
||||
}
|
||||
|
||||
// 2. 合同
|
||||
List<Map<String, Object>> contracts = mapper.listContracts(projectId);
|
||||
result.put("contracts", contracts);
|
||||
|
||||
// 3. 进度
|
||||
Map<String, Object> schedule = mapper.getSchedule(projectId);
|
||||
Map<String, Object> scheduleSection = new LinkedHashMap<>();
|
||||
if (schedule != null) {
|
||||
Long sid = parseLong(schedule.get("schedule_id"));
|
||||
if (sid != null) {
|
||||
List<Map<String, Object>> steps = mapper.listSteps(sid);
|
||||
Integer delays = mapper.countDelays(sid);
|
||||
scheduleSection.putAll(schedule);
|
||||
scheduleSection.put("steps", steps);
|
||||
scheduleSection.put("totalSteps", steps.size());
|
||||
scheduleSection.put("doneSteps", steps.stream()
|
||||
.filter(s -> isStepDone(s.get("status"))).count());
|
||||
scheduleSection.put("delayCount", delays == null ? 0 : delays);
|
||||
scheduleSection.put("overdue", isScheduleOverdue(schedule));
|
||||
}
|
||||
}
|
||||
result.put("schedule", scheduleSection);
|
||||
|
||||
// 4. 采购需求 + 到货
|
||||
Map<String, Object> reqSection = new LinkedHashMap<>();
|
||||
reqSection.put("byStatus", toCountMap(mapper.requirementsByStatus(projectId), "status"));
|
||||
reqSection.put("recent", mapper.recentRequirements(projectId));
|
||||
result.put("requirements", reqSection);
|
||||
|
||||
Map<String, Object> arrivalSection = new LinkedHashMap<>();
|
||||
arrivalSection.put("byStatus", toCountMap(mapper.arrivalsByStatus(projectId), "detail_status"));
|
||||
result.put("arrivals", arrivalSection);
|
||||
|
||||
// 5. 库房
|
||||
Map<String, Object> whSection = new LinkedHashMap<>();
|
||||
whSection.put("byStatus", toCountMap(mapper.warehouseByStatus(projectId), "status"));
|
||||
whSection.put("recent", mapper.recentWarehouseRequests(projectId));
|
||||
result.put("warehouse", whSection);
|
||||
|
||||
// 6. 财务(不假装是会计利润,只做减法)
|
||||
Map<String, Object> finSection = new LinkedHashMap<>();
|
||||
BigDecimal contractAmount = sumContractPrice(contracts);
|
||||
BigDecimal paymentDone = nz(mapper.sumPaymentDone(projectId));
|
||||
BigDecimal paymentTotal = nz(mapper.sumPaymentTotal(projectId));
|
||||
BigDecimal cost = nz(mapper.sumCost(projectId));
|
||||
finSection.put("contractAmount", contractAmount);
|
||||
finSection.put("paymentDone", paymentDone);
|
||||
finSection.put("paymentTotal", paymentTotal);
|
||||
finSection.put("paymentRemain", paymentTotal.subtract(paymentDone));
|
||||
finSection.put("cost", cost);
|
||||
finSection.put("netCash", paymentDone.subtract(cost));
|
||||
finSection.put("byFinanceType", toCountMap(mapper.financeByType(projectId), "finance_type"));
|
||||
result.put("finance", finSection);
|
||||
|
||||
// 7. 任务
|
||||
Map<String, Object> taskSection = new LinkedHashMap<>();
|
||||
taskSection.put("byState", toCountMap(mapper.tasksByState(projectId), "state"));
|
||||
Integer overdue = mapper.countOverdueTasks(projectId);
|
||||
taskSection.put("overdueCount", overdue == null ? 0 : overdue);
|
||||
taskSection.put("recent", mapper.recentTasks(projectId));
|
||||
result.put("tasks", taskSection);
|
||||
|
||||
// 8. 团队
|
||||
Map<String, Object> teamSection = new LinkedHashMap<>();
|
||||
teamSection.put("functionary", header.get("functionary"));
|
||||
teamSection.put("functionaryNick", header.get("functionary_nick"));
|
||||
List<Long> workerIds = mapper.taskWorkers(projectId);
|
||||
List<Map<String, Object>> members = workerIds == null || workerIds.isEmpty()
|
||||
? Collections.emptyList() : mapper.usersByIds(workerIds);
|
||||
teamSection.put("memberCount", members.size());
|
||||
teamSection.put("members", members);
|
||||
result.put("team", teamSection);
|
||||
|
||||
// 9. 报告 / 会议 / 操作日志
|
||||
result.put("reports", mapper.recentReports(projectId));
|
||||
result.put("meetings", mapper.recentMeetings(projectId));
|
||||
result.put("operationLog", mapper.recentOpLog(projectId));
|
||||
|
||||
// 9.5 fad_rm 制造主线
|
||||
List<Map<String, Object>> install = mapper.installProgress(projectId);
|
||||
List<Map<String, Object>> commissioning = mapper.commissioningRecords(projectId);
|
||||
List<Map<String, Object>> checklist = mapper.acceptanceChecklist(projectId);
|
||||
List<Map<String, Object>> acceptItems = mapper.acceptanceItems(projectId);
|
||||
if (!install.isEmpty() || !commissioning.isEmpty() || !checklist.isEmpty() || !acceptItems.isEmpty()) {
|
||||
Map<String, Object> mfg = new LinkedHashMap<>();
|
||||
mfg.put("install", install);
|
||||
mfg.put("commissioning", commissioning);
|
||||
mfg.put("checklist", checklist);
|
||||
mfg.put("checklistDone", checklist.stream()
|
||||
.filter(c -> "Y".equals(String.valueOf(c.get("is_checked"))) || "1".equals(String.valueOf(c.get("is_checked"))))
|
||||
.count());
|
||||
mfg.put("checklistTotal", checklist.size());
|
||||
mfg.put("acceptItems", acceptItems);
|
||||
result.put("manufacturing", mfg);
|
||||
}
|
||||
|
||||
// 10. 审批时间线(合同 + 采购需求的所有审批单合并)
|
||||
List<Long> contractIds = new ArrayList<>();
|
||||
for (Map<String, Object> c : contracts) {
|
||||
Long id = parseLong(c.get("contract_id"));
|
||||
if (id != null) contractIds.add(id);
|
||||
}
|
||||
List<Map<String, Object>> recentReqs = (List<Map<String, Object>>) reqSection.get("recent");
|
||||
List<Long> reqIds = new ArrayList<>();
|
||||
if (recentReqs != null) {
|
||||
for (Map<String, Object> r : recentReqs) {
|
||||
Long id = parseLong(r.get("requirement_id"));
|
||||
if (id != null) reqIds.add(id);
|
||||
}
|
||||
}
|
||||
List<Map<String, Object>> filters = new ArrayList<>();
|
||||
if (!contractIds.isEmpty()) {
|
||||
Map<String, Object> f = new HashMap<>();
|
||||
f.put("type", "contract");
|
||||
f.put("ids", contractIds);
|
||||
filters.add(f);
|
||||
}
|
||||
if (!reqIds.isEmpty()) {
|
||||
Map<String, Object> f = new HashMap<>();
|
||||
f.put("type", "purchase_req");
|
||||
f.put("ids", reqIds);
|
||||
filters.add(f);
|
||||
}
|
||||
List<Map<String, Object>> timeline = filters.isEmpty()
|
||||
? Collections.emptyList() : mapper.approvalTimeline(filters);
|
||||
result.put("approvals", timeline);
|
||||
|
||||
// 11. 健康度评分 + 一句话总结(傻子领导友好)
|
||||
result.put("health", computeHealth(result));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> dashboard(String status, String nameLike, Integer limit) {
|
||||
Map<String, Object> r = new LinkedHashMap<>();
|
||||
r.put("summary", mapper.dashboardSummary());
|
||||
List<Map<String, Object>> projects = mapper.dashboardProjects(status, nameLike,
|
||||
limit == null ? 50 : Math.min(200, limit));
|
||||
// 每个项目算个轻量级健康度
|
||||
for (Map<String, Object> p : projects) {
|
||||
p.put("health", computeDashboardRowHealth(p));
|
||||
}
|
||||
r.put("projects", projects);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** 全景页健康度:根据多维度信号聚合 */
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> computeHealth(Map<String, Object> data) {
|
||||
Map<String, Object> h = new LinkedHashMap<>();
|
||||
List<String> red = new ArrayList<>();
|
||||
List<String> yellow = new ArrayList<>();
|
||||
|
||||
Map<String, Object> header = (Map<String, Object>) data.get("header");
|
||||
Map<String, Object> schedule = (Map<String, Object>) data.get("schedule");
|
||||
Map<String, Object> finance = (Map<String, Object>) data.get("finance");
|
||||
Map<String, Object> tasks = (Map<String, Object>) data.get("tasks");
|
||||
List<Map<String, Object>> approvals = (List<Map<String, Object>>) data.get("approvals");
|
||||
|
||||
// 红灯:工期超期且未完结
|
||||
if (header != null) {
|
||||
Object end = header.get("finish_time");
|
||||
Object pstatus = header.get("project_status");
|
||||
if (end instanceof Date && !"1".equals(String.valueOf(pstatus))
|
||||
&& ((Date) end).before(new Date())) {
|
||||
red.add("项目已过工期但未标记完结");
|
||||
}
|
||||
Object postpone = header.get("postpone_count");
|
||||
if (postpone instanceof Number && ((Number) postpone).intValue() >= 2) {
|
||||
yellow.add("项目已延期 " + postpone + " 次");
|
||||
}
|
||||
}
|
||||
// 进度超期
|
||||
if (schedule != null && Boolean.TRUE.equals(schedule.get("overdue"))) {
|
||||
red.add("进度计划超期");
|
||||
}
|
||||
// 任务超期
|
||||
if (tasks != null) {
|
||||
Object overdueCount = tasks.get("overdueCount");
|
||||
if (overdueCount instanceof Number) {
|
||||
int n = ((Number) overdueCount).intValue();
|
||||
if (n >= 5) red.add(n + " 个任务超期");
|
||||
else if (n >= 1) yellow.add(n + " 个任务超期");
|
||||
}
|
||||
}
|
||||
// 待审批堆积
|
||||
int pendingApprovals = 0;
|
||||
if (approvals != null) {
|
||||
for (Map<String, Object> a : approvals) {
|
||||
Object s = a.get("status");
|
||||
if (s instanceof Number && ((Number) s).intValue() == 0) pendingApprovals++;
|
||||
}
|
||||
if (pendingApprovals >= 5) red.add(pendingApprovals + " 张审批单待办堆积");
|
||||
else if (pendingApprovals >= 1) yellow.add(pendingApprovals + " 张审批单待办");
|
||||
}
|
||||
// 净现金为负
|
||||
if (finance != null) {
|
||||
BigDecimal net = nzBd(finance.get("netCash"));
|
||||
BigDecimal cost = nzBd(finance.get("cost"));
|
||||
if (net.signum() < 0 && cost.signum() > 0) {
|
||||
yellow.add("当前已支出多于已收款 ¥" + net.abs().toPlainString());
|
||||
}
|
||||
}
|
||||
|
||||
String level;
|
||||
String oneLiner;
|
||||
if (!red.isEmpty()) {
|
||||
level = "red";
|
||||
oneLiner = String.join(";", red);
|
||||
} else if (!yellow.isEmpty()) {
|
||||
level = "yellow";
|
||||
oneLiner = String.join(";", yellow);
|
||||
} else {
|
||||
level = "green";
|
||||
oneLiner = "";
|
||||
}
|
||||
h.put("level", level);
|
||||
h.put("redSignals", red);
|
||||
h.put("yellowSignals", yellow);
|
||||
h.put("oneLiner", oneLiner);
|
||||
return h;
|
||||
}
|
||||
|
||||
/** 集团驾驶舱单行的轻量健康度:只看几个核心信号 */
|
||||
private Map<String, Object> computeDashboardRowHealth(Map<String, Object> p) {
|
||||
Map<String, Object> h = new LinkedHashMap<>();
|
||||
List<String> reasons = new ArrayList<>();
|
||||
String level = "green";
|
||||
Object end = p.get("finish_time");
|
||||
Object pstatus = p.get("project_status");
|
||||
boolean running = !"1".equals(String.valueOf(pstatus));
|
||||
if (running && end instanceof Date && ((Date) end).before(new Date())) {
|
||||
level = "red";
|
||||
reasons.add("已过工期");
|
||||
}
|
||||
Object overdueTasks = p.get("overdue_tasks");
|
||||
if (overdueTasks instanceof Number && ((Number) overdueTasks).intValue() >= 5) {
|
||||
if (!"red".equals(level)) level = "red";
|
||||
reasons.add(overdueTasks + " 个任务超期");
|
||||
} else if (overdueTasks instanceof Number && ((Number) overdueTasks).intValue() >= 1) {
|
||||
if ("green".equals(level)) level = "yellow";
|
||||
reasons.add(overdueTasks + " 个任务超期");
|
||||
}
|
||||
Object pendingApprovals = p.get("pending_approvals");
|
||||
if (pendingApprovals instanceof Number && ((Number) pendingApprovals).intValue() >= 5) {
|
||||
if (!"red".equals(level)) level = "red";
|
||||
reasons.add(pendingApprovals + " 张审批堆积");
|
||||
} else if (pendingApprovals instanceof Number && ((Number) pendingApprovals).intValue() >= 1) {
|
||||
if ("green".equals(level)) level = "yellow";
|
||||
reasons.add(pendingApprovals + " 张审批待办");
|
||||
}
|
||||
BigDecimal paymentDone = nzBd(p.get("payment_done"));
|
||||
BigDecimal cost = nzBd(p.get("cost"));
|
||||
if (cost.signum() > 0 && paymentDone.subtract(cost).signum() < 0) {
|
||||
if ("green".equals(level)) level = "yellow";
|
||||
reasons.add("成本>收款");
|
||||
}
|
||||
h.put("level", level);
|
||||
h.put("reasons", reasons);
|
||||
return h;
|
||||
}
|
||||
|
||||
/** 把 Object 容错解析成 Long(数字 / 字符串都能) */
|
||||
private static Long parseLong(Object o) {
|
||||
if (o == null) return null;
|
||||
if (o instanceof Number) return ((Number) o).longValue();
|
||||
try { return Long.parseLong(o.toString().trim()); } catch (Exception e) { return null; }
|
||||
}
|
||||
|
||||
private static BigDecimal nzBd(Object o) {
|
||||
if (o == null) return BigDecimal.ZERO;
|
||||
if (o instanceof BigDecimal) return (BigDecimal) o;
|
||||
if (o instanceof Number) return BigDecimal.valueOf(((Number) o).doubleValue());
|
||||
try { return new BigDecimal(o.toString()); } catch (Exception e) { return BigDecimal.ZERO; }
|
||||
}
|
||||
|
||||
// ============ 辅助 ============
|
||||
|
||||
private static BigDecimal nz(BigDecimal v) {
|
||||
return v == null ? BigDecimal.ZERO : v;
|
||||
}
|
||||
|
||||
private static Map<Object, Object> toCountMap(List<Map<String, Object>> rows, String keyCol) {
|
||||
Map<Object, Object> r = new LinkedHashMap<>();
|
||||
if (rows == null) return r;
|
||||
for (Map<String, Object> row : rows) {
|
||||
r.put(row.get(keyCol), row.get("c"));
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
private static BigDecimal sumContractPrice(List<Map<String, Object>> contracts) {
|
||||
if (contracts == null || contracts.isEmpty()) return BigDecimal.ZERO;
|
||||
BigDecimal sum = BigDecimal.ZERO;
|
||||
for (Map<String, Object> c : contracts) {
|
||||
Object price = c.get("contract_price");
|
||||
if (price == null) continue;
|
||||
try {
|
||||
sum = sum.add(new BigDecimal(price.toString()));
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
private static boolean isStepDone(Object status) {
|
||||
if (!(status instanceof Number)) return false;
|
||||
int s = ((Number) status).intValue();
|
||||
// 约定:1 完成 / 2 完结(具体跟 oa_project_schedule_step 业务保持一致)
|
||||
return s == 1 || s == 2;
|
||||
}
|
||||
|
||||
private static boolean isScheduleOverdue(Map<String, Object> schedule) {
|
||||
Object end = schedule.get("end_time");
|
||||
if (!(end instanceof Date)) return false;
|
||||
Object st = schedule.get("status");
|
||||
// 已完结的不算超期
|
||||
if (st instanceof Number && ((Number) st).intValue() == 2) return false;
|
||||
return ((Date) end).before(new Date());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.ruoyi.oa.task;
|
||||
|
||||
import com.ruoyi.oa.im.ImSendService;
|
||||
import com.ruoyi.oa.mapper.OaPostponeRecordMapper;
|
||||
import com.ruoyi.oa.service.IOaPerformanceService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 每天扫一次所有"我名下超期"事项:
|
||||
* - 超期 ≥ 7 天 且 < 30 天 → 每天给 owner 发 IM 一次(提醒)
|
||||
* - 超期 ≥ 30 天 → 给 owner 发 IM "已通知高总";给高伟发 IM 一次;扣 owner 20 分(dedup_key 唯一,一次性)
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OverdueScanScheduler {
|
||||
|
||||
// 高伟(老总)
|
||||
private static final Long BOSS_USER_ID = 1859252208375152641L;
|
||||
|
||||
private final OaPostponeRecordMapper postponeMapper;
|
||||
private final ImSendService imSendService;
|
||||
private final IOaPerformanceService performanceService;
|
||||
|
||||
/** 每天上午 9:00 跑一次 */
|
||||
@Scheduled(cron = "0 0 9 * * ?")
|
||||
public void run() {
|
||||
log.info("[OverdueScan] start");
|
||||
int reminded = 0, escalated = 0, deducted = 0;
|
||||
Set<String> bossSentDedup = new HashSet<>(); // 避免单天多次提同一事项给老板
|
||||
|
||||
// 复用 mapper.listOverdueForUser 拿不到全员,单独写一个全员 SQL
|
||||
// 直接在这里用现成 mapper 各类型的"任意人"超期查询
|
||||
List<Map<String, Object>> all = scanAll();
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
for (Map<String, Object> item : all) {
|
||||
Object ownerObj = item.get("owner_id");
|
||||
Object owner2Obj = item.get("owner_id2");
|
||||
String type = String.valueOf(item.get("business_type"));
|
||||
Object idObj = item.get("business_id");
|
||||
String title = String.valueOf(item.get("business_title"));
|
||||
Object deadlineObj = item.get("deadline");
|
||||
if (!(deadlineObj instanceof Date)) continue;
|
||||
if (!(idObj instanceof Number)) continue;
|
||||
long bizId = ((Number) idObj).longValue();
|
||||
long deadline = ((Date) deadlineObj).getTime();
|
||||
long days = Math.max(1, (now - deadline) / 86400000L);
|
||||
|
||||
// owner 列表(去重)
|
||||
Set<Long> owners = new HashSet<>();
|
||||
if (ownerObj instanceof Number) owners.add(((Number) ownerObj).longValue());
|
||||
if (owner2Obj instanceof Number) owners.add(((Number) owner2Obj).longValue());
|
||||
if (owners.isEmpty()) continue;
|
||||
|
||||
String label = typeLabel(type);
|
||||
for (Long owner : owners) {
|
||||
if (days >= 30) {
|
||||
// ① 给员工:你的事已超期 N 天,已通知高总
|
||||
imSendService.sendToOaUser(owner,
|
||||
"你有事项超期 " + days + " 天",
|
||||
"【" + label + "】" + safe(title) + " 已超期 " + days + " 天,已通知高总。请尽快处理。",
|
||||
type, bizId, frontRoute(type));
|
||||
// ② 给高总:通报
|
||||
String bossKey = type + ":" + bizId;
|
||||
if (bossSentDedup.add(bossKey)) {
|
||||
imSendService.sendToOaUser(BOSS_USER_ID,
|
||||
"[超期通报] " + label + " 超期 " + days + " 天",
|
||||
"员工 #" + owner + " 名下的【" + label + "】" + safe(title)
|
||||
+ " 已超期 " + days + " 天,请尽快联系完成。",
|
||||
type, bizId, frontRoute(type));
|
||||
escalated++;
|
||||
}
|
||||
// ③ 一次性扣 20 分,dedup_key 同业务只扣一次(不叠加)
|
||||
boolean ok = performanceService.addDeduction(owner, null,
|
||||
"overdue30", type, bizId,
|
||||
20,
|
||||
"事项超期 ≥ 30 天:" + safe(title),
|
||||
"overdue30_" + type + "_" + bizId);
|
||||
if (ok) deducted++;
|
||||
} else if (days >= 7) {
|
||||
// 仅每日提醒
|
||||
imSendService.sendToOaUser(owner,
|
||||
"事项超期提醒",
|
||||
"【" + label + "】" + safe(title) + " 已超期 " + days + " 天,请尽快处理。"
|
||||
+ " 注:超过 30 天会自动通知高总并扣 20 分。",
|
||||
type, bizId, frontRoute(type));
|
||||
reminded++;
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info("[OverdueScan] done: reminded={} escalated={} deducted20={}", reminded, escalated, deducted);
|
||||
}
|
||||
|
||||
/** 复用 OaPostponeRecordMapper 的逻辑但去掉 owner 过滤 —— 直接执行一次模拟全量扫描 */
|
||||
private List<Map<String, Object>> scanAll() {
|
||||
return postponeMapper.scanAllOverdue();
|
||||
}
|
||||
|
||||
private String typeLabel(String t) {
|
||||
switch (t) {
|
||||
case "task": return "任务";
|
||||
case "step": return "进度步骤";
|
||||
case "requirement": return "采购需求";
|
||||
default: return t;
|
||||
}
|
||||
}
|
||||
|
||||
private String frontRoute(String t) {
|
||||
switch (t) {
|
||||
case "task": return "/task/task";
|
||||
case "step": return "/step/step";
|
||||
case "requirement": return "/hint/requirement";
|
||||
default: return "/";
|
||||
}
|
||||
}
|
||||
|
||||
private String safe(String s) {
|
||||
if (s == null || "null".equals(s)) return "";
|
||||
return s.length() > 40 ? s.substring(0, 40) + "…" : s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package com.ruoyi.oa.task;
|
||||
|
||||
import com.ruoyi.oa.service.IOaPerformanceService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 自动加分(鼓励正向行为):
|
||||
* - 完成任务 +1/条 上限 +10/月
|
||||
* - 完成步骤 +1/步 上限 +6/月
|
||||
* - 报工日报 +1/份 上限 +6/月
|
||||
* - 出差登记 +1/天 上限 +6/月
|
||||
* - 月度全勤 +3 上限 +3/月(月初算上月)
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PerformanceRewardScheduler {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
private final IOaPerformanceService perfService;
|
||||
|
||||
/** 每天 9:30 扫昨天的事(加分 + 逾期日扣) */
|
||||
@Scheduled(cron = "0 30 9 * * ?")
|
||||
public void runDaily() {
|
||||
Date yesterday = yesterday();
|
||||
String dateStr = new SimpleDateFormat("yyyy-MM-dd").format(yesterday);
|
||||
log.info("[Reward] daily scan for {}", dateStr);
|
||||
|
||||
// 正向加分(看昨天完成 / 提交的)
|
||||
scanTaskComplete(dateStr);
|
||||
scanStepComplete(dateStr);
|
||||
scanReport(dateStr);
|
||||
scanTrip(dateStr);
|
||||
|
||||
// 逾期日扣(基于"今天"判定还在逾期的)
|
||||
String todayStr = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
|
||||
scanOverdueDaily(todayStr);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当天 21:00 检测今天有没有报工。没报工的扣 1 分。
|
||||
* 粘性:dedup_key 含日期,事后补录不退分。
|
||||
*/
|
||||
@Scheduled(cron = "0 0 21 * * ?")
|
||||
public void runEveningReportCheck() {
|
||||
Date today = new Date();
|
||||
String dateStr = new SimpleDateFormat("yyyy-MM-dd").format(today);
|
||||
log.info("[Reward] 21:00 report check for {}", dateStr);
|
||||
scanNoReport(dateStr, today);
|
||||
}
|
||||
|
||||
/** 每月 1 号 8 点算上个月全勤 */
|
||||
@Scheduled(cron = "0 0 8 1 * ?")
|
||||
public void runMonthlyAttendance() {
|
||||
Calendar c = Calendar.getInstance();
|
||||
c.add(Calendar.MONTH, -1);
|
||||
String prevPeriod = new SimpleDateFormat("yyyy-MM").format(c.getTime());
|
||||
log.info("[Reward] monthly attendance for {}", prevPeriod);
|
||||
scanAttendance(prevPeriod, c);
|
||||
}
|
||||
|
||||
private void scanTaskComplete(String dateStr) {
|
||||
List<Map<String, Object>> rows = jdbc.queryForList(
|
||||
"SELECT task_id, worker_id, task_title FROM sys_oa_task " +
|
||||
"WHERE state = 2 AND completed_time IS NOT NULL " +
|
||||
"AND DATE(completed_time) = ? AND worker_id IS NOT NULL",
|
||||
dateStr);
|
||||
for (Map<String, Object> r : rows) {
|
||||
Long uid = num(r.get("worker_id"));
|
||||
Long tid = num(r.get("task_id"));
|
||||
String title = String.valueOf(r.get("task_title"));
|
||||
perfService.tryReward(uid, null, "reward_task", "task", tid,
|
||||
1, "完成任务:" + safe(title),
|
||||
"reward_task_" + tid, 10);
|
||||
}
|
||||
}
|
||||
|
||||
private void scanStepComplete(String dateStr) {
|
||||
List<Map<String, Object>> rows = jdbc.queryForList(
|
||||
"SELECT s.track_id, s.step_name, " +
|
||||
" COALESCE((SELECT user_id FROM sys_user WHERE nick_name = sch.steward AND status='0' LIMIT 1), " +
|
||||
" (SELECT user_id FROM sys_user WHERE nick_name = p.functionary AND status='0' LIMIT 1)) AS owner_id " +
|
||||
"FROM oa_project_schedule_step s " +
|
||||
"JOIN oa_project_schedule sch ON sch.schedule_id = s.schedule_id " +
|
||||
"LEFT JOIN sys_oa_project p ON p.project_id = sch.project_id " +
|
||||
"WHERE s.status = 2 AND s.del_flag = '0' " +
|
||||
"AND DATE(COALESCE(s.actual_end, s.update_time)) = ?",
|
||||
dateStr);
|
||||
for (Map<String, Object> r : rows) {
|
||||
Long uid = num(r.get("owner_id"));
|
||||
Long sid = num(r.get("track_id"));
|
||||
String name = String.valueOf(r.get("step_name"));
|
||||
if (uid == null || sid == null) continue;
|
||||
perfService.tryReward(uid, null, "reward_step", "step", sid,
|
||||
1, "完成步骤:" + safe(name),
|
||||
"reward_step_" + sid, 6);
|
||||
}
|
||||
}
|
||||
|
||||
private void scanReport(String dateStr) {
|
||||
List<Map<String, Object>> rows = jdbc.queryForList(
|
||||
"SELECT report_id, user_id, content FROM oa_project_report " +
|
||||
"WHERE del_flag = 0 AND DATE(create_time) = ?",
|
||||
dateStr);
|
||||
for (Map<String, Object> r : rows) {
|
||||
Long uid = num(r.get("user_id"));
|
||||
Long rid = num(r.get("report_id"));
|
||||
if (uid == null || rid == null) continue;
|
||||
perfService.tryReward(uid, null, "reward_report", null, rid,
|
||||
1, "提交工作日报",
|
||||
"reward_report_" + rid, 6);
|
||||
}
|
||||
}
|
||||
|
||||
private void scanTrip(String dateStr) {
|
||||
// 出差:oa_project_report.is_trip > 0
|
||||
// 同一人同一天最多算一次
|
||||
List<Map<String, Object>> rows = jdbc.queryForList(
|
||||
"SELECT DISTINCT user_id FROM oa_project_report " +
|
||||
"WHERE del_flag = 0 AND is_trip > 0 AND DATE(create_time) = ?",
|
||||
dateStr);
|
||||
for (Map<String, Object> r : rows) {
|
||||
Long uid = num(r.get("user_id"));
|
||||
if (uid == null) continue;
|
||||
perfService.tryReward(uid, null, "reward_trip", null, null,
|
||||
1, "当天出差 " + dateStr,
|
||||
"reward_trip_" + uid + "_" + dateStr, 6);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「没报工」扣分:
|
||||
* - 仅工作日触发(周一~周五)
|
||||
* - 只扣"近 30 天提交过报工"的活跃用户(避免误伤不需要报工的人)
|
||||
* - dedup_key 含日期,补录不能退回
|
||||
*/
|
||||
private void scanNoReport(String dateStr, Date date) {
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.setTime(date);
|
||||
int dow = cal.get(Calendar.DAY_OF_WEEK);
|
||||
if (dow == Calendar.SATURDAY || dow == Calendar.SUNDAY) {
|
||||
log.info("[Reward] skip noreport scan on weekend {}", dateStr);
|
||||
return;
|
||||
}
|
||||
// 近 30 天交过报工 = 活跃用户
|
||||
List<Map<String, Object>> active = jdbc.queryForList(
|
||||
"SELECT DISTINCT user_id FROM oa_project_report " +
|
||||
"WHERE del_flag = 0 AND create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)");
|
||||
for (Map<String, Object> a : active) {
|
||||
Long uid = num(a.get("user_id"));
|
||||
if (uid == null) continue;
|
||||
// 当天有没有提交过
|
||||
Integer cnt = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM oa_project_report " +
|
||||
"WHERE del_flag = 0 AND user_id = ? AND DATE(create_time) = ?",
|
||||
Integer.class, uid, dateStr);
|
||||
if (cnt != null && cnt > 0) continue;
|
||||
// 扣 1 分,dedup_key 唯一(补录不退)
|
||||
perfService.addDeduction(uid, null,
|
||||
"noreport", null, null,
|
||||
1, "未提交 " + dateStr + " 工作日报",
|
||||
"noreport_" + uid + "_" + dateStr);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每日逾期扣分:扫当前仍处于逾期未解决状态的事项;
|
||||
* 每个事项每天扣 owner -1,dedup_key=overdue_daily_{type}_{id}_{today} 同业务同日唯一。
|
||||
* 无月度封顶。
|
||||
*/
|
||||
private void scanOverdueDaily(String todayDateStr) {
|
||||
List<Map<String, Object>> rows = jdbc.queryForList(
|
||||
"SELECT * FROM ( " +
|
||||
" SELECT 'task' AS biz, t.task_id AS bid, t.task_title AS title, " +
|
||||
" t.create_user_id AS owner1, t.worker_id AS owner2 " +
|
||||
" FROM sys_oa_task t WHERE (t.del_flag=0 OR t.del_flag IS NULL) " +
|
||||
" AND t.state IN (0,15) AND t.finish_time IS NOT NULL AND t.finish_time < NOW() " +
|
||||
" UNION ALL " +
|
||||
" SELECT 'step', s.track_id, s.step_name, " +
|
||||
" COALESCE( " +
|
||||
" (SELECT user_id FROM sys_user WHERE nick_name=sch.steward AND status='0' LIMIT 1), " +
|
||||
" (SELECT user_id FROM sys_user WHERE nick_name=p.functionary AND status='0' LIMIT 1) " +
|
||||
" ), NULL " +
|
||||
" FROM oa_project_schedule_step s " +
|
||||
" JOIN oa_project_schedule sch ON sch.schedule_id=s.schedule_id " +
|
||||
" LEFT JOIN sys_oa_project p ON p.project_id=sch.project_id " +
|
||||
" WHERE s.del_flag='0' AND (s.status IS NULL OR s.status != 2) " +
|
||||
" AND COALESCE(s.end_time, s.plan_end) IS NOT NULL " +
|
||||
" AND COALESCE(s.end_time, s.plan_end) < NOW() " +
|
||||
" UNION ALL " +
|
||||
" SELECT 'requirement', r.requirement_id, r.title, " +
|
||||
" (SELECT user_id FROM sys_user WHERE user_name=r.create_by AND status='0' LIMIT 1), " +
|
||||
" r.owner_id " +
|
||||
" FROM oa_requirements r WHERE r.del_flag=0 AND r.status IN (0,1) AND r.deadline < NOW() " +
|
||||
") x " +
|
||||
// 排除已经在等顺延审批 或 已经顺延还未到期 的
|
||||
"WHERE NOT EXISTS (SELECT 1 FROM oa_postpone_record p WHERE p.business_type=x.biz AND p.business_id=x.bid AND p.del_flag=0 AND p.status=0) " +
|
||||
" AND NOT EXISTS (SELECT 1 FROM oa_postpone_record p2 WHERE p2.business_type=x.biz AND p2.business_id=x.bid AND p2.del_flag=0 AND p2.status=1 AND p2.new_deadline > NOW())");
|
||||
for (Map<String, Object> r : rows) {
|
||||
String biz = String.valueOf(r.get("biz"));
|
||||
Long bid = num(r.get("bid"));
|
||||
String title = String.valueOf(r.get("title"));
|
||||
java.util.Set<Long> owners = new java.util.HashSet<>();
|
||||
Long o1 = num(r.get("owner1"));
|
||||
Long o2 = num(r.get("owner2"));
|
||||
if (o1 != null) owners.add(o1);
|
||||
if (o2 != null) owners.add(o2);
|
||||
if (bid == null) continue;
|
||||
for (Long owner : owners) {
|
||||
perfService.addDeduction(owner, null,
|
||||
"overdue_daily", biz, bid,
|
||||
1, "[" + label(biz) + "] " + safe(title) + " 当日仍逾期",
|
||||
"overdue_daily_" + biz + "_" + bid + "_" + todayDateStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String label(String t) {
|
||||
switch (t) {
|
||||
case "task": return "任务";
|
||||
case "step": return "进度步骤";
|
||||
case "requirement": return "采购需求";
|
||||
default: return t;
|
||||
}
|
||||
}
|
||||
|
||||
private void scanAttendance(String period, Calendar monthCal) {
|
||||
// 简化判定:当月 sys_oa_attendance 记录天数 >= 18 即视为全勤
|
||||
int year = monthCal.get(Calendar.YEAR);
|
||||
int month = monthCal.get(Calendar.MONTH) + 1;
|
||||
Integer min = year * 10000 + month * 100;
|
||||
Integer max = year * 10000 + month * 100 + 31;
|
||||
List<Map<String, Object>> rows = jdbc.queryForList(
|
||||
"SELECT user_id, COUNT(DISTINCT attendance_day) AS days FROM sys_oa_attendance " +
|
||||
"WHERE del_flag = 0 AND attendance_day BETWEEN ? AND ? GROUP BY user_id",
|
||||
min, max);
|
||||
for (Map<String, Object> r : rows) {
|
||||
Long uid = num(r.get("user_id"));
|
||||
int days = ((Number) r.get("days")).intValue();
|
||||
if (uid == null || days < 18) continue;
|
||||
perfService.tryReward(uid, null, "reward_attend", null, null,
|
||||
3, "月度全勤(出勤 " + days + " 天)",
|
||||
"reward_attend_" + uid + "_" + period, 3);
|
||||
}
|
||||
}
|
||||
|
||||
private static Long num(Object o) {
|
||||
if (o == null) return null;
|
||||
if (o instanceof Number) return ((Number) o).longValue();
|
||||
try { return Long.parseLong(o.toString()); } catch (Exception e) { return null; }
|
||||
}
|
||||
|
||||
private static Date yesterday() {
|
||||
Calendar c = Calendar.getInstance();
|
||||
c.add(Calendar.DATE, -1);
|
||||
c.set(Calendar.HOUR_OF_DAY, 0); c.set(Calendar.MINUTE, 0);
|
||||
c.set(Calendar.SECOND, 0); c.set(Calendar.MILLISECOND, 0);
|
||||
return c.getTime();
|
||||
}
|
||||
|
||||
private static String safe(String s) {
|
||||
if (s == null || "null".equals(s)) return "";
|
||||
return s.length() > 40 ? s.substring(0, 40) + "…" : s;
|
||||
}
|
||||
}
|
||||
25
ruoyi-ui/src/api/oa/performance.js
Normal file
25
ruoyi-ui/src/api/oa/performance.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function myPerformance(period) {
|
||||
return request({ url: '/oa/performance/mine', method: 'get', params: { period } })
|
||||
}
|
||||
export function rankPerformance(query) {
|
||||
return request({ url: '/oa/performance/rank', method: 'get', params: query })
|
||||
}
|
||||
export function userPerformance(userId, period) {
|
||||
return request({ url: '/oa/performance/of/' + userId, method: 'get', params: { period } })
|
||||
}
|
||||
// 高总(或管理员)打主观分 0~40
|
||||
export function setSubjective(userId, userName, period, score) {
|
||||
return request({
|
||||
url: '/oa/performance/subjective', method: 'post',
|
||||
params: { userId, userName, period, score }
|
||||
})
|
||||
}
|
||||
// 手动加/扣分:points 正=扣 负=加
|
||||
export function manualAdjust(userId, userName, points, reason) {
|
||||
return request({
|
||||
url: '/oa/performance/manual', method: 'post',
|
||||
params: { userId, userName, points, reason }
|
||||
})
|
||||
}
|
||||
24
ruoyi-ui/src/api/oa/postpone.js
Normal file
24
ruoyi-ui/src/api/oa/postpone.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 我名下所有未处理的超期
|
||||
export function listMyOverdue() {
|
||||
return request({ url: '/oa/postpone/mine', method: 'get' })
|
||||
}
|
||||
|
||||
export function postponeComplete(businessType, businessId) {
|
||||
return request({
|
||||
url: '/oa/postpone/complete', method: 'post',
|
||||
params: { businessType, businessId }
|
||||
})
|
||||
}
|
||||
|
||||
export function postponeCancel(businessType, businessId, reason) {
|
||||
return request({
|
||||
url: '/oa/postpone/cancel', method: 'post',
|
||||
params: { businessType, businessId, reason }
|
||||
})
|
||||
}
|
||||
|
||||
export function postpone(data) {
|
||||
return request({ url: '/oa/postpone/postpone', method: 'post', data })
|
||||
}
|
||||
18
ruoyi-ui/src/api/oa/projectOverview.js
Normal file
18
ruoyi-ui/src/api/oa/projectOverview.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 项目全景:一次拉完所有维度
|
||||
export function getProjectOverview(projectId) {
|
||||
return request({
|
||||
url: '/oa/project/overview/' + projectId,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 集团驾驶舱:全公司项目一览 + 全局汇总
|
||||
export function getProjectDashboardOverview(query) {
|
||||
return request({
|
||||
url: '/oa/project/overview/dashboard',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
193
ruoyi-ui/src/components/PerfWidget/index.vue
Normal file
193
ruoyi-ui/src/components/PerfWidget/index.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="perf-w" v-loading="loading" @click="goDetail">
|
||||
<div v-if="score" class="card" :class="lvClass">
|
||||
<!-- 顶部:等级 + 分数 -->
|
||||
<div class="row1">
|
||||
<div class="grade">{{ score.grade || '-' }}</div>
|
||||
<div class="right">
|
||||
<div class="score">
|
||||
<span class="num">{{ score.totalScore || 0 }}</span>
|
||||
<span class="div">/100</span>
|
||||
</div>
|
||||
<div class="period">{{ score.period }} 月度绩效</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间:渐变进度条 -->
|
||||
<div class="track">
|
||||
<div class="track-fill" :style="{ width: pct(score.totalScore, 100) }"></div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:mini 指标 -->
|
||||
<div class="row2">
|
||||
<div class="pill">
|
||||
<span class="lbl">基础</span>
|
||||
<span class="val">{{ score.baseScore || 80 }}</span>
|
||||
</div>
|
||||
<div class="pill" v-if="(score.reward || 0) > 0">
|
||||
<span class="lbl">加</span>
|
||||
<span class="val ok">+{{ score.reward }}</span>
|
||||
</div>
|
||||
<div class="pill" v-if="(score.deduction || 0) > 0">
|
||||
<span class="lbl">扣</span>
|
||||
<span class="val bad">-{{ score.deduction }}</span>
|
||||
</div>
|
||||
<div class="pill">
|
||||
<span class="lbl">主观</span>
|
||||
<span class="val">{{ score.bonus || 0 }}</span>
|
||||
<span v-if="!isSet" class="tip">·待打</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow"><i class="el-icon-arrow-right"></i></div>
|
||||
</div>
|
||||
<div v-else-if="!loading" class="empty">暂无绩效数据</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { myPerformance } from '@/api/oa/performance'
|
||||
|
||||
export default {
|
||||
name: 'PerfWidget',
|
||||
data() { return { loading: false, score: null } },
|
||||
computed: {
|
||||
lvClass() {
|
||||
const g = this.score && this.score.grade
|
||||
if (!g) return ''
|
||||
if (g.startsWith('A')) return 'lv-a'
|
||||
if (g.startsWith('B')) return 'lv-b'
|
||||
if (g.startsWith('C')) return 'lv-c'
|
||||
return 'lv-d'
|
||||
},
|
||||
isSet() {
|
||||
return this.score && (this.score.subjectiveSet === 1 || this.score.subjective_set === 1)
|
||||
}
|
||||
},
|
||||
created() { this.load() },
|
||||
methods: {
|
||||
load() {
|
||||
this.loading = true
|
||||
myPerformance().then(res => {
|
||||
this.score = (res.data && res.data.score) || null
|
||||
}).finally(() => { this.loading = false })
|
||||
},
|
||||
pct(v, max) {
|
||||
if (!max) return '0%'
|
||||
return Math.max(0, Math.min(100, (v / max) * 100)) + '%'
|
||||
},
|
||||
goDetail() {
|
||||
// 从用户路由树里找到"我的绩效"组件对应的实际路径,避免菜单被挪窝后写死失效
|
||||
const target = 'oa/performance/mine/index'
|
||||
const routes = this.$store.getters && this.$store.getters.permission_routes || this.$router.options.routes || []
|
||||
const path = this.findRoutePath(routes, target, '')
|
||||
if (path) {
|
||||
this.$router.push(path)
|
||||
} else {
|
||||
// 兜底:还是按默认 /perf/mine 尝试一下
|
||||
this.$router.push('/perf/mine').catch(() => { window.location.assign('/perf/mine') })
|
||||
}
|
||||
},
|
||||
findRoutePath(routes, target, parent) {
|
||||
for (const r of routes) {
|
||||
let p = r.path || ''
|
||||
if (p && !p.startsWith('/')) p = (parent.endsWith('/') ? parent : parent + '/') + p
|
||||
else if (p) p = p
|
||||
else p = parent
|
||||
const cmp = r.component
|
||||
const cmpStr = cmp && (cmp.name || (cmp.toString && cmp.toString().match(/['"]([^'"]*?)['"]/)?.[1])) || ''
|
||||
if (r.meta && r.meta.title === '我的绩效') return p
|
||||
if (typeof cmpStr === 'string' && cmpStr.indexOf('performance/mine') >= 0) return p
|
||||
if (r.children && r.children.length) {
|
||||
const found = this.findRoutePath(r.children, target, p)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.perf-w {
|
||||
height: 100%; padding: 6px; box-sizing: border-box; cursor: pointer;
|
||||
}
|
||||
.card {
|
||||
height: 100%; position: relative;
|
||||
background: linear-gradient(135deg, #fff 0%, #f5fbef 100%);
|
||||
border-radius: 12px; padding: 14px 16px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.04);
|
||||
transition: all .2s; overflow: hidden;
|
||||
display: flex; flex-direction: column; justify-content: space-between;
|
||||
}
|
||||
.card:hover { box-shadow: 0 6px 18px rgba(0,0,0,.08); transform: translateY(-1px); }
|
||||
.card::before {
|
||||
content: ''; position: absolute; top: 0; right: 0; width: 130px; height: 130px;
|
||||
background: radial-gradient(circle, rgba(103,194,58,0.18) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.card.lv-a { background: linear-gradient(135deg, #fff 0%, #f5fbef 100%); }
|
||||
.card.lv-b { background: linear-gradient(135deg, #fff 0%, #ecf5ff 100%); }
|
||||
.card.lv-b::before { background: radial-gradient(circle, rgba(64,158,255,0.18) 0%, transparent 70%); }
|
||||
.card.lv-c { background: linear-gradient(135deg, #fff 0%, #fdf6ec 100%); }
|
||||
.card.lv-c::before { background: radial-gradient(circle, rgba(230,162,60,0.18) 0%, transparent 70%); }
|
||||
.card.lv-d { background: linear-gradient(135deg, #fff 0%, #fef0f0 100%); }
|
||||
.card.lv-d::before { background: radial-gradient(circle, rgba(245,108,108,0.18) 0%, transparent 70%); }
|
||||
|
||||
.row1 { display: flex; align-items: center; gap: 14px; position: relative; z-index: 1; }
|
||||
.grade {
|
||||
font-size: 56px; font-weight: 800; line-height: 1;
|
||||
color: #67c23a; min-width: 70px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
.card.lv-a .grade { color: #67c23a; }
|
||||
.card.lv-b .grade { color: #409EFF; }
|
||||
.card.lv-c .grade { color: #e6a23c; }
|
||||
.card.lv-d .grade { color: #f56c6c; }
|
||||
|
||||
.right { flex: 1; }
|
||||
.score { display: flex; align-items: baseline; gap: 4px; }
|
||||
.score .num { font-size: 28px; font-weight: 700; color: #303133; line-height: 1; }
|
||||
.score .div { font-size: 13px; color: #909399; }
|
||||
.period { font-size: 11px; color: #909399; margin-top: 2px; }
|
||||
|
||||
.track {
|
||||
margin: 10px 0 8px; height: 6px; background: #f0f0f0; border-radius: 3px; overflow: hidden;
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
.track-fill {
|
||||
height: 100%; border-radius: 3px;
|
||||
background: linear-gradient(90deg, #67c23a 0%, #5daf34 100%);
|
||||
transition: width .6s ease;
|
||||
}
|
||||
.card.lv-b .track-fill { background: linear-gradient(90deg, #409EFF 0%, #337ecc 100%); }
|
||||
.card.lv-c .track-fill { background: linear-gradient(90deg, #e6a23c 0%, #cf9236 100%); }
|
||||
.card.lv-d .track-fill { background: linear-gradient(90deg, #f56c6c 0%, #d65b5b 100%); }
|
||||
|
||||
.row2 {
|
||||
display: flex; gap: 6px; flex-wrap: wrap; position: relative; z-index: 1;
|
||||
}
|
||||
.pill {
|
||||
background: rgba(255,255,255,0.7); border: 1px solid rgba(0,0,0,0.05);
|
||||
border-radius: 12px; padding: 2px 9px; font-size: 11px;
|
||||
display: flex; align-items: center; gap: 4px; color: #606266;
|
||||
}
|
||||
.pill .lbl { color: #909399; }
|
||||
.pill .val { font-weight: 600; color: #303133; font-family: Menlo, Consolas, monospace; }
|
||||
.pill .val.ok { color: #67c23a; }
|
||||
.pill .val.bad { color: #f56c6c; }
|
||||
.pill .tip { color: #c0c4cc; font-size: 10px; }
|
||||
|
||||
.arrow {
|
||||
position: absolute; bottom: 8px; right: 10px;
|
||||
color: #c0c4cc; font-size: 16px; z-index: 1;
|
||||
}
|
||||
.card:hover .arrow { color: #909399; transform: translateX(3px); transition: all .2s; }
|
||||
|
||||
.empty {
|
||||
color: #909399; font-size: 13px;
|
||||
height: 100%; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,7 @@
|
||||
import Announcements from '@/components/Announcements/index.vue'
|
||||
import MiniCalendar from '@/components/MiniCalendar/index.vue'
|
||||
import QuickEntry from '@/components/QuickEntry/index.vue'
|
||||
import PerfWidget from '@/components/PerfWidget/index.vue'
|
||||
import {
|
||||
ExpressQuestionList,
|
||||
FeedbackList,
|
||||
@@ -77,6 +78,11 @@ export const WIDGET_REGISTRY = {
|
||||
title: '快捷入口',
|
||||
component: QuickEntry,
|
||||
defaultSize: { w: 12, h: 4 }
|
||||
},
|
||||
perf: {
|
||||
title: '我的绩效',
|
||||
component: PerfWidget,
|
||||
defaultSize: { w: 4, h: 4 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
376
ruoyi-ui/src/layout/components/OverdueGuard.vue
Normal file
376
ruoyi-ui/src/layout/components/OverdueGuard.vue
Normal file
@@ -0,0 +1,376 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- ============ 强制弹窗 ============ -->
|
||||
<el-dialog
|
||||
title="超期事项处理"
|
||||
:visible.sync="visible"
|
||||
:show-close="false"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
width="780px"
|
||||
custom-class="overdue-dialog"
|
||||
append-to-body>
|
||||
|
||||
<div class="head-meta">
|
||||
你名下有 <b>{{ remainCount }}</b> 项已超期。
|
||||
<span class="hint-30">超过 30 天会通知高总并扣除 20 分绩效</span>。
|
||||
建议逐条处理;如暂无法处理可点「稍后处理」,浮窗会一直提醒。
|
||||
</div>
|
||||
|
||||
<div v-if="current" class="item-card">
|
||||
<div class="item-head">
|
||||
<el-tag :type="typeTag(current.business_type)" size="medium">{{ typeLabel(current.business_type) }}</el-tag>
|
||||
<span class="item-title">{{ current.business_title || ('#' + current.business_id) }}</span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<span>原截止:{{ fmtDateTime(current.deadline) }}</span>
|
||||
<span class="bad">已超期 {{ overdueDays(current.deadline) }} 天</span>
|
||||
<el-link type="primary" :underline="false" @click="goDetail(current)">
|
||||
查看详情 <i class="el-icon-top-right"></i>
|
||||
</el-link>
|
||||
</div>
|
||||
|
||||
<el-form :model="form" label-width="100px" class="form-area" size="small" ref="form" :rules="rules">
|
||||
<el-form-item label="处理方式">
|
||||
<el-radio-group v-model="form.action">
|
||||
<el-radio label="complete">已完成</el-radio>
|
||||
<el-radio label="postpone">顺延</el-radio>
|
||||
<el-radio label="cancel">取消</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="form.action === 'postpone'">
|
||||
<el-form-item label="新截止日期" prop="newDeadline">
|
||||
<el-date-picker v-model="form.newDeadline" type="datetime"
|
||||
value-format="yyyy-MM-dd HH:mm:ss"
|
||||
:picker-options="pickerOpts"
|
||||
style="width: 260px;" />
|
||||
</el-form-item>
|
||||
<el-form-item label="顺延理由" prop="reason">
|
||||
<el-input v-model="form.reason" type="textarea" :rows="3" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
type="warning" :closable="false" show-icon
|
||||
title="第 3 次及以后顺延需要项目负责人审批,通过后才生效。" />
|
||||
</template>
|
||||
|
||||
<template v-if="form.action === 'cancel'">
|
||||
<el-form-item label="取消理由" prop="reason">
|
||||
<el-input v-model="form.reason" type="textarea" :rows="3" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div slot="footer" class="footer">
|
||||
<span class="muted">{{ doneCount }} / {{ totalCount }} 已处理</span>
|
||||
<div>
|
||||
<el-button @click="skip">稍后处理</el-button>
|
||||
<el-button :loading="submitting" type="primary" :disabled="!form.action" @click="submit">
|
||||
提交并下一条
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ============ 关不掉的浮窗 ============ -->
|
||||
<transition name="float-slide">
|
||||
<div v-if="floatVisible" class="overdue-float">
|
||||
<div class="float-head">
|
||||
<i class="el-icon-warning"></i>
|
||||
<span>我的超期 ({{ pending.length }})</span>
|
||||
</div>
|
||||
<div class="float-list">
|
||||
<div v-for="item in pending" :key="item.business_type + '-' + item.business_id"
|
||||
class="float-item"
|
||||
:class="[urgencyClass(item.deadline), strikeMap[itemKey(item)] ? 'striking' : '']">
|
||||
<div class="float-item-title" @click="reopenFor(item)">
|
||||
<el-tag :type="typeTag(item.business_type)" size="mini">{{ typeLabel(item.business_type) }}</el-tag>
|
||||
<span class="title-text">{{ shorten(item.business_title, 22) || ('#' + item.business_id) }}</span>
|
||||
<el-tag v-if="overdueDays(item.deadline) >= 30" type="danger" size="mini" effect="dark" class="boss-tag">已通知高总</el-tag>
|
||||
</div>
|
||||
<div class="float-item-meta">
|
||||
已超期 {{ overdueDays(item.deadline) }} 天
|
||||
<el-button type="text" size="mini" class="quick-done" @click.stop="quickComplete(item)">
|
||||
<i class="el-icon-check"></i> 完成
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="float-footer">点标题打开处理窗 · 点"完成"直接标记完成</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listMyOverdue, postponeComplete, postponeCancel, postpone } from '@/api/oa/postpone'
|
||||
|
||||
export default {
|
||||
name: 'OverdueGuard',
|
||||
data() {
|
||||
return {
|
||||
visible: false,
|
||||
floatVisible: false,
|
||||
pending: [],
|
||||
totalCount: 0,
|
||||
doneCount: 0,
|
||||
submitting: false,
|
||||
strikeMap: {}, // 正在划掉动画的项 key
|
||||
form: { action: '', newDeadline: '', reason: '' },
|
||||
rules: {
|
||||
newDeadline: [{ required: true, message: '请选择新截止日期', trigger: 'change' }],
|
||||
reason: [{ required: true, message: '请填写理由', trigger: 'blur' }]
|
||||
},
|
||||
pickerOpts: { disabledDate: (d) => d.getTime() < Date.now() - 86400000 }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
current() { return this.pending[0] || null },
|
||||
remainCount() { return this.pending.length }
|
||||
},
|
||||
mounted() {
|
||||
this.boot()
|
||||
},
|
||||
methods: {
|
||||
async boot() {
|
||||
try {
|
||||
const res = await listMyOverdue()
|
||||
const list = res.data || []
|
||||
if (!list.length) return
|
||||
this.pending = list
|
||||
this.totalCount = list.length
|
||||
this.doneCount = 0
|
||||
this.resetForm()
|
||||
this.visible = true
|
||||
this.floatVisible = false
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
resetForm() {
|
||||
this.form = { action: '', newDeadline: '', reason: '' }
|
||||
},
|
||||
skip() {
|
||||
this.visible = false
|
||||
this.floatVisible = true
|
||||
},
|
||||
reopenFor(item) {
|
||||
// 把点击的事项挪到队首,然后打开弹窗
|
||||
const idx = this.pending.indexOf(item)
|
||||
if (idx > 0) {
|
||||
this.pending.splice(idx, 1)
|
||||
this.pending.unshift(item)
|
||||
}
|
||||
this.resetForm()
|
||||
this.visible = true
|
||||
this.floatVisible = false
|
||||
},
|
||||
async submit() {
|
||||
if (!this.current) return
|
||||
const cur = this.current
|
||||
this.submitting = true
|
||||
try {
|
||||
if (this.form.action === 'complete') {
|
||||
await postponeComplete(cur.business_type, cur.business_id)
|
||||
this.$modal.msgSuccess('已标记完成')
|
||||
} else if (this.form.action === 'cancel') {
|
||||
if (!this.form.reason || !this.form.reason.trim()) {
|
||||
this.$modal.msgWarning('请填写取消理由')
|
||||
this.submitting = false
|
||||
return
|
||||
}
|
||||
await postponeCancel(cur.business_type, cur.business_id, this.form.reason.trim())
|
||||
this.$modal.msgSuccess('已取消')
|
||||
} else if (this.form.action === 'postpone') {
|
||||
if (!this.form.newDeadline) {
|
||||
this.$modal.msgWarning('请选择新截止日期')
|
||||
this.submitting = false
|
||||
return
|
||||
}
|
||||
if (!this.form.reason || !this.form.reason.trim()) {
|
||||
this.$modal.msgWarning('请填写顺延理由')
|
||||
this.submitting = false
|
||||
return
|
||||
}
|
||||
const res = await postpone({
|
||||
businessType: cur.business_type,
|
||||
businessId: cur.business_id,
|
||||
businessTitle: cur.business_title,
|
||||
newDeadline: this.form.newDeadline,
|
||||
reason: this.form.reason.trim()
|
||||
})
|
||||
const r = res.data || {}
|
||||
if (r.needApproval) {
|
||||
this.$modal.msgWarning('第 ' + r.postponeSeq + ' 次顺延,已提交项目负责人审批,通过后生效')
|
||||
} else {
|
||||
this.$modal.msgSuccess('已顺延(第 ' + r.postponeSeq + ' 次)')
|
||||
}
|
||||
} else {
|
||||
this.submitting = false
|
||||
return
|
||||
}
|
||||
this.pending.shift()
|
||||
this.doneCount++
|
||||
this.resetForm()
|
||||
if (!this.pending.length) {
|
||||
this.visible = false
|
||||
this.floatVisible = false
|
||||
}
|
||||
} catch (e) {
|
||||
// 错就停留,让用户重试
|
||||
} finally {
|
||||
this.submitting = false
|
||||
}
|
||||
},
|
||||
typeLabel(t) {
|
||||
return ({ task: '任务', step: '进度步骤', requirement: '采购需求' })[t] || t
|
||||
},
|
||||
typeTag(t) {
|
||||
return ({ task: '', step: 'warning', requirement: 'success' })[t] || ''
|
||||
},
|
||||
goDetail(item) {
|
||||
const url = this.detailUrl(item)
|
||||
if (url) {
|
||||
const full = this.$router.resolve({ path: url, query: { projectId: item.project_id } })
|
||||
window.open(full.href, '_blank')
|
||||
}
|
||||
},
|
||||
detailUrl(item) {
|
||||
switch (item.business_type) {
|
||||
case 'task': return '/task/task'
|
||||
case 'step': return '/step/step'
|
||||
case 'requirement': return '/hint/requirement'
|
||||
default: return null
|
||||
}
|
||||
},
|
||||
fmtDateTime(d) {
|
||||
if (!d) return '-'
|
||||
const s = String(d)
|
||||
return s.length >= 19 ? s.substring(0, 19).replace('T', ' ') : s
|
||||
},
|
||||
overdueDays(d) {
|
||||
if (!d) return 0
|
||||
const t = new Date(String(d).replace('T', ' ')).getTime()
|
||||
if (!t || isNaN(t)) return 0
|
||||
return Math.max(1, Math.floor((Date.now() - t) / 86400000))
|
||||
},
|
||||
urgencyClass(d) {
|
||||
const days = this.overdueDays(d)
|
||||
if (days <= 3) return 'fresh' // 新超期 红
|
||||
if (days <= 14) return 'mid' // 中等 黄
|
||||
return 'old' // 陈年 灰
|
||||
},
|
||||
shorten(s, n) {
|
||||
if (!s) return ''
|
||||
const t = String(s)
|
||||
return t.length > n ? t.substring(0, n) + '…' : t
|
||||
},
|
||||
itemKey(item) {
|
||||
return item.business_type + '-' + item.business_id
|
||||
},
|
||||
async quickComplete(item) {
|
||||
const key = this.itemKey(item)
|
||||
if (this.strikeMap[key]) return
|
||||
try {
|
||||
await postponeComplete(item.business_type, item.business_id)
|
||||
// 视觉划掉,1 秒后从列表移除
|
||||
this.$set(this.strikeMap, key, true)
|
||||
setTimeout(() => {
|
||||
const idx = this.pending.findIndex(x => this.itemKey(x) === key)
|
||||
if (idx >= 0) {
|
||||
this.pending.splice(idx, 1)
|
||||
this.doneCount++
|
||||
}
|
||||
this.$delete(this.strikeMap, key)
|
||||
if (!this.pending.length) {
|
||||
this.visible = false
|
||||
this.floatVisible = false
|
||||
}
|
||||
}, 900)
|
||||
} catch (e) {
|
||||
this.$modal.msgError('完成失败,请稍后再试')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 弹窗 */
|
||||
.overdue-dialog .head-meta { font-size: 14px; color: #303133; margin-bottom: 14px; line-height: 1.7; }
|
||||
.overdue-dialog .head-meta b { color: #f56c6c; font-size: 16px; margin: 0 3px; }
|
||||
.overdue-dialog .head-meta .hint-30 {
|
||||
background: #fef0f0; color: #f56c6c; padding: 1px 6px; border-radius: 3px;
|
||||
font-weight: 500; font-size: 13px;
|
||||
}
|
||||
.overdue-dialog .item-card { background: #fafafa; border-radius: 6px; padding: 14px 16px; margin-bottom: 4px; }
|
||||
.overdue-dialog .item-head { margin-bottom: 8px; }
|
||||
.overdue-dialog .item-head .item-title { margin-left: 8px; font-size: 15px; font-weight: 600; color: #303133; }
|
||||
.overdue-dialog .item-meta { font-size: 13px; color: #606266; margin-bottom: 12px; display: flex; gap: 18px; align-items: center; }
|
||||
.overdue-dialog .item-meta .bad { color: #f56c6c; font-weight: 500; }
|
||||
.overdue-dialog .form-area { margin-top: 8px; }
|
||||
.overdue-dialog .footer { display: flex; justify-content: space-between; align-items: center; }
|
||||
.overdue-dialog .footer .muted { color: #909399; font-size: 12px; }
|
||||
|
||||
/* 浮窗 */
|
||||
.overdue-float {
|
||||
position: fixed; right: 16px; bottom: 16px; z-index: 2000;
|
||||
width: 320px; max-height: 60vh;
|
||||
background: #fff; border-radius: 10px;
|
||||
box-shadow: 0 8px 32px rgba(245, 108, 108, 0.25), 0 0 0 2px rgba(245, 108, 108, 0.45);
|
||||
display: flex; flex-direction: column;
|
||||
animation: overduePulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes overduePulse {
|
||||
0%, 100% { box-shadow: 0 8px 32px rgba(245, 108, 108, 0.25), 0 0 0 2px rgba(245, 108, 108, 0.45); }
|
||||
50% { box-shadow: 0 8px 32px rgba(245, 108, 108, 0.45), 0 0 0 3px rgba(245, 108, 108, 0.8); }
|
||||
}
|
||||
.overdue-float .float-head {
|
||||
padding: 12px 14px; background: #fef0f0; border-radius: 10px 10px 0 0;
|
||||
font-size: 14px; font-weight: 600; color: #f56c6c;
|
||||
display: flex; align-items: center; gap: 6px; border-bottom: 1px solid #fde2e2;
|
||||
}
|
||||
.overdue-float .float-head i { font-size: 18px; }
|
||||
.overdue-float .float-list { padding: 4px 0; overflow-y: auto; flex: 1; }
|
||||
.overdue-float .float-item {
|
||||
padding: 8px 14px; cursor: pointer; border-left: 3px solid transparent;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.overdue-float .float-item:hover { background: #fafafa; }
|
||||
.overdue-float .float-item.fresh { border-left-color: #f56c6c; }
|
||||
.overdue-float .float-item.mid { border-left-color: #e6a23c; }
|
||||
.overdue-float .float-item.old { border-left-color: #c0c4cc; }
|
||||
.overdue-float .float-item-title {
|
||||
font-size: 13px; color: #303133;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.overdue-float .float-item-title .title-text { flex: 1; }
|
||||
.overdue-float .float-item-title .boss-tag { margin-left: 2px; }
|
||||
.overdue-float .float-item-meta {
|
||||
font-size: 11px; color: #909399; margin-top: 3px;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.overdue-float .float-item-meta .quick-done { padding: 2px 8px; color: #67c23a; font-weight: 500; }
|
||||
.overdue-float .float-item-meta .quick-done:hover { color: #5daf34; }
|
||||
/* 划掉动画 */
|
||||
.overdue-float .float-item.striking {
|
||||
opacity: 0.55; transform: translateX(8px); transition: opacity .9s, transform .9s;
|
||||
}
|
||||
.overdue-float .float-item.striking .title-text {
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: #67c23a;
|
||||
text-decoration-thickness: 2px;
|
||||
color: #909399;
|
||||
transition: all .9s ease;
|
||||
}
|
||||
.overdue-float .float-item.fresh .float-item-meta { color: #f56c6c; }
|
||||
.overdue-float .float-item.mid .float-item-meta { color: #e6a23c; }
|
||||
.overdue-float .float-footer {
|
||||
padding: 6px 14px; font-size: 11px; color: #909399;
|
||||
border-top: 1px solid #f0f0f0; text-align: center;
|
||||
}
|
||||
|
||||
/* 浮窗动画 */
|
||||
.float-slide-enter-active, .float-slide-leave-active { transition: all 0.3s; }
|
||||
.float-slide-enter, .float-slide-leave-to { opacity: 0; transform: translateY(20px); }
|
||||
</style>
|
||||
@@ -13,6 +13,7 @@
|
||||
</right-panel>
|
||||
</div>
|
||||
<tutorial-guide />
|
||||
<overdue-guard />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -20,6 +21,7 @@
|
||||
import RightPanel from '@/components/RightPanel'
|
||||
import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components'
|
||||
import TutorialGuide from './components/TutorialGuide.vue'
|
||||
import OverdueGuard from './components/OverdueGuard.vue'
|
||||
import ResizeMixin from './mixin/ResizeHandler'
|
||||
import { mapState } from 'vuex'
|
||||
import variables from '@/assets/styles/variables.scss'
|
||||
@@ -31,6 +33,7 @@ export default {
|
||||
Navbar,
|
||||
RightPanel,
|
||||
TutorialGuide,
|
||||
OverdueGuard,
|
||||
Settings,
|
||||
Sidebar,
|
||||
TagsView,
|
||||
|
||||
227
ruoyi-ui/src/views/oa/docs/panorama/index.vue
Normal file
227
ruoyi-ui/src/views/oa/docs/panorama/index.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="app-container docs-page">
|
||||
<h1>项目全景流 · 使用说明</h1>
|
||||
<el-alert type="info" :closable="false" show-icon
|
||||
title="本系统两个"看大局"的入口:①「集团驾驶舱」一眼看全公司所有项目的红黄绿灯;点进任意一个项目就到 ②「项目全景」,这一页把销售/合同/进度/采购/财务/任务/审批…全部串起来。"
|
||||
style="margin-bottom: 20px;" />
|
||||
|
||||
<!-- 1. 总览图 -->
|
||||
<el-card class="doc-card" shadow="never">
|
||||
<div slot="header"><b>一、两层结构</b></div>
|
||||
<div class="svg-wrap">
|
||||
<svg viewBox="0 0 1100 360" class="flow-svg" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#606266"/></marker>
|
||||
</defs>
|
||||
|
||||
<!-- 第一层:驾驶舱 -->
|
||||
<rect x="20" y="20" width="1060" height="110" rx="8" fill="#eef9ff" stroke="#91d5ff" />
|
||||
<text x="40" y="55" class="layer">第 1 层 · 集团驾驶舱</text>
|
||||
<text x="40" y="75" class="layer-sub">全公司全部项目一览,红黄绿灯一眼看清谁要救火 / 谁正常 / 谁完成</text>
|
||||
|
||||
<g class="proj">
|
||||
<rect x="60" y="90" width="120" height="28" rx="4" />
|
||||
<text x="120" y="108" class="proj-t">● 项目A(红)</text>
|
||||
</g>
|
||||
<g class="proj">
|
||||
<rect x="200" y="90" width="120" height="28" rx="4" />
|
||||
<text x="260" y="108" class="proj-t">● 项目B(黄)</text>
|
||||
</g>
|
||||
<g class="proj">
|
||||
<rect x="340" y="90" width="120" height="28" rx="4" />
|
||||
<text x="400" y="108" class="proj-t">● 项目C(绿)</text>
|
||||
</g>
|
||||
<text x="490" y="108" class="dots">…… N 个项目</text>
|
||||
|
||||
<!-- 下钻 -->
|
||||
<text x="540" y="160" class="dive">点任意一行 → 下钻到详情</text>
|
||||
<line x1="490" y1="170" x2="540" y2="200" stroke="#606266" stroke-width="1.6" marker-end="url(#arr)" />
|
||||
|
||||
<!-- 第二层:项目全景 -->
|
||||
<rect x="20" y="180" width="1060" height="160" rx="8" fill="#fff7e6" stroke="#ffd591" />
|
||||
<text x="40" y="215" class="layer">第 2 层 · 项目全景(单项目)</text>
|
||||
<text x="40" y="235" class="layer-sub">把这一个项目跨越所有部门的关键信息全聚在一页,每张卡有"详情→"按钮跳到原模块</text>
|
||||
|
||||
<g class="card">
|
||||
<rect x="50" y="250" width="120" height="40" rx="4" />
|
||||
<text x="110" y="266" class="cc">健康度</text>
|
||||
<text x="110" y="282" class="cc-sub">红 / 黄 / 绿</text>
|
||||
</g>
|
||||
<g class="card">
|
||||
<rect x="180" y="250" width="100" height="40" rx="4" />
|
||||
<text x="230" y="274" class="cc">合同</text>
|
||||
</g>
|
||||
<g class="card">
|
||||
<rect x="290" y="250" width="100" height="40" rx="4" />
|
||||
<text x="340" y="274" class="cc">进度</text>
|
||||
</g>
|
||||
<g class="card">
|
||||
<rect x="400" y="250" width="100" height="40" rx="4" />
|
||||
<text x="450" y="274" class="cc">采购</text>
|
||||
</g>
|
||||
<g class="card">
|
||||
<rect x="510" y="250" width="100" height="40" rx="4" />
|
||||
<text x="560" y="274" class="cc">库房</text>
|
||||
</g>
|
||||
<g class="card">
|
||||
<rect x="620" y="250" width="100" height="40" rx="4" />
|
||||
<text x="670" y="274" class="cc">财务</text>
|
||||
</g>
|
||||
<g class="card">
|
||||
<rect x="730" y="250" width="100" height="40" rx="4" />
|
||||
<text x="780" y="274" class="cc">任务/团队</text>
|
||||
</g>
|
||||
<g class="card">
|
||||
<rect x="840" y="250" width="100" height="40" rx="4" />
|
||||
<text x="890" y="274" class="cc">审批活动</text>
|
||||
</g>
|
||||
<g class="card">
|
||||
<rect x="950" y="250" width="100" height="40" rx="4" />
|
||||
<text x="1000" y="274" class="cc">制造主线</text>
|
||||
</g>
|
||||
|
||||
<text x="540" y="320" class="dive">每张卡 → "详情→"跳到原模块编辑</text>
|
||||
</svg>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 2. 红绿灯怎么判 -->
|
||||
<el-card class="doc-card" shadow="never">
|
||||
<div slot="header"><b>二、红绿灯怎么判(决定老板要不要管这个项目)</b></div>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<div class="state-card red">
|
||||
<h3>🔴 红灯 · 必须立刻介入</h3>
|
||||
<ul>
|
||||
<li>项目已过工期但状态还是「进行中」</li>
|
||||
<li>进度计划已超期</li>
|
||||
<li>≥ 5 个任务超期</li>
|
||||
<li>≥ 5 张审批单待办堆积</li>
|
||||
</ul>
|
||||
<p class="hint">任意一条命中就是红灯。</p>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="state-card yellow">
|
||||
<h3>🟡 黄灯 · 值得关注</h3>
|
||||
<ul>
|
||||
<li>累计延期 ≥ 2 次</li>
|
||||
<li>1~4 个任务超期</li>
|
||||
<li>1~4 张审批单待办</li>
|
||||
<li>已支出 > 已收款(现金净流出)</li>
|
||||
</ul>
|
||||
<p class="hint">没有红灯但命中黄灯条件就是黄灯。</p>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="state-card green">
|
||||
<h3>🟢 绿灯 · 进展正常</h3>
|
||||
<ul>
|
||||
<li>红黄都没命中</li>
|
||||
</ul>
|
||||
<p class="hint">系统会一句话告诉老板"该项目进展正常,无需立即介入"。</p>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- 3. 三类角色怎么用 -->
|
||||
<el-card class="doc-card" shadow="never">
|
||||
<div slot="header"><b>三、不同角色怎么用这两页</b></div>
|
||||
<el-table :data="roleSteps" border size="small">
|
||||
<el-table-column label="你是" prop="role" width="120" />
|
||||
<el-table-column label="先打开" prop="entry" width="220" />
|
||||
<el-table-column label="然后看" prop="focus" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 4. 全景页"九张卡" -->
|
||||
<el-card class="doc-card" shadow="never">
|
||||
<div slot="header"><b>四、全景页那 9 张卡分别讲什么</b></div>
|
||||
<el-table :data="cards" border size="small">
|
||||
<el-table-column label="卡片" prop="name" width="120" />
|
||||
<el-table-column label="它显示什么" prop="what" />
|
||||
<el-table-column label="点哪里下钻" prop="drill" width="160" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 5. FAQ -->
|
||||
<el-card class="doc-card" shadow="never">
|
||||
<div slot="header"><b>五、常见问题</b></div>
|
||||
<el-collapse>
|
||||
<el-collapse-item title="为什么我看到的项目少?">
|
||||
驾驶舱默认只显示「进行中」的项目(最常用)。把顶部「状态」筛选改成「全部」或「已完结」可看其他状态的项目。打开「只看异常」开关可只看红黄灯项目。
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="红绿灯什么时候更新?我刚处理完任务,灯还是红的">
|
||||
打开页面时算的。处理完了点页面上的「刷新」就会重算。不会自动轮询(避免给数据库压力)。
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="净现金那个数字能当利润看吗?">
|
||||
<b>不能。</b> 它就是简单「已收款 − 已记成本」,不含税、不含未到的应收/应付、不区分项目阶段。仅当"这个项目当前是不是在烧钱"参考。
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="制造主线那张卡某些项目不显示?">
|
||||
只有挂了「轧机厂相关数据」(安装进度 / 调试记录 / 验收清单)的项目才会出现这张卡。普通项目不显示是正常的。
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="点"详情→"跳错了页">
|
||||
有些跳转链接走的是新写的路由,如果你的角色没勾这些菜单权限就跳不进去。让管理员去角色管理给你开权限即可。
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'OaDocPanorama',
|
||||
data() {
|
||||
return {
|
||||
roleSteps: [
|
||||
{ role: '老板/总监', entry: '集团驾驶舱', focus: '先看顶部 6 个全局指标(在跑/超期/审批堆积);再扫表格 — 重点看红灯/黄灯行;点行进项目全景看细节。' },
|
||||
{ role: '项目负责人', entry: '项目全景', focus: '搜索自己负责的项目;最上方"健康度大卡"告诉你这单要不要救火;再看每张分卡。' },
|
||||
{ role: '一线员工', entry: '原业务模块', focus: '日常在原模块(合同/采购/任务)干活;偶尔来全景页看看自己的项目整体走到哪儿。' }
|
||||
],
|
||||
cards: [
|
||||
{ name: '健康度', what: '红/黄/绿灯 + 一句话总结 + 命中的红灯/黄灯信号', drill: '—' },
|
||||
{ name: 'KPI 大字', what: '项目金额 / 已收款 / 已记成本 / 净现金 / 进度% / 待审批 / 超期任务 / 延期次数', drill: '部分可点跳' },
|
||||
{ name: '合同', what: '该项目所有合同的列表 + 金额合计 + 每份的审批状态', drill: '"查看全部→"' },
|
||||
{ name: '进度计划', what: '前 6 步的甘特,已完成/进行中/未开始;是否超期;延期申请次数', drill: '"查看完整→"' },
|
||||
{ name: '采购需求', what: '该项目下采购需求 4 种状态分布 + 最近 5 条 + 审批状态', drill: '"查看全部→"' },
|
||||
{ name: '到货 & 库房', what: '采购到货 3 种状态分布 + 库房申请状态分布', drill: '"查看到货明细→"' },
|
||||
{ name: '财务', what: '合同总金额 / 计划收款 / 已收 / 待收 / 已记成本 / 净现金', drill: '"查看流水→"' },
|
||||
{ name: '任务', what: '执行中 / 待验收 / 完成 / 延期申请 4 种状态计数 + 超期数', drill: '"查看全部→"' },
|
||||
{ name: '团队', what: '项目负责人 + 所有参与任务的人', drill: '—' },
|
||||
{ name: '报告 & 会议', what: '最近工作日志 + 会议纪要', drill: '"日志→"' },
|
||||
{ name: '制造主线', what: '(仅轧机厂项目)安装进度 / 调试记录 / 验收清单', drill: '—' },
|
||||
{ name: '审批活动', what: '这个项目下所有审批单的统一时间线', drill: '"审批中心→"' },
|
||||
{ name: '操作日志', what: '该项目最近 20 条操作流水', drill: '—' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.docs-page { max-width: 1240px; }
|
||||
.docs-page h1 { font-size: 22px; margin: 0 0 16px; }
|
||||
.doc-card { margin-bottom: 16px; }
|
||||
|
||||
.svg-wrap { width: 100%; overflow-x: auto; }
|
||||
.flow-svg { width: 100%; min-width: 1000px; height: auto; display: block; }
|
||||
.flow-svg .layer { font-size: 16px; font-weight: 700; fill: #303133; }
|
||||
.flow-svg .layer-sub { font-size: 12px; fill: #606266; }
|
||||
.flow-svg .proj rect { fill: #fff; stroke: #91d5ff; stroke-width: 1.2; }
|
||||
.flow-svg .proj .proj-t { font-size: 12px; fill: #303133; text-anchor: middle; }
|
||||
.flow-svg .card rect { fill: #fff; stroke: #ffa940; stroke-width: 1.2; }
|
||||
.flow-svg .card .cc { font-size: 13px; fill: #303133; text-anchor: middle; font-weight: 600; }
|
||||
.flow-svg .card .cc-sub { font-size: 10px; fill: #909399; text-anchor: middle; }
|
||||
.flow-svg .dots { font-size: 13px; fill: #909399; }
|
||||
.flow-svg .dive { font-size: 12px; fill: #606266; font-style: italic; }
|
||||
|
||||
.state-card { background: #fafafa; border-radius: 6px; padding: 14px 16px; height: 100%; }
|
||||
.state-card.red { background: #fef0f0; }
|
||||
.state-card.yellow { background: #fdf6ec; }
|
||||
.state-card.green { background: #f0f9eb; }
|
||||
.state-card h3 { margin: 0 0 8px; font-size: 14px; color: #303133; }
|
||||
.state-card ul { padding-left: 22px; margin: 8px 0; line-height: 1.8; color: #606266; font-size: 13px; }
|
||||
.state-card .hint { color: #909399; font-size: 12px; margin: 4px 0 0; }
|
||||
</style>
|
||||
258
ruoyi-ui/src/views/oa/docs/performance/index.vue
Normal file
258
ruoyi-ui/src/views/oa/docs/performance/index.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div class="docs">
|
||||
<div class="title-bar">
|
||||
<h1>绩效计算标准</h1>
|
||||
<span class="muted">每月 1 号自动归零;周期 = 自然月</span>
|
||||
</div>
|
||||
|
||||
<!-- 1. 公式 -->
|
||||
<div class="card">
|
||||
<div class="card-head"><b>1. 总分模型</b></div>
|
||||
<div class="card-body">
|
||||
<div class="formula">
|
||||
<span class="part bg-blue">基础 80</span>
|
||||
<span class="op">+</span>
|
||||
<span class="part bg-green">系统加分 +N</span>
|
||||
<span class="op">−</span>
|
||||
<span class="part bg-red">系统扣分 -N</span>
|
||||
<span class="op">+</span>
|
||||
<span class="part bg-purple">高总主观 ±20</span>
|
||||
<span class="op">=</span>
|
||||
<span class="part bg-grey">总分 0~100</span>
|
||||
</div>
|
||||
<el-alert type="success" :closable="false" show-icon>
|
||||
<b>初始默认:基础 80 → 等级 B+</b>。员工没做错也没做好 = 80 分。<br/>
|
||||
要往 A 升,得自己加分(完成任务/报工/出差)+ 高总评价;想跌到 C/D 就是被扣分了。
|
||||
</el-alert>
|
||||
<h4>等级标尺(100 分制)</h4>
|
||||
<div class="grade-line">
|
||||
<div class="g-cell g-a">A+ ≥ 95</div>
|
||||
<div class="g-cell g-a">A ≥ 90</div>
|
||||
<div class="g-cell g-a">A- ≥ 85</div>
|
||||
<div class="g-cell g-b">B+ ≥ 80</div>
|
||||
<div class="g-cell g-b">B ≥ 75</div>
|
||||
<div class="g-cell g-b">B- ≥ 70</div>
|
||||
<div class="g-cell g-c">C+ ≥ 65</div>
|
||||
<div class="g-cell g-c">C ≥ 60</div>
|
||||
<div class="g-cell g-c">C- ≥ 55</div>
|
||||
<div class="g-cell g-d">D < 55</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 加分 -->
|
||||
<div class="card">
|
||||
<div class="card-head"><b>2. 系统自动加分</b></div>
|
||||
<div class="card-body">
|
||||
<el-table :data="rewards" border>
|
||||
<el-table-column label="行为" prop="name" width="160" />
|
||||
<el-table-column label="分值" prop="pts" width="100" align="center">
|
||||
<template slot-scope="{row}"><b class="ok">{{ row.pts }}</b></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="月度上限" prop="cap" width="100" align="center" />
|
||||
<el-table-column label="触发时机" prop="how" />
|
||||
</el-table>
|
||||
<p class="hint">加分会把总分推上去,<b>没有 60 那个 cap 了</b>(之前模型才有),现在所有加扣全部直接进总分,封顶 100。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 扣分 -->
|
||||
<div class="card">
|
||||
<div class="card-head"><b>3. 系统自动扣分</b></div>
|
||||
<div class="card-body">
|
||||
<el-table :data="penalties" border>
|
||||
<el-table-column label="行为" prop="name" width="180" />
|
||||
<el-table-column label="扣分" prop="pts" width="100" align="center">
|
||||
<template slot-scope="{row}"><b class="bad">{{ row.pts }}</b></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="是否粘性" prop="stick" width="120" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<el-tag size="mini" :type="row.stick==='是' ? 'danger' : 'info'">{{ row.stick }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="说明" prop="how" />
|
||||
</el-table>
|
||||
<p class="hint">
|
||||
<b>粘性 = 一旦扣了不能事后补救</b>(比如忘了报工,21:00 检测时就扣,第二天补写也不退)。<br/>
|
||||
只有「日逾期」是非粘性的 — 你今天解决了,明天就不再继续扣。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. 高总主观分 -->
|
||||
<div class="card">
|
||||
<div class="card-head"><b>4. 高总主观分(-20 ~ +20)</b></div>
|
||||
<div class="card-body">
|
||||
<p>
|
||||
每月由高总在「全员绩效」页给每个员工手动评价:
|
||||
</p>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8">
|
||||
<div class="state-card ok">
|
||||
<h4>+1 ~ +20</h4>
|
||||
<p>员工表现突出 / 系统错扣需要回血 / 重大贡献。</p>
|
||||
<p class="hint">这就是"<b>20 分冗余</b>"—— 项目多被误扣的可以由高总评价补回来。</p>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="state-card">
|
||||
<h4>0(默认)</h4>
|
||||
<p>未评价 = 中性,对总分无影响。</p>
|
||||
<p class="hint">列表里显示「待评」灰标签,但不影响员工的客观分数。</p>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="state-card bad">
|
||||
<h4>-1 ~ -20</h4>
|
||||
<p>态度问题 / 客户投诉 / 系统未覆盖的严重过错。</p>
|
||||
<p class="hint">注意:系统扣分已经覆盖了大部分客观错误,主观负分要慎重。</p>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<h4 style="margin-top: 16px;">参考维度</h4>
|
||||
<div class="dim-grid">
|
||||
<div class="dim"><b>业务贡献</b><br/>客户开发 / 销售业绩 / 重大项目突破</div>
|
||||
<div class="dim"><b>协作配合</b><br/>跨部门协助 / 主动补位 / 同事评价</div>
|
||||
<div class="dim"><b>主动担当</b><br/>主动揽事 / 解决难题 / 顶班</div>
|
||||
<div class="dim"><b>学习成长</b><br/>技能精进 / 流程改进 / 分享经验</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5. 算例 -->
|
||||
<div class="card">
|
||||
<div class="card-head"><b>5. 算例</b></div>
|
||||
<div class="card-body">
|
||||
<el-table :data="examples" border>
|
||||
<el-table-column label="员工" prop="role" width="160" />
|
||||
<el-table-column label="自动加" align="center" width="100">
|
||||
<template slot-scope="{row}"><span class="ok">+{{ row.reward }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="自动扣" align="center" width="100">
|
||||
<template slot-scope="{row}"><span class="bad">-{{ row.ded }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="高总主观" align="center" width="100">
|
||||
<template slot-scope="{row}">
|
||||
<span :style="row.bonus > 0 ? 'color:#67c23a' : (row.bonus < 0 ? 'color:#f56c6c' : '')">
|
||||
{{ row.bonus > 0 ? '+' : '' }}{{ row.bonus }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="总分" align="center" width="100">
|
||||
<template slot-scope="{row}"><b>{{ row.total }}</b></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="等级" align="center" width="80">
|
||||
<template slot-scope="{row}"><el-tag size="mini" :type="row.tag">{{ row.grade }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="点评" prop="note" />
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 6. 常见问题 -->
|
||||
<div class="card">
|
||||
<div class="card-head"><b>6. 常见问题</b></div>
|
||||
<div class="card-body">
|
||||
<el-collapse>
|
||||
<el-collapse-item title="为什么我刚入职就是 B+?">
|
||||
B+ 是「中性」起点。没扣分 = 80 = B+。要上 A 系列得靠自己加分(完成任务/报工/全勤)+ 高总评价。
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="项目特别多,被扣分会不会太狠?">
|
||||
高总有 ±20 的"冗余"可以加回来。月底高总评价时,错扣的部分可以补到 +20。
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="我做了很多任务但等级没上来?">
|
||||
完成任务每条 +1 上限 +10/月。加上报工 +6 / 出差 +6 / 全勤 +3 = 最多自动加 31 分,足够把 80 推到 100(A+)。
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="忘了报工怎么办?补写能退分吗?">
|
||||
<b>不能</b>。系统每天 21:00 检测,那一刻没有当天报工就扣 1 分,事后补录不退分。这是"粘性扣分"。
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="逾期任务每天都扣,会不会越扣越多?">
|
||||
会。每天每条逾期事项扣 1 分,没有月度上限。但<b>当天解决了第二天就不扣了</b>。所以一发现逾期赶紧处理或者主动顺延。
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="顺延超过 3 次为什么要走审批?">
|
||||
防止"装死" — 一项事故顺延一次可以,反复推就要让高总知情,必须经过审批通过才能继续顺延,过不了就要被迫面对扣分。
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'OaDocPerformance',
|
||||
data() {
|
||||
return {
|
||||
rewards: [
|
||||
{ name: '完成任务', pts: '+1', cap: '+10', how: '任务状态推到「完成」时按执行人计入' },
|
||||
{ name: '完成进度步骤', pts: '+1', cap: '+6', how: '步骤状态推到「完成」时按进度负责人计入' },
|
||||
{ name: '提交工作日报', pts: '+1', cap: '+6', how: '在系统提交报工记录' },
|
||||
{ name: '出差登记', pts: '+1/天', cap: '+6', how: '工作日报里勾选"是否出差"' },
|
||||
{ name: '月度全勤', pts: '+3', cap: '+3', how: '当月考勤打卡 ≥ 18 天,月初算上月' }
|
||||
],
|
||||
penalties: [
|
||||
{ name: '顺延一次', pts: '-1', stick: '是', how: '每次顺延独立扣 1 分;第 3 次起需高总审批' },
|
||||
{ name: '超期 ≥ 30 天', pts: '-20', stick: '是', how: '同一事项一次性扣 20 分,并 IM 通知高总' },
|
||||
{ name: '当天没报工', pts: '-1', stick: '是', how: '每天 21:00 检测今天是否有报工;漏了就扣,补写不退' },
|
||||
{ name: '逾期事项 / 天', pts: '-1', stick: '否', how: '每天扫描,仍处于逾期状态的事项每条 -1。次日解决就不再扣' },
|
||||
{ name: '高总手动扣分', pts: '自定义', stick: '是', how: '高总/陆总/admin 在「全员绩效」页可手动扣分(带原因留痕)' }
|
||||
],
|
||||
examples: [
|
||||
{ role: '正常员工', reward: 0, ded: 0, bonus: 0, total: 80, grade: 'B+', tag: '', note: '初始状态,谁都不偏不倚' },
|
||||
{ role: '勤奋员工', reward: 20, ded: 0, bonus: 5, total: 100, grade: 'A+', tag: 'success', note: '完成任务多 + 高总评价好' },
|
||||
{ role: '正常员工 + 高总加分', reward: 0, ded: 0, bonus: 10, total: 90, grade: 'A', tag: 'success', note: '协作好、被高总看在眼里' },
|
||||
{ role: '项目多被错扣 + 高总回血', reward: 5, ded: 18, bonus: 13, total: 80, grade: 'B+', tag: '', note: '高总用主观分把错扣回血' },
|
||||
{ role: '忘报工 5 次 + 顺延 3 次', reward: 0, ded: 8, bonus: 0, total: 72, grade: 'B-', tag: 'warning', note: '日常不严谨,掉到 B-' },
|
||||
{ role: '超期 30 天 + 多次顺延', reward: 0, ded: 26, bonus: -5, total: 49, grade: 'D', tag: 'danger', note: '严重 + 高总差评,D 级' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.docs { padding: 16px 20px; background: #f5f7fa; min-height: calc(100vh - 50px); }
|
||||
.title-bar { display: flex; align-items: baseline; gap: 12px; margin-bottom: 14px; }
|
||||
.title-bar h1 { font-size: 22px; margin: 0; }
|
||||
.muted { color: #909399; font-size: 13px; }
|
||||
.ok { color: #67c23a; }
|
||||
.bad { color: #f56c6c; }
|
||||
|
||||
.card { background: #fff; border-radius: 10px; padding: 18px 22px; margin-bottom: 14px; }
|
||||
.card-head { font-size: 16px; margin-bottom: 12px; color: #303133; }
|
||||
.card-body p { line-height: 1.8; color: #606266; }
|
||||
.card-body h4 { margin: 14px 0 8px; font-size: 14px; }
|
||||
.hint { color: #909399; font-size: 12px; margin-top: 10px; line-height: 1.8; }
|
||||
|
||||
.formula {
|
||||
display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px;
|
||||
font-size: 13px; font-weight: 600; padding: 18px;
|
||||
background: #fafafa; border-radius: 8px; margin-bottom: 14px;
|
||||
}
|
||||
.formula .part { padding: 6px 12px; border-radius: 6px; color: #fff; white-space: nowrap; }
|
||||
.formula .part.bg-blue { background: linear-gradient(135deg, #409EFF, #337ecc); }
|
||||
.formula .part.bg-green { background: linear-gradient(135deg, #67c23a, #5daf34); }
|
||||
.formula .part.bg-red { background: linear-gradient(135deg, #f56c6c, #d65b5b); }
|
||||
.formula .part.bg-purple { background: linear-gradient(135deg, #b37feb, #9254de); }
|
||||
.formula .part.bg-grey { background: linear-gradient(135deg, #606266, #303133); }
|
||||
.formula .op { color: #909399; font-size: 16px; }
|
||||
|
||||
.grade-line { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.g-cell {
|
||||
padding: 4px 10px; border-radius: 4px; font-size: 12px; color: #fff; font-weight: 600;
|
||||
}
|
||||
.g-cell.g-a { background: #67c23a; }
|
||||
.g-cell.g-b { background: #409EFF; }
|
||||
.g-cell.g-c { background: #e6a23c; }
|
||||
.g-cell.g-d { background: #f56c6c; }
|
||||
|
||||
.state-card { background: #fafafa; border-radius: 6px; padding: 14px 16px; height: 100%; }
|
||||
.state-card.ok { background: #f0f9eb; }
|
||||
.state-card.bad { background: #fef0f0; }
|
||||
.state-card h4 { margin: 0 0 8px; }
|
||||
.state-card p { font-size: 13px; }
|
||||
|
||||
.dim-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin: 14px 0; }
|
||||
.dim { background: #fafafa; border-left: 3px solid #409EFF; border-radius: 6px; padding: 12px 16px; font-size: 12px; color: #606266; line-height: 1.7; }
|
||||
.dim b { color: #303133; font-size: 13px; }
|
||||
</style>
|
||||
218
ruoyi-ui/src/views/oa/performance/mine/index.vue
Normal file
218
ruoyi-ui/src/views/oa/performance/mine/index.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div class="perf">
|
||||
<div class="toolbar">
|
||||
<el-date-picker v-model="period" type="month" format="yyyy-MM" value-format="yyyy-MM"
|
||||
placeholder="选择月份" size="small" @change="load" style="width: 180px;" />
|
||||
<el-button size="small" icon="el-icon-refresh" @click="load">刷新</el-button>
|
||||
<span class="muted">本月规则:基础 60 + 高总主观 0~40 − 系统自动扣分 = 总分</span>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="hero">
|
||||
<div class="card-grade" :class="gradeClass(score.grade)">
|
||||
<div class="grade-letter">{{ score.grade || '-' }}</div>
|
||||
<div class="grade-period">{{ period }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card-breakdown">
|
||||
<div class="break-title">总分构成</div>
|
||||
<div class="break-rows">
|
||||
<div class="brow">
|
||||
<span class="b-lbl">客观基础</span>
|
||||
<span class="b-bar"><span class="b-fill b1" :style="{width: pct(score.baseScore || 60, 60)}"></span></span>
|
||||
<span class="b-val">{{ score.baseScore || 60 }}</span>
|
||||
</div>
|
||||
<div class="brow">
|
||||
<span class="b-lbl bad">系统扣分</span>
|
||||
<span class="b-bar"><span class="b-fill b2" :style="{width: pct(score.deduction || 0, 60)}"></span></span>
|
||||
<span class="b-val bad">-{{ score.deduction || 0 }}</span>
|
||||
</div>
|
||||
<div class="brow">
|
||||
<span class="b-lbl">高总主观</span>
|
||||
<span class="b-bar"><span class="b-fill b3" :style="{width: pct(score.bonus || 0, 40)}"></span></span>
|
||||
<span class="b-val">+{{ score.bonus || 0 }} / 40</span>
|
||||
</div>
|
||||
<div class="brow strong">
|
||||
<span class="b-lbl">总分</span>
|
||||
<span class="b-bar"><span class="b-fill b4" :style="{width: pct(score.totalScore || 0, 100)}"></span></span>
|
||||
<span class="b-val" :class="totalClass(score.totalScore)">{{ score.totalScore }} / 100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card panel">
|
||||
<div class="panel-head">
|
||||
<b>本月扣分流水</b>
|
||||
<span class="muted">{{ deductions.length }} 条</span>
|
||||
</div>
|
||||
<el-empty v-if="!deductions.length" :image-size="60" description="本月没有扣分记录" />
|
||||
<el-table v-else :data="deductions" size="small" border>
|
||||
<el-table-column label="时间" prop="createTime" width="160" />
|
||||
<el-table-column label="类型" width="110" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<el-tag :type="srcTag(row.source)" size="mini">{{ srcLabel(row.source) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="业务" width="100" align="center">
|
||||
<template slot-scope="{row}">{{ bizLabel(row.sourceType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="扣分" width="80" align="center">
|
||||
<template slot-scope="{row}"><b style="color:#f56c6c">-{{ row.points }}</b></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原因" prop="reason" min-width="200" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="card panel">
|
||||
<div class="panel-head"><b>规则说明</b></div>
|
||||
<div class="rules">
|
||||
<div class="rule-card">
|
||||
<div class="r-num">60</div>
|
||||
<div class="r-text"><b>客观基础</b><br/>系统根据顺延 / 超期自动扣分</div>
|
||||
</div>
|
||||
<div class="rule-card">
|
||||
<div class="r-num">0~40</div>
|
||||
<div class="r-text"><b>高总主观</b><br/>每月由高总打分,默认满分 40</div>
|
||||
</div>
|
||||
<div class="rule-card warn">
|
||||
<div class="r-num">-1</div>
|
||||
<div class="r-text"><b>每次顺延</b><br/>第 3 次起需高总审批</div>
|
||||
</div>
|
||||
<div class="rule-card bad">
|
||||
<div class="r-num">-20</div>
|
||||
<div class="r-text"><b>超期 ≥ 30 天</b><br/>同一事项只扣一次,并通知高总</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { myPerformance } from '@/api/oa/performance'
|
||||
|
||||
export default {
|
||||
name: 'OaPerformanceMine',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
period: '',
|
||||
score: { baseScore: 60, deduction: 0, bonus: 40, totalScore: 100, grade: 'A+' },
|
||||
deductions: []
|
||||
}
|
||||
},
|
||||
created() {
|
||||
const d = new Date()
|
||||
this.period = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
|
||||
this.load()
|
||||
},
|
||||
methods: {
|
||||
load() {
|
||||
this.loading = true
|
||||
myPerformance(this.period).then(res => {
|
||||
const d = res.data || {}
|
||||
this.score = d.score || this.score
|
||||
this.deductions = d.deductions || []
|
||||
}).finally(() => { this.loading = false })
|
||||
},
|
||||
pct(v, max) {
|
||||
if (!max) return '0%'
|
||||
const p = Math.max(0, Math.min(100, (v / max) * 100))
|
||||
return p + '%'
|
||||
},
|
||||
gradeClass(g) {
|
||||
if (!g) return ''
|
||||
if (g.startsWith('A')) return 'g-a'
|
||||
if (g.startsWith('B')) return 'g-b'
|
||||
if (g.startsWith('C')) return 'g-c'
|
||||
return 'g-d'
|
||||
},
|
||||
totalClass(n) {
|
||||
n = Number(n) || 0
|
||||
if (n >= 90) return 'ok'
|
||||
if (n >= 75) return 'warn'
|
||||
return 'bad'
|
||||
},
|
||||
srcLabel(s) { return ({ postpone:'顺延', overdue30:'超期30天', manual:'手动', manual_subjective:'高总打分' })[s] || s },
|
||||
srcTag(s) { return ({ postpone:'warning', overdue30:'danger', manual:'info', manual_subjective:'success' })[s] || '' },
|
||||
bizLabel(t) { return ({ task:'任务', step:'步骤', requirement:'采购需求' })[t] || (t || '-') }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.perf {
|
||||
padding: 16px 20px; background: #f5f7fa;
|
||||
min-height: calc(100vh - 50px);
|
||||
}
|
||||
.muted { color: #909399; font-size: 12px; }
|
||||
.ok { color: #67c23a; }
|
||||
.warn { color: #e6a23c; }
|
||||
.bad { color: #f56c6c; }
|
||||
|
||||
.toolbar {
|
||||
background: #fff; padding: 12px 16px; border-radius: 8px; margin-bottom: 14px;
|
||||
display: flex; gap: 12px; align-items: center;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid; grid-template-columns: 320px 1fr; gap: 14px; margin-bottom: 14px;
|
||||
}
|
||||
.card-grade {
|
||||
background: linear-gradient(135deg, #f0f9eb 0%, #fff 100%);
|
||||
border-radius: 10px; padding: 28px 20px; text-align: center;
|
||||
border: 1px solid #ebeef5;
|
||||
}
|
||||
.card-grade.g-a { background: linear-gradient(135deg, #f0f9eb 0%, #fff 100%); }
|
||||
.card-grade.g-b { background: linear-gradient(135deg, #ecf5ff 0%, #fff 100%); }
|
||||
.card-grade.g-c { background: linear-gradient(135deg, #fdf6ec 0%, #fff 100%); }
|
||||
.card-grade.g-d { background: linear-gradient(135deg, #fef0f0 0%, #fff 100%); }
|
||||
.grade-letter {
|
||||
font-size: 110px; font-weight: 800; line-height: 1;
|
||||
color: #67c23a;
|
||||
}
|
||||
.card-grade.g-a .grade-letter { color: #67c23a; }
|
||||
.card-grade.g-b .grade-letter { color: #409EFF; }
|
||||
.card-grade.g-c .grade-letter { color: #e6a23c; }
|
||||
.card-grade.g-d .grade-letter { color: #f56c6c; }
|
||||
.grade-period { color: #909399; margin-top: 8px; font-size: 14px; }
|
||||
|
||||
.card-breakdown {
|
||||
background: #fff; border-radius: 10px; padding: 22px 24px;
|
||||
}
|
||||
.break-title { font-size: 15px; font-weight: 600; color: #303133; margin-bottom: 18px; }
|
||||
.brow {
|
||||
display: grid; grid-template-columns: 80px 1fr 110px; align-items: center;
|
||||
gap: 14px; margin-bottom: 14px; font-size: 13px;
|
||||
}
|
||||
.brow.strong { border-top: 1px dashed #ebeef5; padding-top: 14px; margin-top: 6px; }
|
||||
.brow.strong .b-lbl, .brow.strong .b-val { font-weight: 700; font-size: 15px; }
|
||||
.b-lbl { color: #606266; }
|
||||
.b-val { text-align: right; font-family: Menlo, Consolas, monospace; color: #303133; }
|
||||
.b-bar {
|
||||
height: 14px; background: #f4f4f5; border-radius: 7px; overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.b-fill { display: block; height: 100%; border-radius: 7px; transition: width .5s; }
|
||||
.b1 { background: #c0c4cc; }
|
||||
.b2 { background: #f56c6c; }
|
||||
.b3 { background: #67c23a; }
|
||||
.b4 { background: linear-gradient(90deg, #409EFF 0%, #67c23a 100%); }
|
||||
|
||||
.card.panel { background: #fff; border-radius: 10px; padding: 16px 20px; margin-bottom: 14px; }
|
||||
.panel-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
|
||||
.rules { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.rule-card {
|
||||
background: #fafafa; border-radius: 8px; padding: 16px;
|
||||
display: flex; gap: 12px; align-items: center; border-left: 3px solid #409EFF;
|
||||
}
|
||||
.rule-card.warn { border-left-color: #e6a23c; }
|
||||
.rule-card.bad { border-left-color: #f56c6c; }
|
||||
.rule-card .r-num {
|
||||
font-size: 26px; font-weight: 700; color: #409EFF; min-width: 60px; text-align: center;
|
||||
}
|
||||
.rule-card.warn .r-num { color: #e6a23c; }
|
||||
.rule-card.bad .r-num { color: #f56c6c; }
|
||||
.rule-card .r-text { font-size: 12px; line-height: 1.6; color: #606266; }
|
||||
.rule-card .r-text b { color: #303133; font-size: 13px; }
|
||||
</style>
|
||||
521
ruoyi-ui/src/views/oa/performance/rank/index.vue
Normal file
521
ruoyi-ui/src/views/oa/performance/rank/index.vue
Normal file
@@ -0,0 +1,521 @@
|
||||
<template>
|
||||
<div class="rank">
|
||||
<!-- 顶部工具栏 -->
|
||||
<div class="toolbar">
|
||||
<div class="left">
|
||||
<el-date-picker v-model="query.period" type="month" format="yyyy-MM" value-format="yyyy-MM"
|
||||
size="small" @change="load" style="width:160px;" />
|
||||
<el-input v-model="query.nameLike" placeholder="搜索姓名" clearable size="small" style="width:200px;"
|
||||
@keyup.enter.native="load" @clear="load">
|
||||
<i slot="prefix" class="el-input__icon el-icon-search"></i>
|
||||
</el-input>
|
||||
<el-button size="small" type="primary" icon="el-icon-search" @click="load">查询</el-button>
|
||||
</div>
|
||||
<div class="right">
|
||||
<div class="filter-tabs">
|
||||
<span class="tab" :class="{active: filter==='all'}" @click="filter='all'">全部 {{ list.length }}</span>
|
||||
<span class="tab ok" :class="{active: filter==='A'}" @click="filter='A'">A {{ countByLetter('A') }}</span>
|
||||
<span class="tab b" :class="{active: filter==='B'}" @click="filter='B'">B {{ countByLetter('B') }}</span>
|
||||
<span class="tab c" :class="{active: filter==='C'}" @click="filter='C'">C {{ countByLetter('C') }}</span>
|
||||
<span class="tab d" :class="{active: filter==='D'}" @click="filter='D'">D {{ countByLetter('D') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 前三名领奖台 -->
|
||||
<div v-if="!filter || filter==='all'" class="podium" v-loading="loading">
|
||||
<template v-for="(row, idx) in podium">
|
||||
<div :key="row.user_id" class="podium-slot" :class="'p-' + (idx+1)">
|
||||
<div class="medal">
|
||||
<i class="el-icon-trophy"></i>
|
||||
<span class="rank-text">No.{{ idx+1 }}</span>
|
||||
</div>
|
||||
<div class="podium-card" :class="cardClass(row.grade)">
|
||||
<div class="p-grade" :class="gradeClass(row.grade)">{{ row.grade }}</div>
|
||||
<div class="p-name">{{ row.user_nick }}</div>
|
||||
<div class="p-score">
|
||||
<span class="num" :class="totalClass(row.total_score)">{{ row.total_score }}</span>
|
||||
<span class="div">/ 100</span>
|
||||
</div>
|
||||
<div class="p-mini">
|
||||
<span>基础 {{ row.base_score }}</span>
|
||||
<span v-if="row.reward > 0" class="ok">+{{ row.reward }}</span>
|
||||
<span v-if="row.deduction > 0" class="bad">-{{ row.deduction }}</span>
|
||||
<span>主观 {{ row.bonus }}</span>
|
||||
</div>
|
||||
<div class="p-actions" v-if="canScore">
|
||||
<el-button size="mini" type="primary" plain @click="openScore(row)">打分</el-button>
|
||||
<el-button size="mini" type="warning" plain @click="openAdjust(row)">加减分</el-button>
|
||||
</div>
|
||||
<el-button size="mini" type="text" @click="openDetail(row)">查看流水 →</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 其余排名表格 -->
|
||||
<div class="rest" v-loading="loading">
|
||||
<el-table :data="restList" border stripe :row-class-name="rowClass">
|
||||
<el-table-column label="排名" type="index" width="70" align="center" :index="indexFn" />
|
||||
<el-table-column label="员工" prop="user_nick" min-width="140">
|
||||
<template slot-scope="{row}">
|
||||
<span class="user-cell">
|
||||
<span class="dot" :class="gradeDot(row.grade)"></span>
|
||||
{{ row.user_nick }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="等级" width="80" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<span class="grade-tag" :class="gradeClass(row.grade)">{{ row.grade }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="总分" width="100" align="center" sortable prop="total_score">
|
||||
<template slot-scope="{row}">
|
||||
<b :class="totalClass(row.total_score)">{{ row.total_score }}</b>
|
||||
<span class="of">/100</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="基础" prop="base_score" width="80" align="center" />
|
||||
<el-table-column label="加" width="80" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<span v-if="row.reward > 0" class="ok">+{{ row.reward }}</span>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="扣" width="80" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<span v-if="row.deduction > 0" class="bad">-{{ row.deduction }}</span>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="主观" width="110" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<span :style="(row.bonus||0) > 0 ? 'color:#67c23a' : (row.bonus||0) < 0 ? 'color:#f56c6c' : ''">
|
||||
{{ (row.bonus || 0) > 0 ? '+' : '' }}{{ row.bonus || 0 }}
|
||||
</span>
|
||||
<el-tag v-if="row.subjective_set !== 1 && row.subjectiveSet !== 1"
|
||||
size="mini" type="info" effect="plain" style="margin-left:4px;">待评</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" align="center" fixed="right">
|
||||
<template slot-scope="{row}">
|
||||
<el-button type="text" size="mini" @click="openDetail(row)">流水</el-button>
|
||||
<el-button type="text" size="mini" style="color:#409EFF" @click="openScore(row)" v-if="canScore">打分</el-button>
|
||||
<el-button type="text" size="mini" style="color:#e6a23c" @click="openAdjust(row)" v-if="canScore">加/扣</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!loading && !restList.length && !podium.length" description="本周期暂无数据" />
|
||||
</div>
|
||||
|
||||
<!-- 加减分弹窗 -->
|
||||
<el-dialog title="手动加减分" :visible.sync="adjustOpen" width="500px" append-to-body :close-on-click-modal="false">
|
||||
<div v-if="adjustForm.userId" class="form-body">
|
||||
<div class="form-user">
|
||||
<div class="u-name">{{ adjustForm.userName }}</div>
|
||||
<div class="u-period">{{ query.period }} 月度</div>
|
||||
</div>
|
||||
<el-form ref="adjustFormRef" :model="adjustForm" :rules="adjustRules" label-width="90px" size="small">
|
||||
<el-form-item label="操作">
|
||||
<el-radio-group v-model="adjustForm.dir">
|
||||
<el-radio-button :label="-1"><i class="el-icon-plus"></i> 加分</el-radio-button>
|
||||
<el-radio-button :label="1"><i class="el-icon-minus"></i> 扣分</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="分值" prop="absPoints">
|
||||
<el-input-number v-model="adjustForm.absPoints" :min="1" :max="40" size="small" />
|
||||
<span class="preview" :class="adjustForm.dir < 0 ? 'ok' : 'bad'">
|
||||
{{ adjustForm.dir < 0 ? '+' : '−' }}{{ adjustForm.absPoints }} 分
|
||||
</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="原因" prop="reason" required>
|
||||
<el-input v-model="adjustForm.reason" type="textarea" :rows="3" maxlength="200" show-word-limit
|
||||
placeholder="必填:会写入流水留痕,员工可以在「我的绩效」看到" />
|
||||
</el-form-item>
|
||||
<el-form-item label="操作人">
|
||||
<span class="op-name">{{ currentUserName }}(自动)</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div slot="footer">
|
||||
<el-button @click="adjustOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="adjustSubmitting" @click="submitAdjust">
|
||||
{{ adjustForm.dir < 0 ? '保存(加分)' : '保存(扣分)' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 高总主观分弹窗 -->
|
||||
<el-dialog title="高总主观打分" :visible.sync="scoreOpen" width="520px" append-to-body :close-on-click-modal="false">
|
||||
<div v-if="scoreForm.userId" class="form-body">
|
||||
<div class="form-user">
|
||||
<div class="u-name">{{ scoreForm.userName }}</div>
|
||||
<div class="u-period">{{ query.period }} 月度</div>
|
||||
</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-tile">
|
||||
<div class="i-lbl">基础</div>
|
||||
<div class="i-val">{{ scoreForm.baseScore }}</div>
|
||||
</div>
|
||||
<div class="info-tile">
|
||||
<div class="i-lbl">扣分</div>
|
||||
<div class="i-val bad">-{{ scoreForm.deduction }}</div>
|
||||
</div>
|
||||
<div class="info-tile">
|
||||
<div class="i-lbl">加分</div>
|
||||
<div class="i-val ok">+{{ scoreForm.reward || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slider-label">主观分(-20 ~ +20,默认 0 = 未评价/不调节)</div>
|
||||
<el-slider v-model="scoreForm.score" :min="-20" :max="20" show-input
|
||||
:marks="{'-20':'-20','-10':'-10','0':'0','10':'+10','20':'+20'}" />
|
||||
|
||||
<div class="preview-total">
|
||||
预估总分 =
|
||||
<span class="calc">{{ scoreForm.baseScore }} - {{ scoreForm.deduction }} + {{ scoreForm.reward || 0 }} + {{ scoreForm.score }}</span>
|
||||
=
|
||||
<b :class="totalClass(previewTotal)">{{ previewTotal }}</b>
|
||||
({{ gradeOf(previewTotal) }})
|
||||
</div>
|
||||
</div>
|
||||
<div slot="footer">
|
||||
<el-button @click="scoreOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="scoreSubmitting" @click="submitScore">保存打分</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 流水弹窗 -->
|
||||
<el-dialog title="扣分 / 加分 流水" :visible.sync="detailOpen" width="700px" append-to-body>
|
||||
<div v-if="detail.user" class="detail-head">
|
||||
<span class="u-name">{{ detail.user }}</span>
|
||||
<span class="u-period">{{ query.period }}</span>
|
||||
</div>
|
||||
<el-empty v-if="!detail.list.length" :image-size="60" description="无流水" />
|
||||
<el-table v-else :data="detail.list" size="small" border>
|
||||
<el-table-column label="时间" prop="createTime" width="160" />
|
||||
<el-table-column label="来源" width="120" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<el-tag size="mini" :type="srcTag(row.source)">{{ srcLabel(row.source) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="业务" width="80" align="center">
|
||||
<template slot-scope="{row}">{{ bizLabel(row.sourceType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分值" width="70" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<b :style="{color: row.points > 0 ? '#f56c6c' : '#67c23a'}">
|
||||
{{ row.points > 0 ? '-' : '+' }}{{ Math.abs(row.points) }}
|
||||
</b>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原因" prop="reason" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { rankPerformance, userPerformance, setSubjective, manualAdjust } from '@/api/oa/performance'
|
||||
|
||||
// 可以打分的人员白名单:高总 + 陆永强
|
||||
const SCORER_USER_IDS = ['1859252208375152641', '1858417253738815490']
|
||||
|
||||
export default {
|
||||
name: 'OaPerformanceRank',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
query: { period: '', nameLike: '' },
|
||||
filter: 'all',
|
||||
list: [],
|
||||
detailOpen: false,
|
||||
detail: { user: '', list: [] },
|
||||
scoreOpen: false,
|
||||
scoreSubmitting: false,
|
||||
scoreForm: { userId: null, userName: '', baseScore: 80, deduction: 0, reward: 0, score: 0 },
|
||||
adjustOpen: false,
|
||||
adjustSubmitting: false,
|
||||
adjustForm: { userId: null, userName: '', dir: -1, absPoints: 5, reason: '' },
|
||||
adjustRules: {
|
||||
reason: [{ required: true, message: '请填写原因(必填)', trigger: 'blur' },
|
||||
{ min: 3, message: '原因至少 3 个字', trigger: 'blur' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canScore() {
|
||||
const uid = this.$store.getters.userId || (this.$store.state.user && this.$store.state.user.userId)
|
||||
const uidStr = String(uid || '')
|
||||
const roles = (this.$store.state.user && this.$store.state.user.roles) || []
|
||||
return SCORER_USER_IDS.includes(uidStr)
|
||||
|| roles.includes('admin')
|
||||
|| (this.$store.state.user && this.$store.state.user.name === 'admin')
|
||||
},
|
||||
currentUserName() {
|
||||
return (this.$store.state.user && (this.$store.state.user.nickName || this.$store.state.user.name)) || '当前操作人'
|
||||
},
|
||||
filteredList() {
|
||||
if (this.filter === 'all') return this.list
|
||||
return this.list.filter(r => r.grade && r.grade.charAt(0) === this.filter)
|
||||
},
|
||||
podium() {
|
||||
return this.filteredList.slice(0, 3)
|
||||
},
|
||||
restList() {
|
||||
return this.filteredList.slice(3)
|
||||
},
|
||||
previewTotal() {
|
||||
const t = (this.scoreForm.baseScore || 0) - (this.scoreForm.deduction || 0)
|
||||
+ (this.scoreForm.reward || 0) + (this.scoreForm.score || 0)
|
||||
return Math.max(0, Math.min(100, t))
|
||||
}
|
||||
},
|
||||
created() {
|
||||
const d = new Date()
|
||||
this.query.period = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
|
||||
this.load()
|
||||
},
|
||||
methods: {
|
||||
load() {
|
||||
this.loading = true
|
||||
rankPerformance(this.query).then(res => {
|
||||
this.list = res.data || []
|
||||
}).finally(() => { this.loading = false })
|
||||
},
|
||||
countByLetter(letter) {
|
||||
return this.list.filter(r => r.grade && r.grade.charAt(0) === letter).length
|
||||
},
|
||||
indexFn(i) { return i + 4 },
|
||||
cardClass(g) {
|
||||
if (!g) return ''
|
||||
if (g.startsWith('A')) return 'card-a'
|
||||
if (g.startsWith('B')) return 'card-b'
|
||||
if (g.startsWith('C')) return 'card-c'
|
||||
return 'card-d'
|
||||
},
|
||||
gradeClass(g) {
|
||||
if (!g) return ''
|
||||
if (g.startsWith('A')) return 'g-a'
|
||||
if (g.startsWith('B')) return 'g-b'
|
||||
if (g.startsWith('C')) return 'g-c'
|
||||
return 'g-d'
|
||||
},
|
||||
gradeDot(g) { return this.gradeClass(g) },
|
||||
rowClass({ row }) {
|
||||
if (!row.grade) return ''
|
||||
if (row.grade.startsWith('D')) return 'row-d'
|
||||
return ''
|
||||
},
|
||||
totalClass(n) {
|
||||
n = Number(n) || 0
|
||||
if (n >= 90) return 'ok'
|
||||
if (n >= 75) return 'warn'
|
||||
return 'bad'
|
||||
},
|
||||
gradeOf(n) {
|
||||
if (n >= 95) return 'A+'; if (n >= 90) return 'A'; if (n >= 85) return 'A-'
|
||||
if (n >= 80) return 'B+'; if (n >= 75) return 'B'; if (n >= 70) return 'B-'
|
||||
if (n >= 65) return 'C+'; if (n >= 60) return 'C'; if (n >= 55) return 'C-'
|
||||
return 'D'
|
||||
},
|
||||
openDetail(row) {
|
||||
userPerformance(row.user_id, this.query.period).then(res => {
|
||||
const d = res.data || {}
|
||||
this.detail = { user: row.user_nick, list: d.deductions || [] }
|
||||
this.detailOpen = true
|
||||
})
|
||||
},
|
||||
openScore(row) {
|
||||
this.scoreForm = {
|
||||
userId: row.user_id, userName: row.user_nick,
|
||||
baseScore: row.base_score || 80,
|
||||
deduction: row.deduction || 0,
|
||||
reward: row.reward || 0,
|
||||
score: row.bonus == null ? 0 : row.bonus
|
||||
}
|
||||
this.scoreOpen = true
|
||||
},
|
||||
submitScore() {
|
||||
this.scoreSubmitting = true
|
||||
setSubjective(this.scoreForm.userId, this.scoreForm.userName, this.query.period, this.scoreForm.score)
|
||||
.then(() => {
|
||||
this.$modal.msgSuccess('已保存')
|
||||
this.scoreOpen = false
|
||||
this.load()
|
||||
}).finally(() => { this.scoreSubmitting = false })
|
||||
},
|
||||
openAdjust(row) {
|
||||
this.adjustForm = {
|
||||
userId: row.user_id, userName: row.user_nick,
|
||||
dir: -1, absPoints: 5, reason: ''
|
||||
}
|
||||
this.adjustOpen = true
|
||||
},
|
||||
submitAdjust() {
|
||||
this.$refs.adjustFormRef.validate(valid => {
|
||||
if (!valid) {
|
||||
this.$modal.msgWarning('请填写原因(必填)')
|
||||
return
|
||||
}
|
||||
this.adjustSubmitting = true
|
||||
const points = this.adjustForm.dir * this.adjustForm.absPoints
|
||||
const fullReason = '[' + this.currentUserName + '] ' + this.adjustForm.reason.trim()
|
||||
manualAdjust(this.adjustForm.userId, this.adjustForm.userName, points, fullReason)
|
||||
.then(() => {
|
||||
this.$modal.msgSuccess(points > 0 ? '已扣分' : '已加分')
|
||||
this.adjustOpen = false
|
||||
this.load()
|
||||
}).finally(() => { this.adjustSubmitting = false })
|
||||
})
|
||||
},
|
||||
srcLabel(s) { return ({ postpone:'顺延', overdue30:'超期30天', overdue_daily:'日逾期', noreport:'忘报工', manual:'手动', manual_subjective:'高总打分', reward_task:'完成任务', reward_step:'完成步骤', reward_report:'报工', reward_trip:'出差', reward_attend:'全勤' })[s] || s },
|
||||
srcTag(s) {
|
||||
if (s && s.startsWith('reward_')) return 'success'
|
||||
if (s === 'manual') return 'info'
|
||||
return 'danger'
|
||||
},
|
||||
bizLabel(t) { return ({ task:'任务', step:'步骤', requirement:'采购需求' })[t] || (t || '-') }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rank {
|
||||
padding: 16px 20px; background: #f5f7fa; min-height: calc(100vh - 50px);
|
||||
}
|
||||
.muted { color: #909399; font-size: 12px; }
|
||||
.ok { color: #67c23a; }
|
||||
.warn { color: #e6a23c; }
|
||||
.bad { color: #f56c6c; }
|
||||
.of { color: #909399; font-size: 11px; margin-left: 2px; }
|
||||
|
||||
/* ====== Toolbar ====== */
|
||||
.toolbar {
|
||||
background: #fff; padding: 12px 16px; border-radius: 10px; margin-bottom: 14px;
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.left { display: flex; gap: 10px; align-items: center; }
|
||||
.filter-tabs { display: flex; gap: 4px; background: #f4f4f5; padding: 4px; border-radius: 18px; }
|
||||
.tab {
|
||||
padding: 4px 14px; border-radius: 14px; font-size: 12px; cursor: pointer;
|
||||
color: #606266; transition: all .15s; user-select: none;
|
||||
}
|
||||
.tab.active { background: #fff; color: #303133; box-shadow: 0 1px 3px rgba(0,0,0,.1); font-weight: 600; }
|
||||
.tab.ok.active { color: #67c23a; }
|
||||
.tab.b.active { color: #409EFF; }
|
||||
.tab.c.active { color: #e6a23c; }
|
||||
.tab.d.active { color: #f56c6c; }
|
||||
|
||||
/* ====== Podium 前三名 ====== */
|
||||
.podium {
|
||||
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; margin-bottom: 14px;
|
||||
}
|
||||
.podium-slot { position: relative; }
|
||||
.podium-slot.p-1 { order: 2; } /* 第一名居中 */
|
||||
.podium-slot.p-2 { order: 1; }
|
||||
.podium-slot.p-3 { order: 3; }
|
||||
.podium-card {
|
||||
background: #fff; border-radius: 14px; padding: 22px 18px 16px; text-align: center;
|
||||
position: relative; box-shadow: 0 2px 10px rgba(0,0,0,.04);
|
||||
transition: all .2s;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.podium-slot.p-1 .podium-card { margin-top: 0; padding: 30px 18px 18px; }
|
||||
.podium-card:hover { box-shadow: 0 8px 24px rgba(0,0,0,.1); transform: translateY(-3px); }
|
||||
.podium-card.card-a { background: linear-gradient(180deg, #f0f9eb 0%, #fff 50%); border-top: 4px solid #67c23a; }
|
||||
.podium-card.card-b { background: linear-gradient(180deg, #ecf5ff 0%, #fff 50%); border-top: 4px solid #409EFF; }
|
||||
.podium-card.card-c { background: linear-gradient(180deg, #fdf6ec 0%, #fff 50%); border-top: 4px solid #e6a23c; }
|
||||
.podium-card.card-d { background: linear-gradient(180deg, #fef0f0 0%, #fff 50%); border-top: 4px solid #f56c6c; }
|
||||
|
||||
.medal {
|
||||
position: absolute; top: -20px; left: 50%; transform: translateX(-50%);
|
||||
z-index: 2; display: flex; align-items: center; gap: 4px;
|
||||
padding: 6px 14px; border-radius: 20px; color: #fff;
|
||||
font-size: 12px; font-weight: 600;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.15);
|
||||
}
|
||||
.p-1 .medal { background: linear-gradient(135deg, #ffd700, #ff9500); top: -16px; }
|
||||
.p-2 .medal { background: linear-gradient(135deg, #d0d4d8, #909399); }
|
||||
.p-3 .medal { background: linear-gradient(135deg, #cd7f32, #a0522d); }
|
||||
.medal i { font-size: 14px; }
|
||||
|
||||
.p-grade {
|
||||
font-size: 60px; font-weight: 800; line-height: 1; margin: 6px 0 10px;
|
||||
color: #67c23a;
|
||||
}
|
||||
.podium-slot.p-1 .p-grade { font-size: 72px; }
|
||||
.p-grade.g-a { color: #67c23a; }
|
||||
.p-grade.g-b { color: #409EFF; }
|
||||
.p-grade.g-c { color: #e6a23c; }
|
||||
.p-grade.g-d { color: #f56c6c; }
|
||||
|
||||
.p-name { font-size: 15px; font-weight: 600; color: #303133; margin-bottom: 6px; }
|
||||
.p-score { display: flex; justify-content: center; align-items: baseline; gap: 3px; margin-bottom: 10px; }
|
||||
.p-score .num { font-size: 26px; font-weight: 700; }
|
||||
.p-score .num.ok { color: #67c23a; }
|
||||
.p-score .num.warn { color: #e6a23c; }
|
||||
.p-score .num.bad { color: #f56c6c; }
|
||||
.p-score .div { font-size: 12px; color: #909399; }
|
||||
.p-mini {
|
||||
display: flex; justify-content: center; gap: 8px; font-size: 11px;
|
||||
color: #606266; padding: 8px 0; border-top: 1px dashed #ebeef5;
|
||||
border-bottom: 1px dashed #ebeef5; margin-bottom: 10px;
|
||||
}
|
||||
.p-actions { display: flex; gap: 6px; justify-content: center; margin-bottom: 8px; }
|
||||
|
||||
/* ====== Rest Table ====== */
|
||||
.rest { background: #fff; border-radius: 10px; padding: 10px 14px; }
|
||||
.user-cell { display: flex; align-items: center; gap: 8px; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; background: #c0c4cc; }
|
||||
.dot.g-a { background: #67c23a; }
|
||||
.dot.g-b { background: #409EFF; }
|
||||
.dot.g-c { background: #e6a23c; }
|
||||
.dot.g-d { background: #f56c6c; }
|
||||
|
||||
.grade-tag {
|
||||
display: inline-block; padding: 2px 12px; border-radius: 12px;
|
||||
color: #fff; font-weight: 600; font-size: 12px;
|
||||
}
|
||||
.grade-tag.g-a { background: #67c23a; }
|
||||
.grade-tag.g-b { background: #409EFF; }
|
||||
.grade-tag.g-c { background: #e6a23c; }
|
||||
.grade-tag.g-d { background: #f56c6c; }
|
||||
|
||||
::v-deep .row-d { background: #fff5f5 !important; }
|
||||
|
||||
/* ====== Dialogs ====== */
|
||||
.form-body { padding: 0 8px; }
|
||||
.form-user {
|
||||
display: flex; align-items: baseline; gap: 12px; margin-bottom: 18px;
|
||||
padding-bottom: 14px; border-bottom: 1px dashed #ebeef5;
|
||||
}
|
||||
.form-user .u-name { font-size: 18px; font-weight: 600; color: #303133; }
|
||||
.form-user .u-period { color: #909399; font-size: 13px; }
|
||||
.preview { margin-left: 14px; font-size: 15px; font-weight: 600; }
|
||||
.op-name { color: #606266; }
|
||||
|
||||
.info-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 18px; }
|
||||
.info-tile { background: #fafafa; border-radius: 6px; padding: 10px; text-align: center; }
|
||||
.i-lbl { font-size: 11px; color: #909399; margin-bottom: 3px; }
|
||||
.i-val { font-size: 20px; font-weight: 700; color: #303133; }
|
||||
.i-val.ok { color: #67c23a; }
|
||||
.i-val.bad { color: #f56c6c; }
|
||||
|
||||
.slider-label { color: #303133; font-weight: 500; margin-bottom: 14px; font-size: 13px; }
|
||||
.preview-total {
|
||||
margin-top: 24px; padding: 12px 16px; background: #fafafa;
|
||||
border-radius: 8px; font-size: 13px; color: #606266; line-height: 1.7;
|
||||
}
|
||||
.preview-total .calc { color: #909399; font-family: Menlo, Consolas, monospace; }
|
||||
.preview-total b { font-size: 22px; margin: 0 4px; }
|
||||
.preview-total b.ok { color: #67c23a; }
|
||||
.preview-total b.warn { color: #e6a23c; }
|
||||
.preview-total b.bad { color: #f56c6c; }
|
||||
|
||||
.detail-head { display: flex; gap: 14px; align-items: baseline; margin-bottom: 14px; padding-bottom: 8px; border-bottom: 1px dashed #ebeef5; }
|
||||
.detail-head .u-name { font-size: 16px; font-weight: 600; }
|
||||
.detail-head .u-period { color: #909399; }
|
||||
</style>
|
||||
271
ruoyi-ui/src/views/oa/project/dashboardOverview/index.vue
Normal file
271
ruoyi-ui/src/views/oa/project/dashboardOverview/index.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div class="cockpit">
|
||||
<!-- ============ 顶部全局 KPI ============ -->
|
||||
<div class="hero">
|
||||
<div class="hero-grid">
|
||||
<div class="hero-tile">
|
||||
<div class="hero-num">{{ summary.running || 0 }}</div>
|
||||
<div class="hero-lbl">进行中项目</div>
|
||||
</div>
|
||||
<div class="hero-tile">
|
||||
<div class="hero-num muted">{{ summary.finished || 0 }}</div>
|
||||
<div class="hero-lbl">已完结项目</div>
|
||||
</div>
|
||||
<div class="hero-tile" :class="(summary.overdue_running||0)>0 ? 'danger':''">
|
||||
<div class="hero-num">{{ summary.overdue_running || 0 }}</div>
|
||||
<div class="hero-lbl">在跑已超期</div>
|
||||
</div>
|
||||
<div class="hero-tile" :class="(summary.total_pending_approvals||0)>0 ? 'warning':''">
|
||||
<div class="hero-num">{{ summary.total_pending_approvals || 0 }}</div>
|
||||
<div class="hero-lbl">全局待审批</div>
|
||||
</div>
|
||||
<div class="hero-tile" :class="(summary.total_overdue_tasks||0)>0 ? 'danger':''">
|
||||
<div class="hero-num">{{ summary.total_overdue_tasks || 0 }}</div>
|
||||
<div class="hero-lbl">超期任务总数</div>
|
||||
</div>
|
||||
<div class="hero-tile big">
|
||||
<div class="hero-num">¥{{ fmtMoney(summary.running_funds) }}</div>
|
||||
<div class="hero-lbl">在跑项目总金额</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 筛选条 ============ -->
|
||||
<div class="toolbar">
|
||||
<div class="left-tools">
|
||||
<el-select v-model="query.status" placeholder="状态" clearable size="small" style="width:120px;" @change="load">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="进行中" value="0" />
|
||||
<el-option label="已完结" value="1" />
|
||||
<el-option label="已暂停" value="2" />
|
||||
</el-select>
|
||||
<el-input v-model="query.nameLike" placeholder="项目名/编号" clearable size="small" style="width:240px;"
|
||||
@keyup.enter.native="load" @clear="load">
|
||||
<i slot="prefix" class="el-input__icon el-icon-search"></i>
|
||||
</el-input>
|
||||
<el-button size="small" type="primary" icon="el-icon-search" @click="load">查询</el-button>
|
||||
<el-button size="small" icon="el-icon-refresh" @click="load">刷新</el-button>
|
||||
<div class="filter-pills">
|
||||
<span class="pill" :class="{active: filter==='all'}" @click="filter='all'">全部 {{ projects.length }}</span>
|
||||
<span class="pill red" :class="{active: filter==='red'}" @click="filter='red'">红 {{ levelCount('red') }}</span>
|
||||
<span class="pill yellow" :class="{active: filter==='yellow'}" @click="filter='yellow'">黄 {{ levelCount('yellow') }}</span>
|
||||
<span class="pill green" :class="{active: filter==='green'}" @click="filter='green'">绿 {{ levelCount('green') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 项目卡片网格 ============ -->
|
||||
<div v-loading="loading" class="grid-wrap">
|
||||
<div class="grid">
|
||||
<div v-for="row in filteredProjects" :key="row.project_id"
|
||||
class="proj-card" :class="'lv-' + (row.health && row.health.level || 'green')"
|
||||
@click="openPanorama(row)">
|
||||
<div class="card-head">
|
||||
<span class="dot" :class="row.health && row.health.level"></span>
|
||||
<span v-if="row.project_code" class="code">{{ row.project_code }}</span>
|
||||
<span class="status">{{ projectStatusText(row.project_status) }}</span>
|
||||
</div>
|
||||
<div class="card-title">{{ row.project_name }}</div>
|
||||
<div class="card-meta">
|
||||
<span><i class="el-icon-user"></i> {{ row.functionary_nick || '—' }}</span>
|
||||
<span><i class="el-icon-date"></i> {{ fmtDate(row.begin_time) }} ~ {{ fmtDate(row.finish_time) }}</span>
|
||||
<span v-if="row.postpone_count > 0" class="warn"><i class="el-icon-warning-outline"></i> 延期{{ row.postpone_count }}</span>
|
||||
</div>
|
||||
<div class="card-stats">
|
||||
<div class="stat">
|
||||
<div class="s-lbl">合同</div>
|
||||
<div class="s-val">¥{{ fmtMoney(row.funds) }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="s-lbl">已收</div>
|
||||
<div class="s-val ok">¥{{ fmtMoney(row.payment_done) }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="s-lbl">成本</div>
|
||||
<div class="s-val">¥{{ fmtMoney(row.cost) }}</div>
|
||||
</div>
|
||||
<div class="stat" :class="(row.pending_approvals||0)>0 ? 'attn':''">
|
||||
<div class="s-lbl">待审批</div>
|
||||
<div class="s-val">{{ row.pending_approvals || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat" :class="(row.overdue_tasks||0)>0 ? 'bad':''">
|
||||
<div class="s-lbl">超期</div>
|
||||
<div class="s-val">{{ row.overdue_tasks || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="row.health && row.health.reasons && row.health.reasons.length" class="reason-row">
|
||||
<i class="el-icon-warning"></i>
|
||||
{{ row.health.reasons.join(' · ') }}
|
||||
</div>
|
||||
<div class="card-foot">
|
||||
<span>全景视图</span>
|
||||
<i class="el-icon-arrow-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!loading && !filteredProjects.length" description="没有匹配的项目" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getProjectDashboardOverview } from '@/api/oa/projectOverview'
|
||||
|
||||
export default {
|
||||
name: 'OaProjectDashboardOverview',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
query: { status: '0', nameLike: '', limit: 200 },
|
||||
filter: 'all',
|
||||
summary: {},
|
||||
projects: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredProjects() {
|
||||
if (this.filter === 'all') return this.projects
|
||||
return this.projects.filter(p => p.health && p.health.level === this.filter)
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.load()
|
||||
},
|
||||
methods: {
|
||||
load() {
|
||||
this.loading = true
|
||||
getProjectDashboardOverview(this.query).then(res => {
|
||||
const d = res.data || {}
|
||||
this.summary = d.summary || {}
|
||||
this.projects = d.projects || []
|
||||
}).finally(() => { this.loading = false })
|
||||
},
|
||||
openPanorama(row) {
|
||||
this.$router.push({ path: '/project/panorama', query: { projectId: row.project_id } })
|
||||
},
|
||||
levelCount(lv) {
|
||||
return this.projects.filter(p => p.health && p.health.level === lv).length
|
||||
},
|
||||
fmtDate(d) { if (!d) return '-'; const s = String(d); return s.length >= 10 ? s.substring(0, 10) : s },
|
||||
fmtMoney(v) {
|
||||
if (v == null) return '0'
|
||||
const n = parseFloat(v)
|
||||
if (isNaN(n)) return v
|
||||
if (n >= 100000000) return (n/100000000).toFixed(2) + ' 亿'
|
||||
if (n >= 10000) return (n/10000).toFixed(2) + ' 万'
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
},
|
||||
projectStatusText(s) { return ({ '0':'进行中','1':'已完结','2':'已暂停' })[String(s)] || s }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cockpit {
|
||||
padding: 16px 20px;
|
||||
background: #f5f7fa;
|
||||
min-height: calc(100vh - 50px);
|
||||
}
|
||||
|
||||
/* ===== HERO ===== */
|
||||
.hero { margin-bottom: 14px; }
|
||||
.hero-grid {
|
||||
display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px;
|
||||
}
|
||||
.hero-tile {
|
||||
background: #fff; border-radius: 8px; padding: 18px 20px;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
border-left: 4px solid #409EFF;
|
||||
transition: transform .2s, box-shadow .2s;
|
||||
}
|
||||
.hero-tile:hover { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(0,0,0,0.05); }
|
||||
.hero-tile.danger { border-left-color: #f56c6c; }
|
||||
.hero-tile.warning { border-left-color: #e6a23c; }
|
||||
.hero-tile.big .hero-num { font-size: 24px; }
|
||||
.hero-num {
|
||||
font-size: 32px; font-weight: 700; color: #303133; line-height: 1.1;
|
||||
}
|
||||
.hero-tile.danger .hero-num { color: #f56c6c; }
|
||||
.hero-tile.warning .hero-num { color: #e6a23c; }
|
||||
.hero-num.muted { color: #909399; }
|
||||
.hero-lbl { font-size: 13px; color: #909399; }
|
||||
|
||||
/* ===== TOOLBAR ===== */
|
||||
.toolbar {
|
||||
background: #fff; padding: 12px 16px; border-radius: 8px; margin-bottom: 14px;
|
||||
}
|
||||
.left-tools { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.filter-pills { margin-left: 12px; display: flex; gap: 6px; }
|
||||
.pill {
|
||||
padding: 4px 12px; border-radius: 14px; font-size: 12px; cursor: pointer;
|
||||
background: #f4f4f5; color: #606266; user-select: none; transition: all .2s;
|
||||
}
|
||||
.pill:hover { background: #e9eaeb; }
|
||||
.pill.active { background: #409EFF; color: #fff; }
|
||||
.pill.red { background: #fef0f0; color: #f56c6c; }
|
||||
.pill.red.active { background: #f56c6c; color: #fff; }
|
||||
.pill.yellow { background: #fdf6ec; color: #e6a23c; }
|
||||
.pill.yellow.active { background: #e6a23c; color: #fff; }
|
||||
.pill.green { background: #f0f9eb; color: #67c23a; }
|
||||
.pill.green.active { background: #67c23a; color: #fff; }
|
||||
|
||||
/* ===== 项目卡片网格 ===== */
|
||||
.grid-wrap { background: transparent; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 14px; }
|
||||
.proj-card {
|
||||
background: #fff; border-radius: 10px; padding: 16px 18px; cursor: pointer;
|
||||
border-top: 4px solid #67c23a; transition: all .2s;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.03);
|
||||
}
|
||||
.proj-card:hover { transform: translateY(-3px); box-shadow: 0 10px 24px rgba(0,0,0,.08); }
|
||||
.proj-card.lv-red { border-top-color: #f56c6c; }
|
||||
.proj-card.lv-yellow { border-top-color: #e6a23c; }
|
||||
.proj-card.lv-green { border-top-color: #67c23a; }
|
||||
|
||||
.card-head { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #909399; margin-bottom: 6px; }
|
||||
.card-head .dot { width: 10px; height: 10px; border-radius: 50%; background: #c0c4cc; }
|
||||
.card-head .dot.red { background: #f56c6c; }
|
||||
.card-head .dot.yellow { background: #e6a23c; }
|
||||
.card-head .dot.green { background: #67c23a; }
|
||||
.card-head .code { font-family: Menlo, Consolas, monospace; background: #f4f4f5; padding: 1px 6px; border-radius: 3px; color: #606266; }
|
||||
.card-head .status { margin-left: auto; }
|
||||
|
||||
.card-title {
|
||||
font-size: 16px; font-weight: 600; color: #303133;
|
||||
line-height: 1.4; margin-bottom: 10px;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
overflow: hidden; word-break: break-all;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex; flex-wrap: wrap; gap: 10px 14px; font-size: 12px; color: #606266; margin-bottom: 12px;
|
||||
}
|
||||
.card-meta i { margin-right: 3px; color: #909399; }
|
||||
.card-meta .warn { color: #e6a23c; font-weight: 500; }
|
||||
.card-meta .warn i { color: #e6a23c; }
|
||||
|
||||
.card-stats {
|
||||
display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; padding: 10px 0;
|
||||
border-top: 1px dashed #ebeef5; border-bottom: 1px dashed #ebeef5;
|
||||
}
|
||||
.stat { text-align: center; }
|
||||
.s-lbl { font-size: 11px; color: #909399; margin-bottom: 2px; }
|
||||
.s-val { font-size: 13px; font-weight: 600; color: #303133; }
|
||||
.s-val.ok { color: #67c23a; }
|
||||
.stat.bad .s-val { color: #f56c6c; }
|
||||
.stat.attn .s-val { color: #e6a23c; }
|
||||
|
||||
.reason-row {
|
||||
font-size: 12px; color: #f56c6c; padding: 8px 0 0;
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.proj-card.lv-yellow .reason-row { color: #e6a23c; }
|
||||
.proj-card.lv-green .reason-row { display: none; }
|
||||
|
||||
.card-foot {
|
||||
margin-top: 10px; display: flex; justify-content: space-between; align-items: center;
|
||||
color: #409EFF; font-size: 13px; font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
624
ruoyi-ui/src/views/oa/project/panorama/index.vue
Normal file
624
ruoyi-ui/src/views/oa/project/panorama/index.vue
Normal file
@@ -0,0 +1,624 @@
|
||||
<template>
|
||||
<div class="overview">
|
||||
<!-- 顶部操作条 -->
|
||||
<div class="topbar" v-if="projectId">
|
||||
<el-button size="small" icon="el-icon-back" @click="goBack">返回项目列表</el-button>
|
||||
<el-button size="small" icon="el-icon-refresh" @click="load">刷新</el-button>
|
||||
<span v-if="lastLoadAt" class="muted">最后加载 {{ lastLoadAt }}</span>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!projectId" description="请从项目列表点击「全景」进入">
|
||||
<el-button type="primary" @click="goBack">前往项目列表</el-button>
|
||||
</el-empty>
|
||||
|
||||
<div v-else v-loading="loading">
|
||||
<!-- ============ 健康度卡 ============ -->
|
||||
<el-card v-if="data.health && data.health.level !== 'green'"
|
||||
class="health" :class="'health--' + data.health.level" shadow="never">
|
||||
<div class="health-main">
|
||||
<div class="health-icon">
|
||||
<i v-if="data.health.level==='red'" class="el-icon-warning"></i>
|
||||
<i v-else class="el-icon-warning-outline"></i>
|
||||
</div>
|
||||
<div class="health-text">
|
||||
<div v-if="data.health.redSignals && data.health.redSignals.length" class="signal-row">
|
||||
<el-tag v-for="(s,i) in data.health.redSignals" :key="'r-'+i" type="danger" size="small" style="margin: 2px 6px 2px 0;">{{ s }}</el-tag>
|
||||
</div>
|
||||
<div v-if="data.health.yellowSignals && data.health.yellowSignals.length" class="signal-row">
|
||||
<el-tag v-for="(s,i) in data.health.yellowSignals" :key="'y-'+i" type="warning" size="small" style="margin: 2px 6px 2px 0;">{{ s }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- ============ 项目头 + 大 KPI ============ -->
|
||||
<el-card class="header" shadow="never" v-if="data.header">
|
||||
<div class="header-top">
|
||||
<div class="header-name">
|
||||
<h1>{{ data.header.project_name }}</h1>
|
||||
<div class="meta">
|
||||
<span v-if="data.header.project_code">编号 {{ data.header.project_code }}</span>
|
||||
<span v-if="data.header.functionary_nick">负责人 {{ data.header.functionary_nick }}</span>
|
||||
<span v-if="data.customer && data.customer.name">客户 {{ data.customer.name }}</span>
|
||||
<span>工期 {{ fmtDate(data.header.begin_time) }} → {{ fmtDate(data.header.finish_time) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-button type="text" icon="el-icon-edit" @click="goEditProject">查看 / 编辑项目</el-button>
|
||||
</div>
|
||||
|
||||
<div class="kpi-row">
|
||||
<kpi-tile label="项目金额" :value="'¥'+fmtMoney(data.header.funds)" />
|
||||
<kpi-tile label="已收款" :value="'¥'+fmtMoney(data.finance.paymentDone)" tone="ok" />
|
||||
<kpi-tile label="已记成本" :value="'¥'+fmtMoney(data.finance.cost)" tone="warn" />
|
||||
<kpi-tile label="净现金" :value="'¥'+fmtMoney(data.finance.netCash)" :tone="netCashTone" />
|
||||
<kpi-tile label="进度" :value="progressText" :sub="progressSub" :tone="scheduleTone" />
|
||||
<kpi-tile label="待审批" :value="String(pendingApprovalCount)" :tone="pendingApprovalCount>0?'warn':'mute'" :clickable="pendingApprovalCount>0" @click="goApproval" />
|
||||
<kpi-tile label="超期任务" :value="String(data.tasks.overdueCount||0)" :tone="(data.tasks.overdueCount||0)>0?'bad':'mute'" :clickable="(data.tasks.overdueCount||0)>0" @click="goTask" />
|
||||
<kpi-tile label="延期次数" :value="String(data.header.postpone_count||0)" :tone="(data.header.postpone_count||0)>=2?'bad':((data.header.postpone_count||0)>=1?'warn':'mute')" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- ============ 主分卡(每张都有跳转)============ -->
|
||||
<el-row :gutter="12">
|
||||
<!-- A 合同 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="card" shadow="never">
|
||||
<div slot="header" class="card-head">
|
||||
<span><b>合同</b>
|
||||
<el-tag v-if="data.contracts.length" size="mini" type="info" style="margin-left: 6px;">
|
||||
{{ data.contracts.length }} 份 / ¥{{ fmtMoney(data.finance.contractAmount) }}
|
||||
</el-tag>
|
||||
</span>
|
||||
<el-button type="text" @click="goContract">查看全部 →</el-button>
|
||||
</div>
|
||||
<el-empty v-if="!data.contracts.length" :image-size="60" description="无合同" />
|
||||
<el-table v-else :data="data.contracts" size="mini" border>
|
||||
<el-table-column label="合同名称" prop="contract_name" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="金额" width="120" align="right">
|
||||
<template slot-scope="{row}">¥{{ fmtMoney(row.contract_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="签订" width="100">
|
||||
<template slot-scope="{row}">{{ fmtDate(row.sign_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="审批" width="70" align="center">
|
||||
<template slot-scope="{row}"><approval-tag :v="row.approval_status" /></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- B 进度 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="card" shadow="never">
|
||||
<div slot="header" class="card-head">
|
||||
<span><b>进度计划</b>
|
||||
<span v-if="data.schedule && data.schedule.steps" class="muted">
|
||||
· 共 {{ data.schedule.totalSteps }} 步 / 已完成 {{ data.schedule.doneSteps }}
|
||||
</span>
|
||||
<el-tag v-if="data.schedule && data.schedule.overdue" size="mini" type="danger" style="margin-left:6px;">已超期</el-tag>
|
||||
<el-tag v-if="data.schedule && data.schedule.delayCount > 0" size="mini" type="warning" style="margin-left:4px;">延期 {{ data.schedule.delayCount }} 次</el-tag>
|
||||
</span>
|
||||
<el-button type="text" @click="goSchedule">查看完整 →</el-button>
|
||||
</div>
|
||||
<el-empty v-if="!data.schedule || !data.schedule.steps || !data.schedule.steps.length" :image-size="60" description="未创建计划" />
|
||||
<el-steps v-else direction="vertical" :active="Math.max(0, data.schedule.doneSteps)" finish-status="success">
|
||||
<el-step v-for="s in data.schedule.steps.slice(0, 6)" :key="s.track_id"
|
||||
:title="s.step_name"
|
||||
:description="(s.plan_end ? '计划 ' + fmtDate(s.plan_end) : '') + (stepStatusText(s.status) ? ' · ' + stepStatusText(s.status) : '')" />
|
||||
</el-steps>
|
||||
<div v-if="data.schedule && data.schedule.steps && data.schedule.steps.length > 6" class="muted" style="text-align:center; padding-top:8px;">
|
||||
还有 {{ data.schedule.steps.length - 6 }} 步 · <el-link type="primary" @click="goSchedule">查看完整</el-link>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- C 采购需求 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="card" shadow="never">
|
||||
<div slot="header" class="card-head">
|
||||
<span><b>采购需求</b><span class="muted"> · 最近 {{ (data.requirements.recent || []).length }} 条</span></span>
|
||||
<el-button type="text" @click="goRequirement">查看全部 →</el-button>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<stat-pill label="未采购" :n="data.requirements.byStatus[0]" />
|
||||
<stat-pill label="采购中" :n="data.requirements.byStatus[1]" type="warning" />
|
||||
<stat-pill label="完成" :n="data.requirements.byStatus[2]" type="success" />
|
||||
<stat-pill label="取消" :n="data.requirements.byStatus[3]" type="info" />
|
||||
</div>
|
||||
<el-table v-if="data.requirements.recent && data.requirements.recent.length"
|
||||
:data="data.requirements.recent.slice(0, 5)" size="mini" border style="margin-top:8px;">
|
||||
<el-table-column label="需求" prop="title" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template slot-scope="{row}">{{ reqStatusText(row.status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="审批" width="70" align="center">
|
||||
<template slot-scope="{row}"><approval-tag :v="row.approval_status" /></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- D 到货 + 库房 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="card" shadow="never">
|
||||
<div slot="header" class="card-head">
|
||||
<span><b>到货 & 库房</b></span>
|
||||
<el-button type="text" @click="goArrival">查看到货明细 →</el-button>
|
||||
</div>
|
||||
<div class="sub-title">到货明细</div>
|
||||
<div class="stat-row">
|
||||
<stat-pill label="待发货" :n="data.arrivals.byStatus[0]" />
|
||||
<stat-pill label="在途" :n="data.arrivals.byStatus[1]" type="warning" />
|
||||
<stat-pill label="已到货" :n="data.arrivals.byStatus[2]" type="success" />
|
||||
</div>
|
||||
<div class="sub-title" style="margin-top:12px;">库房申请</div>
|
||||
<div class="stat-row">
|
||||
<stat-pill v-for="(v, k) in data.warehouse.byStatus" :key="'wh-'+k"
|
||||
:label="warehouseStatusText(k)" :n="v" />
|
||||
<span v-if="!data.warehouse.byStatus || !Object.keys(data.warehouse.byStatus).length" class="muted">暂无</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- E 财务 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="card" shadow="never">
|
||||
<div slot="header" class="card-head">
|
||||
<span><b>财务</b></span>
|
||||
<el-button type="text" @click="goFinance">查看流水 →</el-button>
|
||||
</div>
|
||||
<table class="fin-table">
|
||||
<tr><td>合同总金额</td><td class="num">¥{{ fmtMoney(data.finance.contractAmount) }}</td></tr>
|
||||
<tr><td>计划收款总额</td><td class="num">¥{{ fmtMoney(data.finance.paymentTotal) }}</td></tr>
|
||||
<tr><td>已收款</td><td class="num ok">¥{{ fmtMoney(data.finance.paymentDone) }}</td></tr>
|
||||
<tr><td>待收款</td><td class="num warn">¥{{ fmtMoney(data.finance.paymentRemain) }}</td></tr>
|
||||
<tr><td>已记成本</td><td class="num">¥{{ fmtMoney(data.finance.cost) }}</td></tr>
|
||||
<tr class="strong"><td>净现金(收款 − 成本)</td><td class="num" :class="netCashTone">¥{{ fmtMoney(data.finance.netCash) }}</td></tr>
|
||||
</table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- F 任务 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="card" shadow="never">
|
||||
<div slot="header" class="card-head">
|
||||
<span><b>任务</b>
|
||||
<el-tag v-if="data.tasks.overdueCount > 0" size="mini" type="danger" style="margin-left:6px;">超期 {{ data.tasks.overdueCount }}</el-tag>
|
||||
</span>
|
||||
<el-button type="text" @click="goTask">发放任务 →</el-button>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<stat-pill label="执行中" :n="data.tasks.byState[0]" type="warning" />
|
||||
<stat-pill label="待验收" :n="data.tasks.byState[1]" />
|
||||
<stat-pill label="完成" :n="data.tasks.byState[2]" type="success" />
|
||||
<stat-pill label="延期申请" :n="data.tasks.byState[15]" type="danger" />
|
||||
</div>
|
||||
<el-table v-if="data.tasks.recent && data.tasks.recent.length"
|
||||
:data="data.tasks.recent.slice(0, 6)" size="mini" border style="margin-top:8px;">
|
||||
<el-table-column label="任务" prop="task_title" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="执行人" prop="worker_nick" width="90" />
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<el-tag size="mini" :type="taskStateTag(row.state)">{{ taskStateText(row.state) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- G 团队 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="card" shadow="never">
|
||||
<div slot="header" class="card-head">
|
||||
<span><b>团队</b><span class="muted"> · {{ data.team.memberCount }} 人参与</span></span>
|
||||
</div>
|
||||
<p><b>项目负责人</b>:{{ data.team.functionaryNick || '未指定' }}</p>
|
||||
<div>
|
||||
<el-tag v-for="m in data.team.members" :key="m.user_id"
|
||||
size="small" style="margin: 2px 6px 2px 0;">{{ m.nick_name }}</el-tag>
|
||||
<span v-if="!data.team.members || !data.team.members.length" class="muted">暂无成员</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- H 报告 + 会议 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="card" shadow="never">
|
||||
<div slot="header" class="card-head">
|
||||
<span><b>报告 & 会议</b></span>
|
||||
<el-button type="text" @click="goReport">日志 →</el-button>
|
||||
</div>
|
||||
<div class="sub-title">最近工作日志</div>
|
||||
<ul class="list-tight" v-if="data.reports && data.reports.length">
|
||||
<li v-for="r in data.reports" :key="'rp-'+r.id">
|
||||
<span class="muted">{{ fmtDateTime(r.create_time) }}</span>
|
||||
<span> · {{ r.user_name || r.user_id }}</span>
|
||||
<span> · {{ shortText(r.content, 60) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="muted">暂无日志</p>
|
||||
|
||||
<div class="sub-title" style="margin-top:12px;">最近会议纪要</div>
|
||||
<ul class="list-tight" v-if="data.meetings && data.meetings.length">
|
||||
<li v-for="m in data.meetings" :key="'mt-'+m.id">
|
||||
<span class="muted">{{ fmtDate(m.meeting_date) }}</span>
|
||||
<span> · {{ m.subject }}</span>
|
||||
<span v-if="m.location" class="muted"> @{{ m.location }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="muted">暂无会议纪要</p>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- ============ 制造主线(fad_rm)============ -->
|
||||
<el-card v-if="data.manufacturing" class="card mfg" shadow="never">
|
||||
<div slot="header" class="card-head">
|
||||
<span><b>制造主线(轧机厂)</b>
|
||||
<el-tag v-if="data.manufacturing.checklistTotal" size="mini" type="info" style="margin-left:6px;">
|
||||
验收清单 {{ data.manufacturing.checklistDone }}/{{ data.manufacturing.checklistTotal }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8">
|
||||
<div class="sub-title">安装进度</div>
|
||||
<el-empty v-if="!data.manufacturing.install.length" :image-size="50" description="无" />
|
||||
<el-table v-else :data="data.manufacturing.install" size="mini" border>
|
||||
<el-table-column label="工项" prop="item_name" show-overflow-tooltip />
|
||||
<el-table-column label="计划完成" width="100">
|
||||
<template slot-scope="{row}">{{ fmtDate(row.plan_end) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="70" align="center">
|
||||
<template slot-scope="{row}"><installStatusTag :v="row.status" /></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="sub-title">调试记录(近 10 条)</div>
|
||||
<el-empty v-if="!data.manufacturing.commissioning.length" :image-size="50" description="无" />
|
||||
<ul class="list-tight" v-else>
|
||||
<li v-for="r in data.manufacturing.commissioning" :key="'cm-'+r.record_id">
|
||||
<span class="muted">{{ fmtDate(r.record_date) }}</span>
|
||||
· {{ r.param_name }} = <b>{{ r.param_value }}</b>
|
||||
<el-tag size="mini" :type="r.result==='Y'||r.result==='1' ? 'success':'danger'" style="margin-left:4px;">
|
||||
{{ (r.result==='Y'||r.result==='1') ? '合格' : '不合格' }}
|
||||
</el-tag>
|
||||
</li>
|
||||
</ul>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="sub-title">验收清单</div>
|
||||
<el-empty v-if="!data.manufacturing.checklist.length" :image-size="50" description="无" />
|
||||
<ul class="checklist" v-else>
|
||||
<li v-for="c in data.manufacturing.checklist" :key="'ck-'+c.check_id"
|
||||
:class="(c.is_checked==='Y'||c.is_checked==='1') ? 'done' : ''">
|
||||
<i :class="(c.is_checked==='Y'||c.is_checked==='1') ? 'el-icon-success ok' : 'el-icon-circle-check'"></i>
|
||||
{{ c.item_text }}
|
||||
</li>
|
||||
</ul>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- ============ 审批时间线 ============ -->
|
||||
<el-card class="card" shadow="never">
|
||||
<div slot="header" class="card-head">
|
||||
<span><b>审批活动</b></span>
|
||||
<el-button type="text" @click="goApproval">审批中心 →</el-button>
|
||||
</div>
|
||||
<el-empty v-if="!data.approvals || !data.approvals.length" :image-size="60" description="暂无审批活动" />
|
||||
<el-timeline v-else>
|
||||
<el-timeline-item v-for="a in data.approvals" :key="a.id"
|
||||
:timestamp="fmtDateTime(a.apply_time)"
|
||||
:type="approvalTimelineType(a.status)">
|
||||
<b>{{ businessName(a.business_type) }} · {{ a.business_title || ('#' + a.business_id) }}</b>
|
||||
<div>
|
||||
申请人:{{ a.apply_user_name || '-' }}
|
||||
<span style="margin-left:8px;">{{ a.sign_type === 1 ? '或签' : '会签' }}</span>
|
||||
<approval-tag :v="a.status" style="margin-left:8px;" />
|
||||
<span v-if="a.finish_time" class="muted" style="margin-left:8px;">终结于 {{ fmtDateTime(a.finish_time) }}</span>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</el-card>
|
||||
|
||||
<!-- ============ 操作日志 ============ -->
|
||||
<el-card class="card" shadow="never">
|
||||
<div slot="header" class="card-head">
|
||||
<span><b>项目操作日志</b></span>
|
||||
</div>
|
||||
<el-empty v-if="!data.operationLog || !data.operationLog.length" :image-size="60" description="暂无日志" />
|
||||
<el-table v-else :data="data.operationLog" size="mini" border>
|
||||
<el-table-column label="时间" width="160">
|
||||
<template slot-scope="{row}">{{ fmtDateTime(row.operate_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="对象" prop="target_name" width="180" show-overflow-tooltip />
|
||||
<el-table-column label="操作" prop="operation_desc" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="操作人" prop="operator" width="100" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getProjectOverview } from '@/api/oa/projectOverview'
|
||||
|
||||
// ============ 内联小组件 ============
|
||||
const ApprovalTag = {
|
||||
props: ['v'],
|
||||
render(h) {
|
||||
if (this.v == null) return h('span', { class: 'muted' }, '-')
|
||||
const map = { 0: ['待审','warning'], 1: ['通过','success'], 2: ['驳回','danger'], 3: ['撤回','info'] }
|
||||
const m = map[this.v] || ['-', 'info']
|
||||
return h('el-tag', { props: { size: 'mini', type: m[1] } }, m[0])
|
||||
}
|
||||
}
|
||||
const StatPill = {
|
||||
props: ['label', 'n', 'type'],
|
||||
render(h) {
|
||||
const count = this.n == null ? 0 : this.n
|
||||
return h('div', { class: 'stat-pill stat-pill--' + (this.type || 'default') }, [
|
||||
h('div', { class: 'pill-n' }, String(count)),
|
||||
h('div', { class: 'pill-label' }, this.label)
|
||||
])
|
||||
}
|
||||
}
|
||||
const KpiTile = {
|
||||
props: ['label', 'value', 'sub', 'tone', 'clickable'],
|
||||
render(h) {
|
||||
return h('div', {
|
||||
class: ['kpi-tile', 'kpi-tile--' + (this.tone || 'default'), this.clickable ? 'kpi-tile--click' : ''],
|
||||
on: { click: () => this.clickable && this.$emit('click') }
|
||||
}, [
|
||||
h('div', { class: 'kpi-label' }, this.label),
|
||||
h('div', { class: 'kpi-val' }, this.value),
|
||||
this.sub ? h('div', { class: 'kpi-sub' }, this.sub) : null
|
||||
])
|
||||
}
|
||||
}
|
||||
const InstallStatusTag = {
|
||||
props: ['v'],
|
||||
render(h) {
|
||||
const map = { 'planned': ['未开始','info'], 'doing': ['进行中','warning'], 'done': ['完成','success'], 'delayed': ['延期','danger'] }
|
||||
const m = map[this.v] || [this.v || '-', 'info']
|
||||
return h('el-tag', { props: { size: 'mini', type: m[1] } }, m[0])
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'OaProjectPanorama',
|
||||
components: { ApprovalTag, StatPill, KpiTile, InstallStatusTag },
|
||||
data() {
|
||||
return {
|
||||
projectId: null,
|
||||
loading: false,
|
||||
data: this.emptyData(),
|
||||
lastLoadAt: null
|
||||
}
|
||||
},
|
||||
created() {
|
||||
if (this.$route.query.projectId) {
|
||||
this.projectId = String(this.$route.query.projectId)
|
||||
this.load()
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'$route.query.projectId'(v) {
|
||||
if (v) { this.projectId = String(v); this.load() }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
netCashTone() {
|
||||
const v = this.data.finance && this.data.finance.netCash
|
||||
const n = parseFloat(v)
|
||||
if (isNaN(n)) return 'mute'
|
||||
return n >= 0 ? 'ok' : 'bad'
|
||||
},
|
||||
progressText() {
|
||||
const s = this.data.schedule
|
||||
if (!s || !s.totalSteps) return '—'
|
||||
const pct = Math.round((s.doneSteps / s.totalSteps) * 100)
|
||||
return pct + '%'
|
||||
},
|
||||
progressSub() {
|
||||
const s = this.data.schedule
|
||||
if (!s || !s.totalSteps) return null
|
||||
return s.doneSteps + ' / ' + s.totalSteps + ' 步'
|
||||
},
|
||||
scheduleTone() {
|
||||
const s = this.data.schedule
|
||||
if (!s) return 'mute'
|
||||
if (s.overdue) return 'bad'
|
||||
if (s.delayCount > 0) return 'warn'
|
||||
return 'ok'
|
||||
},
|
||||
pendingApprovalCount() {
|
||||
return (this.data.approvals || []).filter(a => a.status === 0).length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
emptyData() {
|
||||
return {
|
||||
header: null, finance: {}, requirements: { byStatus: {}, recent: [] },
|
||||
arrivals: { byStatus: {} }, warehouse: { byStatus: {}, recent: [] },
|
||||
tasks: { byState: {}, overdueCount: 0 }, team: { members: [] },
|
||||
schedule: {}, contracts: [], reports: [], meetings: [], approvals: [],
|
||||
operationLog: [], health: null, manufacturing: null
|
||||
}
|
||||
},
|
||||
goBack() {
|
||||
this.$router.push({ path: '/project/project' })
|
||||
},
|
||||
load() {
|
||||
if (!this.projectId) return
|
||||
this.loading = true
|
||||
getProjectOverview(this.projectId).then(res => {
|
||||
const d = res.data || {}
|
||||
// 兜底
|
||||
;['finance','requirements','arrivals','warehouse','tasks','team','schedule'].forEach(k => {
|
||||
if (!d[k]) d[k] = {}
|
||||
})
|
||||
;['contracts','reports','meetings','approvals','operationLog'].forEach(k => {
|
||||
if (!d[k]) d[k] = []
|
||||
})
|
||||
if (!d.requirements.byStatus) d.requirements.byStatus = {}
|
||||
if (!d.requirements.recent) d.requirements.recent = []
|
||||
if (!d.arrivals.byStatus) d.arrivals.byStatus = {}
|
||||
if (!d.warehouse.byStatus) d.warehouse.byStatus = {}
|
||||
if (!d.warehouse.recent) d.warehouse.recent = []
|
||||
if (!d.tasks.byState) d.tasks.byState = {}
|
||||
if (!d.team.members) d.team.members = []
|
||||
this.data = d
|
||||
this.lastLoadAt = this.fmtDateTime(new Date())
|
||||
}).finally(() => { this.loading = false })
|
||||
},
|
||||
// ===== 跳转(实际菜单路径) =====
|
||||
goEditProject() {
|
||||
this.$router.push({ path: '/project/project', query: { projectId: this.projectId } })
|
||||
},
|
||||
goContract() {
|
||||
this.$router.push({ path: '/project/proContract', query: { projectId: this.projectId } })
|
||||
},
|
||||
goSchedule() {
|
||||
this.$router.push({ path: '/step/step', query: { projectId: this.projectId } })
|
||||
},
|
||||
goRequirement() {
|
||||
this.$router.push({ path: '/hint/requirement', query: { projectId: this.projectId } })
|
||||
},
|
||||
goArrival() {
|
||||
this.$router.push({ path: '/hint/requirement', query: { projectId: this.projectId } })
|
||||
},
|
||||
goFinance() {
|
||||
this.$router.push({ path: '/finance/finance', query: { projectId: this.projectId } })
|
||||
},
|
||||
goTask() {
|
||||
this.$router.push({ path: '/task/task/allocation', query: { projectId: this.projectId } })
|
||||
},
|
||||
goApproval() {
|
||||
this.$router.push({ path: '/approval/pending' })
|
||||
},
|
||||
goReport() {
|
||||
this.$router.push({ path: '/hint/projectReport', query: { projectId: this.projectId } })
|
||||
},
|
||||
// ===== 文案 =====
|
||||
fmtDate(d) { if (!d) return '-'; const s = String(d); return s.length >= 10 ? s.substring(0, 10) : s },
|
||||
fmtDateTime(d) {
|
||||
if (!d) return '-'
|
||||
if (d instanceof Date) {
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
const s = String(d)
|
||||
return s.length >= 19 ? s.substring(0, 19).replace('T', ' ') : s
|
||||
},
|
||||
fmtMoney(v) {
|
||||
if (v == null) return '0.00'
|
||||
const n = parseFloat(v)
|
||||
if (isNaN(n)) return v
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
},
|
||||
shortText(s, n) {
|
||||
if (!s) return ''
|
||||
// 富文本去 HTML 标签 + 反转义实体 + 压空白
|
||||
let t = String(s).replace(/<[^>]+>/g, ' ')
|
||||
t = t.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
|
||||
t = t.replace(/\s+/g, ' ').trim()
|
||||
return t.length > n ? t.substring(0, n) + '…' : t
|
||||
},
|
||||
stepStatusText(s) { return ({ 0: '未开始', 1: '进行中', 2: '已完成' })[s] || '' },
|
||||
taskStateText(s) { return ({ 0: '执行中', 1: '待验收', 2: '完成', 15: '延期申请' })[s] || s },
|
||||
taskStateTag(s) { return ({ 0: 'warning', 1: '', 2: 'success', 15: 'danger' })[s] || 'info' },
|
||||
reqStatusText(s) { return ({ 0: '未采购', 1: '采购中', 2: '完成', 3: '取消' })[s] || s },
|
||||
warehouseStatusText(k) {
|
||||
return ({ '0':'待审/未派','1':'处理中','2':'已完成','3':'取消' })[String(k)] || '状态 ' + k
|
||||
},
|
||||
businessName(t) { return ({ purchase_req: '采购需求', contract: '合同' })[t] || t },
|
||||
approvalTimelineType(s) { return ({ 0: 'warning', 1: 'success', 2: 'danger', 3: 'info' })[s] || '' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.overview { padding: 16px 20px; background: #f5f7fa; min-height: calc(100vh - 50px); }
|
||||
.overview .picker { margin-bottom: 12px; }
|
||||
.overview .muted { color: #909399; font-size: 12px; }
|
||||
.overview .ok { color: #67c23a; }
|
||||
.overview .warn { color: #e6a23c; }
|
||||
.overview .bad { color: #f56c6c; }
|
||||
|
||||
/* ===== 健康度大卡 ===== */
|
||||
.health { margin-bottom: 12px; border-left: 8px solid transparent; }
|
||||
.health--red { background: linear-gradient(90deg,#fef0f0 0%,#fff 60%); border-left-color:#f56c6c; }
|
||||
.health--yellow { background: linear-gradient(90deg,#fdf6ec 0%,#fff 60%); border-left-color:#e6a23c; }
|
||||
.health--green { background: linear-gradient(90deg,#f0f9eb 0%,#fff 60%); border-left-color:#67c23a; }
|
||||
.health-main { display: flex; align-items: center; gap: 18px; }
|
||||
.health-icon { font-size: 50px; line-height: 1; }
|
||||
.health--red .health-icon i { color: #f56c6c; }
|
||||
.health--yellow .health-icon i { color: #e6a23c; }
|
||||
.health--green .health-icon i { color: #67c23a; }
|
||||
.health-text { flex: 1; }
|
||||
.health-title { font-size: 18px; font-weight: 600; color: #303133; }
|
||||
.health-line { font-size: 15px; color: #606266; margin-top: 4px; line-height: 1.7; }
|
||||
.health-signals { margin-top: 10px; padding-top: 10px; border-top: 1px dashed #ebeef5; }
|
||||
.signal-row { line-height: 1.9; font-size: 13px; }
|
||||
.signal-row b { color: #606266; margin-right: 6px; }
|
||||
|
||||
/* ===== 项目头 + KPI ===== */
|
||||
.header { margin-bottom: 12px; background: linear-gradient(135deg, #f0f9ff 0%, #fff 100%); }
|
||||
.header-top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; }
|
||||
.header-name h1 { font-size: 22px; margin: 0 0 6px; color: #303133; line-height: 1.3; }
|
||||
.header-name .meta { color: #606266; font-size: 13px; }
|
||||
.header-name .meta span { margin-right: 18px; }
|
||||
.kpi-row { display: grid; grid-template-columns: repeat(8, 1fr); gap: 10px; }
|
||||
.kpi-tile {
|
||||
background: #fff; border: 1px solid #ebeef5; border-radius: 6px;
|
||||
padding: 12px 10px; text-align: center; transition: transform .15s;
|
||||
}
|
||||
.kpi-tile--click { cursor: pointer; }
|
||||
.kpi-tile--click:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,.06); }
|
||||
.kpi-tile .kpi-label { font-size: 12px; color: #909399; }
|
||||
.kpi-tile .kpi-val { font-size: 22px; font-weight: 700; color: #303133; margin: 4px 0 2px; word-break: break-all; line-height: 1.2; }
|
||||
.kpi-tile .kpi-sub { font-size: 11px; color: #909399; }
|
||||
.kpi-tile--ok .kpi-val { color: #67c23a; }
|
||||
.kpi-tile--warn .kpi-val { color: #e6a23c; }
|
||||
.kpi-tile--bad .kpi-val { color: #f56c6c; }
|
||||
.kpi-tile--mute .kpi-val { color: #606266; }
|
||||
|
||||
/* ===== 卡片 ===== */
|
||||
.card { margin-bottom: 12px; }
|
||||
.card .card-head { display: flex; justify-content: space-between; align-items: center; }
|
||||
.card .card-head .el-button--text { padding: 0; }
|
||||
.card .sub-title { color: #606266; font-size: 12px; font-weight: 500; margin-bottom: 6px; }
|
||||
.card .hint { color: #909399; font-size: 12px; margin: 8px 0 0; }
|
||||
|
||||
.stat-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.stat-pill { background: #f4f4f5; border-radius: 6px; padding: 8px 14px; min-width: 80px; text-align: center; }
|
||||
.stat-pill .pill-n { font-size: 20px; font-weight: 600; color: #303133; line-height: 1.1; }
|
||||
.stat-pill .pill-label { font-size: 12px; color: #606266; margin-top: 2px; }
|
||||
.stat-pill--success { background: #f0f9eb; }
|
||||
.stat-pill--success .pill-n { color: #67c23a; }
|
||||
.stat-pill--warning { background: #fdf6ec; }
|
||||
.stat-pill--warning .pill-n { color: #e6a23c; }
|
||||
.stat-pill--danger { background: #fef0f0; }
|
||||
.stat-pill--danger .pill-n { color: #f56c6c; }
|
||||
.stat-pill--info { background: #f4f4f5; }
|
||||
.stat-pill--info .pill-n { color: #909399; }
|
||||
|
||||
.fin-table { width: 100%; border-collapse: collapse; }
|
||||
.fin-table td { padding: 6px 4px; border-bottom: 1px dashed #ebeef5; font-size: 13px; }
|
||||
.fin-table td.num { text-align: right; font-family: Menlo,Consolas,monospace; }
|
||||
.fin-table tr.strong td { font-weight: 600; border-top: 1px solid #303133; border-bottom: none; padding-top: 10px; }
|
||||
|
||||
.list-tight { padding-left: 18px; margin: 4px 0; }
|
||||
.list-tight li { line-height: 1.7; font-size: 13px; color: #606266; }
|
||||
|
||||
/* ===== 制造主线 ===== */
|
||||
.mfg .checklist { list-style: none; padding: 0; margin: 0; }
|
||||
.mfg .checklist li { padding: 4px 0; font-size: 13px; color: #606266; line-height: 1.6; }
|
||||
.mfg .checklist li i { margin-right: 4px; color: #c0c4cc; }
|
||||
.mfg .checklist li.done { color: #67c23a; text-decoration: line-through; }
|
||||
.mfg .checklist li.done i.ok { color: #67c23a; }
|
||||
</style>
|
||||
@@ -179,6 +179,9 @@
|
||||
</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-view" @click="handleDetail(scope.row)">详情
|
||||
</el-button>
|
||||
<el-button size="mini" type="text" style="color:#67c23a" icon="el-icon-data-board"
|
||||
@click="handlePanorama(scope.row)">全景
|
||||
</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)"
|
||||
v-hasPermi="['oa:project:remove']">删除
|
||||
</el-button>
|
||||
@@ -922,6 +925,9 @@ export default {
|
||||
console.log(row)
|
||||
this.$router.push('/customer/detail/' + row.customerId);
|
||||
},
|
||||
handlePanorama (row) {
|
||||
this.$router.push({ path: '/project/panorama', query: { projectId: row.projectId } })
|
||||
},
|
||||
handleDetail (row) {
|
||||
this.loading = true;
|
||||
this.detailShow = true;
|
||||
|
||||
40
sql/oa_performance.sql
Normal file
40
sql/oa_performance.sql
Normal file
@@ -0,0 +1,40 @@
|
||||
-- ============================================================
|
||||
-- 个人绩效(与超期/顺延强制管理联动)
|
||||
-- ============================================================
|
||||
|
||||
-- 月度绩效汇总(同一员工同一周期一行)
|
||||
DROP TABLE IF EXISTS oa_performance_score;
|
||||
CREATE TABLE oa_performance_score (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
user_name VARCHAR(64),
|
||||
period VARCHAR(7) NOT NULL COMMENT '考核周期 YYYY-MM',
|
||||
base_score INT NOT NULL DEFAULT 100,
|
||||
deduction INT NOT NULL DEFAULT 0 COMMENT '本周期总扣分(正数)',
|
||||
bonus INT NOT NULL DEFAULT 0 COMMENT '本周期加分(预留)',
|
||||
total_score INT NOT NULL DEFAULT 100 COMMENT '= base - deduction + bonus',
|
||||
grade VARCHAR(4) DEFAULT NULL COMMENT 'A+/A/A-/B+/B/B-/C+/C/C-/D',
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_user_period (user_id, period)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='月度绩效汇总';
|
||||
|
||||
-- 每一笔扣/加分流水
|
||||
DROP TABLE IF EXISTS oa_performance_deduction;
|
||||
CREATE TABLE oa_performance_deduction (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
user_name VARCHAR(64),
|
||||
period VARCHAR(7) NOT NULL COMMENT 'YYYY-MM',
|
||||
source VARCHAR(32) NOT NULL COMMENT 'postpone / overdue30 / manual',
|
||||
source_type VARCHAR(32) DEFAULT NULL COMMENT 'task/step/requirement',
|
||||
source_id BIGINT DEFAULT NULL COMMENT '业务表主键',
|
||||
points INT NOT NULL COMMENT '正=扣 负=加',
|
||||
reason VARCHAR(500),
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_user_period (user_id, period),
|
||||
KEY idx_source (source_type, source_id),
|
||||
UNIQUE KEY uk_dedup (user_id, source, source_type, source_id) COMMENT '同一来源同一业务只扣一次(针对 overdue30 等一次性扣分)'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='绩效流水';
|
||||
36
sql/oa_postpone.sql
Normal file
36
sql/oa_postpone.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- ============================================================
|
||||
-- 顺延强制管理 · 数据层
|
||||
-- ============================================================
|
||||
|
||||
DROP TABLE IF EXISTS oa_postpone_record;
|
||||
|
||||
CREATE TABLE oa_postpone_record (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
business_type VARCHAR(64) NOT NULL COMMENT '业务类型 task / step / requirement',
|
||||
business_id BIGINT NOT NULL COMMENT '业务表主键',
|
||||
business_title VARCHAR(255) DEFAULT NULL COMMENT '业务名称(冗余展示)',
|
||||
owner_id BIGINT NOT NULL COMMENT '事项 owner(顺延申请人)',
|
||||
owner_name VARCHAR(64) DEFAULT NULL,
|
||||
original_deadline DATETIME NOT NULL COMMENT '本次顺延前的截止时间',
|
||||
new_deadline DATETIME NOT NULL COMMENT '申请的新截止时间',
|
||||
reason VARCHAR(1024) NOT NULL COMMENT '顺延理由',
|
||||
postpone_seq INT NOT NULL COMMENT '这是第几次顺延(含本次)',
|
||||
need_approval TINYINT NOT NULL DEFAULT 0 COMMENT '0直接生效 1需要审批',
|
||||
approval_instance_id BIGINT DEFAULT NULL COMMENT '审批单 id',
|
||||
status TINYINT NOT NULL DEFAULT 1 COMMENT '0待审批 1已生效 2被驳回 3已撤回',
|
||||
create_by VARCHAR(64) DEFAULT NULL,
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
update_by VARCHAR(64) DEFAULT NULL,
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
del_flag TINYINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_biz (business_type, business_id),
|
||||
KEY idx_owner (owner_id),
|
||||
KEY idx_inst (approval_instance_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='顺延记录(任务/步骤/采购需求 通用)';
|
||||
|
||||
-- 在审批配置里注册 3 个新业务(第 3 次及以后顺延会调 approvalService.submit 用到)
|
||||
INSERT INTO oa_approval_config (business_type, business_name, approver_ids, sign_type, enabled, remark) VALUES
|
||||
('task_postpone', '任务顺延(第3次起)', '1', 1, 1, '默认 admin 占位,请改成项目负责人'),
|
||||
('step_postpone', '进度步骤顺延(第3次起)', '1', 1, 1, '默认 admin 占位,请改成项目负责人'),
|
||||
('req_postpone', '采购需求顺延(第3次起)', '1', 1, 1, '默认 admin 占位,请改成项目负责人');
|
||||
7
sql/oa_project_panorama_menu.sql
Normal file
7
sql/oa_project_panorama_menu.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- 项目全景 + 集团驾驶舱 菜单(部署到 prod 时执行)
|
||||
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query_param, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) VALUES
|
||||
-- 项目顶级 (1744021537846177794) 下的两个新菜单
|
||||
(2063900003000001, '项目全景', 1744021537846177794, 5, 'panorama', 'oa/project/panorama/index', NULL, 1, 0, 'C', '0', '0', 'oa:project:panorama', 'chart', 'admin', NOW(), '项目全景页'),
|
||||
(2063900003000002, '集团驾驶舱', 1744021537846177794, 1, 'cockpit', 'oa/project/dashboardOverview/index', NULL, 1, 0, 'C', '0', '0', 'oa:project:cockpit', 'monitor', 'admin', NOW(), '全公司项目一览'),
|
||||
-- 说明 (2063900002000001) 下加项目全景流
|
||||
(2063900002000004, '项目全景流', 2063900002000001, 3, 'panorama', 'oa/docs/panorama/index', NULL, 1, 0, 'C', '0', '0', 'oa:docs:panorama', 'documentation', 'admin', NOW(), '项目全景使用说明');
|
||||
Reference in New Issue
Block a user