添加了项目前景 绩效 审批配置做了一部分有点晕 我换换脑子继续这个 还有说明菜单

This commit is contained in:
2026-06-17 17:06:01 +08:00
parent 8ad3f2d7dd
commit 88c374952a
38 changed files with 4932 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
package com.ruoyi.oa.controller;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.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();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 tinyintSQL 里手工处理。
*/
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 &lt; 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 字段保持兼容:
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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());
}
// 绩效扣分(每次顺延 -1dedupKey 含序号避免误判重复)
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 是 LocalDateTimeplanEnd 是 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;
}
}

View File

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

View File

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

View File

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

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

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

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

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

View File

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

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

View File

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

View 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>已支出 &gt; 已收款现金净流出</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>

View 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 &lt; 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 推到 100A+
</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>

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

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

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

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

View File

@@ -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
View 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
View 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 占位,请改成项目负责人');

View 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(), '项目全景使用说明');