企业级软件研发团队绩效考核系统开发(持续更新 Day 9)

0 阅读12分钟

作者:呱牛 

发布日期:2026年3月31日

标签:FastAPI、绩效考核、数据可视化

🔥 今日亮点

2026年3月31日 - 个人绩效页面与数据可视化完成

  • 实现员工选择功能:支持模糊搜索,根据归属团队动态筛选员工列表
  • 开发个人绩效数据展示:总分、团队排名、总工时及各项指标得分
  • 实现指标得分雷达图:个人得分vs团队平均,支持动态计算团队平均分
  • 开发得分对比柱状图:个人得分vs团队最高,显示具体人员信息
  • 优化图表交互体验:鼠标悬停显示详细数据(得分、占比、人员姓名)
  • 实现智能排序功能:默认按总分降序,选择团队后按团队内部总分排序
  • 修复界面布局问题:标签换行、文本对齐、下拉框样式优化
  • 完善数据初始化逻辑:默认加载总分最高的员工数据

📋 文章目录


🎯 个人绩效页面开发

1.1 功能需求

开发个人绩效页面,实现以下功能:

  • 员工选择功能,支持模糊搜索,根据归属团队动态筛选
  • 显示员工基本信息(姓名、工号、归属团队)
  • 显示绩效数据(总分、团队排名、总工时)
  • 显示各项指标得分及占比(需求工作、项目工作、自主研发、运维工作、临时任务)
  • 实现数据可视化图表(雷达图、柱状图)

1.2 实现步骤

步骤1:前端页面布局设计

<template>
  <el-tab-pane label="个人绩效" name="individual">
    <!-- 筛选条件 -->
    <div class="filter-container">
      <div class="filter-group">
        <label class="filter-label">选择员工:</label>
        <el-select 
          v-model="selectedEmployee" 
          class="w-64 custom-select" 
          filterable 
          placeholder="搜索员工姓名..."
          @change="handleEmployeeChange"
        >
          <el-option 
            v-for="employee in filteredEmployeeList" 
            :key="employee.staff_name" 
            :label="`${employee.staff_name} (${employee.staff_no})`" 
            :value="employee.staff_name"
          />
        </el-select>
      </div>
    </div>

    <!-- 员工基本信息 -->
    <div class="employee-info-card" v-if="currentEmployee">
      <div class="info-item">
        <span class="info-label">员工姓名</span>
        <span class="info-value">{{ currentEmployee.staff_name }}</span>
      </div>
      <div class="info-item">
        <span class="info-label">员工工号</span>
        <span class="info-value">{{ currentEmployee.staff_no }}</span>
      </div>
      <div class="info-item">
        <span class="info-label">归属团队</span>
        <span class="info-value">{{ currentEmployee.team_name }}</span>
      </div>
    </div>

    <!-- 绩效数据卡片 -->
    <div class="performance-cards">
      <div class="performance-card">
        <div class="card-title">本考核期次总分</div>
        <div class="card-value">{{ currentEmployee.total_score || 0 }}</div>
      </div>
      <div class="performance-card">
        <div class="card-title">团队排名</div>
        <div class="card-value">{{ getTeamRank() }}</div>
      </div>
      <div class="performance-card">
        <div class="card-title">总工时(人天)</div>
        <div class="card-value">{{ currentEmployee.workload_days || 0 }}</div>
      </div>
    </div>

    <!-- 指标得分卡片 -->
    <div class="indicator-cards">
      <div class="indicator-card" v-for="indicator in indicators" :key="indicator.name">
        <div class="indicator-name">{{ indicator.name }}</div>
        <div class="indicator-score">{{ indicator.score }}</div>
        <div class="indicator-percentage">占比:{{ indicator.percentage }}%</div>
      </div>
    </div>

    <!-- 图表区域 -->
    <div class="charts-container">
      <div class="chart-wrapper">
        <div class="chart-title">指标得分雷达图</div>
        <canvas ref="categoryRadarChart"></canvas>
      </div>
      <div class="chart-wrapper">
        <div class="chart-title">得分对比(vs 团队最高)</div>
        <canvas ref="scoreComparisonChart"></canvas>
      </div>
    </div>
  </el-tab-pane>
</template>

步骤2:实现员工选择功能

// 员工列表数据
const employeeList = ref<any[]>([]);
const selectedEmployee = ref<string>('');
const currentEmployee = ref<any>(null);

// 计算过滤后的员工列表(根据归属团队筛选)
const filteredEmployeeList = computed(() => {
  if (selectedTeam.value === 'all') {
    return employeeList.value;
  }
  return employeeList.value.filter((employee: any) => 
    employee.team_name === selectedTeam.value
  );
});

// 处理员工选择变化
const handleEmployeeChange = () => {
  console.log('选择的员工:', selectedEmployee.value);
  
  // 从员工列表中找到选中的员工
  const employee = employeeList.value.find(
    (emp: any) => emp.staff_name === selectedEmployee.value
  );
  
  if (employee) {
    currentEmployee.value = employee;
    console.log('当前员工信息:', currentEmployee.value);
    
    // 重新初始化图表
    initCharts();
  }
};

步骤3:实现数据加载与排序

// 加载部门、团队和员工数据
const loadDeptAndTeamData = async () => {
  try {
    const params: any = {
      assessment_period: selectedPeriod.value,
      data_timepoint: selectedTimepoint.value
    };
    
    if (selectedDeptLevel2.value) {
      params.dept_level_2 = selectedDeptLevel2.value;
    }
    
    const response = await *PaPerformanceDashboardAPI.getPerformanceList(params);
    
    if (response.data && response.data.length > 0) {
      const deptSet = new Set<string>();
      const teamSet = new Set<string>();
      const employeeSet = new Set<any>();
      
      response.data.forEach((item: any) => {
        if (item.dept_level_2) deptSet.add(item.dept_level_2);
        if (item.team_name) teamSet.add(item.team_name);
        
        employeeSet.add({
          staff_name: item.staff_name,
          staff_no: item.staff_no,
          team_name: item.team_name,
          total_score: item.total_score,
          workload_days: item.workload_days,
          demand_total_score: item.demand_total_score,
          project_total_score: item.project_total_score,
          self_dev_total_score: item.self_dev_total_score,
          ops_total_score: item.ops_total_score,
          task_total_score: item.task_total_score
        });
      });
      
      deptLevel2List.value = Array.from(deptSet).sort();
      teamList.value = Array.from(teamSet).sort();
      
      // 按总分降序排序员工列表
      employeeList.value = Array.from(employeeSet).sort((a, b) => {
        const scoreA = parseFloat(a.total_score) || 0;
        const scoreB = parseFloat(b.total_score) || 0;
        return scoreB - scoreA;
      });
      
      // 默认选择总分最高的员工
      if (employeeList.value.length > 0) {
        selectedEmployee.value = employeeList.value[0].staff_name;
      }
      
      // 数据加载完成后重新初始化图表
      initCharts();
    }
  } catch (error) {
    console.error('加载数据失败:', error);
  }
};

步骤4:实现团队排名计算

// 计算团队排名
const getTeamRank = () => {
  if (!currentEmployee.value || !currentEmployee.value.team_name) {
    return '-';
  }
  
  // 获取同团队的员工列表
  const teamEmployees = employeeList.value.filter(
    (emp: any) => emp.team_name === currentEmployee.value.team_name
  );
  
  // 按总分降序排序
  const sortedEmployees = teamEmployees.sort((a, b) => {
    const scoreA = parseFloat(a.total_score) || 0;
    const scoreB = parseFloat(b.total_score) || 0;
    return scoreB - scoreA;
  });
  
  // 找到当前员工的排名
  const rank = sortedEmployees.findIndex(
    (emp: any) => emp.staff_name === currentEmployee.value.staff_name
  ) + 1;
  
  return rank > 0 ? rank : '-';
};

步骤5:实现指标得分及占比计算

// 计算指标数据
const indicators = computed(() => {
  if (!currentEmployee.value) {
    return [];
  }
  
  const totalScore = parseFloat(currentEmployee.value.total_score) || 0;
  
  const indicatorList = [
    {
      name: '需求工作',
      score: parseFloat(currentEmployee.value.demand_total_score) || 0,
      field: 'demand_total_score'
    },
    {
      name: '项目工作',
      score: parseFloat(currentEmployee.value.project_total_score) || 0,
      field: 'project_total_score'
    },
    {
      name: '自主研发',
      score: parseFloat(currentEmployee.value.self_dev_total_score) || 0,
      field: 'self_dev_total_score'
    },
    {
      name: '运维工作',
      score: parseFloat(currentEmployee.value.ops_total_score) || 0,
      field: 'ops_total_score'
    },
    {
      name: '临时任务',
      score: parseFloat(currentEmployee.value.task_total_score) || 0,
      field: 'task_total_score'
    }
  ];
  
  // 计算占比
  return indicatorList.map(indicator => ({
    ...indicator,
    percentage: totalScore > 0 ? ((indicator.score / totalScore) * 100).toFixed(1) : '0.0'
  }));
});


📊 数据可视化图表开发

2.1 雷达图开发

步骤1:计算团队平均分

// 计算团队平均分
const calculateTeamAverage = () => {
  let filteredEmployees = employeeList.value;
  
  // 根据归属团队筛选
  if (selectedTeam.value !== 'all') {
    filteredEmployees = employeeList.value.filter(
      (employee: any) => employee.team_name === selectedTeam.value
    );
  }
  
  if (filteredEmployees.length === 0) {
    return {
      demand: 0,
      project: 0,
      selfDev: 0,
      ops: 0,
      task: 0
    };
  }
  
  // 计算各项指标的平均分
  const totalDemand = filteredEmployees.reduce(
    (sum: number, emp: any) => sum + (parseFloat(emp.demand_total_score) || 0), 0
  );
  const totalProject = filteredEmployees.reduce(
    (sum: number, emp: any) => sum + (parseFloat(emp.project_total_score) || 0), 0
  );
  const totalSelfDev = filteredEmployees.reduce(
    (sum: number, emp: any) => sum + (parseFloat(emp.self_dev_total_score) || 0), 0
  );
  const totalOps = filteredEmployees.reduce(
    (sum: number, emp: any) => sum + (parseFloat(emp.ops_total_score) || 0), 0
  );
  const totalTask = filteredEmployees.reduce(
    (sum: number, emp: any) => sum + (parseFloat(emp.task_total_score) || 0), 0
  );
  
  const count = filteredEmployees.length;
  
  return {
    demand: totalDemand / count,
    project: totalProject / count,
    selfDev: totalSelfDev / count,
    ops: totalOps / count,
    task: totalTask / count
  };
};

步骤2:初始化雷达图

const initCharts = () => {
  // 销毁旧图表实例
  if (radarChart) {
    radarChart.destroy();
    radarChart = null;
  }
  if (comparisonChart) {
    comparisonChart.destroy();
    comparisonChart = null;
  }
  
  // 雷达图
  if (categoryRadarChart.value) {
    const teamAverage = calculateTeamAverage();
    
    radarChart = new Chart(categoryRadarChart.value, {
      type: 'radar',
      data: {
        labels: ['需求工作', '项目工作', '自主研发', '系统运维', '临时任务'],
        datasets: [{
          label: '个人得分',
          data: [
            parseFloat(currentEmployee.value.demand_total_score) || 0,
            parseFloat(currentEmployee.value.project_total_score) || 0,
            parseFloat(currentEmployee.value.self_dev_total_score) || 0,
            parseFloat(currentEmployee.value.ops_total_score) || 0,
            parseFloat(currentEmployee.value.task_total_score) || 0
          ],
          borderColor: '#667eea',
          backgroundColor: 'rgba(102, 126, 234, 0.2)',
          pointBackgroundColor: '#667eea',
          pointBorderColor: '#fff',
          pointHoverBackgroundColor: '#fff',
          pointHoverBorderColor: '#667eea'
        }, {
          label: '团队平均',
          data: [
            parseFloat(teamAverage.demand) || 0,
            parseFloat(teamAverage.project) || 0,
            parseFloat(teamAverage.selfDev) || 0,
            parseFloat(teamAverage.ops) || 0,
            parseFloat(teamAverage.task) || 0
          ],
          borderColor: '#2ecc71',
          backgroundColor: 'rgba(46, 204, 113, 0.1)',
          pointBackgroundColor: '#2ecc71',
          pointBorderColor: '#fff',
          pointHoverBackgroundColor: '#fff',
          pointHoverBorderColor: '#2ecc71'
        }]
      },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        plugins: {
          legend: {
            position: 'top'
          },
          tooltip: {
            enabled: true,
            mode: 'point',
            callbacks: {
              label: function(context) {
                const label = context.dataset.label || '';
                const value = context.raw || 0;
                
                if (label === '个人得分') {
                  const score = value.toFixed(1);
                  const totalScore = parseFloat(currentEmployee.value.total_score) || 0;
                  const percentage = totalScore > 0 ? ((value / totalScore) * 100).toFixed(1) : '0.0';
                  return [
                    `${label} (${currentEmployee.value.staff_name || '请选择员工'})`,
                    `得分: ${score}`,
                    `占比: ${percentage}%`
                  ];
                } else if (label === '团队平均') {
                  const score = value.toFixed(1);
                  let teamInfo = '';
                  if (selectedTeam.value === 'all') {
                    teamInfo = '整个二级部门';
                  } else {
                    teamInfo = selectedTeam.value;
                  }
                  return [
                    `${label} (${teamInfo})`,
                    `得分: ${score}`
                  ];
                }
                return `${label}: ${value.toFixed(1)}`;
              }
            }
          }
        },
        scales: {
          r: {
            beginAtZero: true,
            min: 0,
            max: 100
          }
        }
      }
    });
  }
};

2.2 柱状图开发

步骤1:计算团队最高分及对应人员

// 得分对比图
if (scoreComparisonChart.value) {
  // 获取团队最高分
  let filteredEmployees = employeeList.value;
  if (selectedTeam.value !== 'all') {
    filteredEmployees = employeeList.value.filter((employee: any) => 
      employee.team_name === selectedTeam.value
    );
  }
  
  // 计算各指标的最高分和对应的人员
  let demandMax = 0;
  let projectMax = 0;
  let selfDevMax = 0;
  let opsMax = 0;
  let taskMax = 0;
  let demandMaxName = '';
  let projectMaxName = '';
  let selfDevMaxName = '';
  let opsMaxName = '';
  let taskMaxName = '';
  
  filteredEmployees.forEach((employee: any) => {
    const demandScore = parseFloat(employee.demand_total_score) || 0;
    const projectScore = parseFloat(employee.project_total_score) || 0;
    const selfDevScore = parseFloat(employee.self_dev_total_score) || 0;
    const opsScore = parseFloat(employee.ops_total_score) || 0;
    const taskScore = parseFloat(employee.task_total_score) || 0;
    
    if (demandScore > demandMax) {
      demandMax = demandScore;
      demandMaxName = employee.staff_name;
    }
    if (projectScore > projectMax) {
      projectMax = projectScore;
      projectMaxName = employee.staff_name;
    }
    if (selfDevScore > selfDevMax) {
      selfDevMax = selfDevScore;
      selfDevMaxName = employee.staff_name;
    }
    if (opsScore > opsMax) {
      opsMax = opsScore;
      opsMaxName = employee.staff_name;
    }
    if (taskScore > taskMax) {
      taskMax = taskScore;
      taskMaxName = employee.staff_name;
    }
  });
  
  // 存储最高分人员信息
  const maxScoreNames = {
    '需求工作': demandMaxName,
    '项目工作': projectMaxName,
    '自主研发': selfDevMaxName,
    '系统运维': opsMaxName,
    '临时任务': taskMaxName
  };
  
  comparisonChart = new Chart(scoreComparisonChart.value, {
    type: 'bar',
    data: {
      labels: ['需求工作', '项目工作', '自主研发', '系统运维', '临时任务'],
      datasets: [{
        label: '个人得分',
        data: [
          parseFloat(currentEmployee.value.demand_total_score) || 0,
          parseFloat(currentEmployee.value.project_total_score) || 0,
          parseFloat(currentEmployee.value.self_dev_total_score) || 0,
          parseFloat(currentEmployee.value.ops_total_score) || 0,
          parseFloat(currentEmployee.value.task_total_score) || 0
        ],
        backgroundColor: 'rgba(102, 126, 234, 0.8)',
        borderColor: 'rgba(102, 126, 234, 1)',
        borderWidth: 1
      }, {
        label: '团队最高',
        data: [
          demandMax,
          projectMax,
          selfDevMax,
          opsMax,
          taskMax
        ],
        backgroundColor: 'rgba(46, 204, 113, 0.6)',
        borderColor: 'rgba(46, 204, 113, 1)',
        borderWidth: 1
      }]
    },
    options: {
      responsive: true,
      maintainAspectRatio: false,
      plugins: {
        legend: {
          position: 'top'
        },
        tooltip: {
          enabled: true,
          mode: 'index',
          callbacks: {
            label: function(context) {
              const label = context.dataset.label || '';
              const value = context.raw || 0;
              const index = context.dataIndex;
              const category = context.chart.data.labels[index];
              
              if (label === '个人得分') {
                return `${label} (${currentEmployee.value.staff_name || '请选择员工'}): ${value.toFixed(1)}`;
              } else if (label === '团队最高') {
                const maxName = maxScoreNames[category] || '无数据';
                return `${label} (${maxName}): ${value.toFixed(1)}`;
              }
              return `${label}: ${value.toFixed(1)}`;
            }
          }
        }
      },
      scales: {
        y: {
          beginAtZero: true,
          min: 0,
          max: Math.max(...[demandMax, projectMax, selfDevMax, opsMax, taskMax, parseFloat(currentEmployee.value.demand_total_score) || 0, parseFloat(currentEmployee.value.project_total_score) || 0, parseFloat(currentEmployee.value.self_dev_total_score) || 0, parseFloat(currentEmployee.value.ops_total_score) || 0, parseFloat(currentEmployee.value.task_total_score) || 0]) * 1.2 || 100
        }
      }
    }
  });
}


🐛 问题排查与修复

3.1 界面布局问题修复

问题1:标签换行问题

问题描述: "选择员工"标签在一行没有显示完,出现了换行。

解决方案: 为标签添加 white-space: nowrap 样式。

.filter-label {
  font-weight: 500;
  color: #606266;
  margin-right: 8px;
  white-space: nowrap;
}

问题2:员工姓名左对齐太靠边

问题描述: 下拉框加载出的姓名太靠左边,需要预留几个像素的位置。

解决方案: 为下拉选项添加左内边距。

.el-select-dropdown__item {
  padding-left: 20px !important;
}

3.2 图表数据显示问题修复

问题1:雷达图无数据显示

问题描述: 选择员工后,雷达图没有显示数据。

原因分析:

  1. employeeList未包含所需字段(demand_total_score等)
  2. 数据加载后未重新初始化图表
  3. 图表实例未销毁导致冲突

解决方案:

  1. 在构建employeeSet时添加所有指标字段
  2. 在loadDeptAndTeamData函数中数据加载完成后调用initCharts()
  3. 在initCharts函数开头销毁所有旧图表实例
// 构建员工数据时包含所有字段
employeeSet.add({
  staff_name: item.staff_name,
  staff_no: item.staff_no,
  team_name: item.team_name,
  total_score: item.total_score,
  workload_days: item.workload_days,
  demand_total_score: item.demand_total_score,
  project_total_score: item.project_total_score,
  self_dev_total_score: item.self_dev_total_score,
  ops_total_score: item.ops_total_score,
  task_total_score: item.task_total_score
});

// 销毁旧图表实例
const initCharts = () => {
  if (radarChart) {
    radarChart.destroy();
    radarChart = null;
  }
  if (comparisonChart) {
    comparisonChart.destroy();
    comparisonChart = null;
  }
  
  // ... 初始化新图表
};

问题2:柱状图只显示一个柱子

问题描述: 需求工作和临时任务只显示一个柱子,没有显示完整。

原因分析: 图表配置缺少必要的样式属性,导致柱子重叠或不可见。

解决方案: 添加 borderColorborderWidthscales 配置。

datasets: [{
  label: '个人得分',
  data: [...],
  backgroundColor: 'rgba(102, 126, 234, 0.8)',
  borderColor: 'rgba(102, 126, 234, 1)',
  borderWidth: 1
}, {
  label: '团队最高',
  data: [...],
  backgroundColor: 'rgba(46, 204, 113, 0.6)',
  borderColor: 'rgba(46, 204, 113, 1)',
  borderWidth: 1
}],
options: {
  scales: {
    y: {
      beginAtZero: true,
      min: 0,
      max: Math.max(...所有数据) * 1.2 || 100
    }
  }
}

3.3 数据排序问题修复

问题:初始加载数据默认是员工曹操

问题描述: 初始加载数据默认是按姓名首字母排序,应该按总分降序排序,默认加载总分最高的员工。

解决方案: 修改排序逻辑,按总分降序排序。

// 按总分降序排序员工列表
employeeList.value = Array.from(employeeSet).sort((a, b) => {
  const scoreA = parseFloat(a.total_score) || 0;
  const scoreB = parseFloat(b.total_score) || 0;
  return scoreB - scoreA;
});

// 默认选择总分最高的员工
if (employeeList.value.length > 0) {
  selectedEmployee.value = employeeList.value[0].staff_name;
}

问题:选择团队后排序逻辑

问题描述: 选择归属团队后,应该按团队内部总分从高到低排序。

解决方案: 在handleTeamChange函数中添加排序逻辑。

const handleTeamChange = () => {
  selectedEmployee.value = '';
  
  if (selectedTeam.value !== 'all') {
    // 先过滤出该团队的员工,再按总分降序排序
    const teamEmployees = employeeList.value
      .filter((employee: any) => employee.team_name === selectedTeam.value)
      .sort((a, b) => {
        const scoreA = parseFloat(a.total_score) || 0;
        const scoreB = parseFloat(b.total_score) || 0;
        return scoreB - scoreA;
      });
    
    // 如果该团队有员工,默认选择总分最高的
    if (teamEmployees.length > 0) {
      selectedEmployee.value = teamEmployees[0].staff_name;
    }
  } else {
    // 选择全部团队时,按全团队总分降序排序,默认选择最高的
    const sortedEmployees = [...employeeList.value].sort((a, b) => {
      const scoreA = parseFloat(a.total_score) || 0;
      const scoreB = parseFloat(b.total_score) || 0;
      return scoreB - scoreA;
    });
    
    if (sortedEmployees.length > 0) {
      selectedEmployee.value = sortedEmployees[0].staff_name;
    }
  }
  
  refreshData();
};


🔧 代码优化

4.1 图表交互优化

优化1:雷达图tooltip显示详细信息

tooltip: {
  enabled: true,
  mode: 'point',
  callbacks: {
    label: function(context) {
      const label = context.dataset.label || '';
      const value = context.raw || 0;
      
      if (label === '个人得分') {
        const score = value.toFixed(1);
        const totalScore = parseFloat(currentEmployee.value.total_score) || 0;
        const percentage = totalScore > 0 ? ((value / totalScore) * 100).toFixed(1) : '0.0';
        return [
          `${label} (${currentEmployee.value.staff_name || '请选择员工'})`,
          `得分: ${score}`,
          `占比: ${percentage}%`
        ];
      } else if (label === '团队平均') {
        const score = value.toFixed(1);
        let teamInfo = '';
        if (selectedTeam.value === 'all') {
          teamInfo = '整个二级部门';
        } else {
          teamInfo = selectedTeam.value;
        }
        return [
          `${label} (${teamInfo})`,
          `得分: ${score}`
        ];
      }
      return `${label}: ${value.toFixed(1)}`;
    }
  }
}

优化2:柱状图tooltip显示人员信息

tooltip: {
  enabled: true,
  mode: 'index',
  callbacks: {
    label: function(context) {
      const label = context.dataset.label || '';
      const value = context.raw || 0;
      const index = context.dataIndex;
      const category = context.chart.data.labels[index];
      
      if (label === '个人得分') {
        return `${label} (${currentEmployee.value.staff_name || '请选择员工'}): ${value.toFixed(1)}`;
      } else if (label === '团队最高') {
        const maxName = maxScoreNames[category] || '无数据';
        return `${label} (${maxName}): ${value.toFixed(1)}`;
      }
      return `${label}: ${value.toFixed(1)}`;
    }
  }
}

4.2 性能优化

优化1:使用computed缓存计算结果

// 使用computed缓存过滤后的员工列表
const filteredEmployeeList = computed(() => {
  if (selectedTeam.value === 'all') {
    return employeeList.value;
  }
  return employeeList.value.filter((employee: any) => 
    employee.team_name === selectedTeam.value
  );
});

// 使用computed缓存指标数据
const indicators = computed(() => {
  if (!currentEmployee.value) {
    return [];
  }
  
  const totalScore = parseFloat(currentEmployee.value.total_score) || 0;
  
  return indicatorList.map(indicator => ({
    ...indicator,
    percentage: totalScore > 0 ? ((indicator.score / totalScore) * 100).toFixed(1) : '0.0'
  }));
});

优化2:图表实例销毁与重建

// 在初始化图表前销毁旧实例
const initCharts = () => {
  if (radarChart) {
    radarChart.destroy();
    radarChart = null;
  }
  if (comparisonChart) {
    comparisonChart.destroy();
    comparisonChart = null;
  }
  
  // 初始化新图表...
};


🚀 技术要点

5.1 Chart.js图表配置

雷达图配置要点

{
  type: 'radar',
  data: {
    labels: ['指标1', '指标2', '指标3', '指标4', '指标5'],
    datasets: [{
      label: '个人得分',
      data: [85, 90, 78, 92, 88],
      borderColor: '#667eea',
      backgroundColor: 'rgba(102, 126, 234, 0.2)',
      pointBackgroundColor: '#667eea',
      pointBorderColor: '#fff',
      pointHoverBackgroundColor: '#fff',
      pointHoverBorderColor: '#667eea'
    }]
  },
  options: {
    responsive: true,
    maintainAspectRatio: false,
    plugins: {
      legend: {
        position: 'top'
      },
      tooltip: {
        enabled: true,
        mode: 'point'
      }
    },
    scales: {
      r: {
        beginAtZero: true,
        min: 0,
        max: 100
      }
    }
  }
}

柱状图配置要点

{
  type: 'bar',
  data: {
    labels: ['指标1', '指标2', '指标3', '指标4', '指标5'],
    datasets: [{
      label: '个人得分',
      data: [85, 90, 78, 92, 88],
      backgroundColor: 'rgba(102, 126, 234, 0.8)',
      borderColor: 'rgba(102, 126, 234, 1)',
      borderWidth: 1
    }, {
      label: '团队最高',
      data: [95, 98, 88, 96, 92],
      backgroundColor: 'rgba(46, 204, 113, 0.6)',
      borderColor: 'rgba(46, 204, 113, 1)',
      borderWidth: 1
    }]
  },
  options: {
    responsive: true,
    maintainAspectRatio: false,
    plugins: {
      legend: {
        position: 'top'
      },
      tooltip: {
        enabled: true,
        mode: 'index'
      }
    },
    scales: {
      y: {
        beginAtZero: true,
        min: 0
      }
    }
  }
}

5.2 Vue 3响应式数据管理

ref与computed的使用

// 使用ref管理响应式数据
const employeeList = ref<any[]>([]);
const selectedEmployee = ref<string>('');
const currentEmployee = ref<any>(null);

// 使用computed缓存计算结果
const filteredEmployeeList = computed(() => {
  if (selectedTeam.value === 'all') {
    return employeeList.value;
  }
  return employeeList.value.filter((employee: any) => 
    employee.team_name === selectedTeam.value
  );
});

const indicators = computed(() => {
  if (!currentEmployee.value) {
    return [];
  }
  
  const totalScore = parseFloat(currentEmployee.value.total_score) || 0;
  
  return indicatorList.map(indicator => ({
    ...indicator,
    percentage: totalScore > 0 ? ((indicator.score / totalScore) * 100).toFixed(1) : '0.0'
  }));
});

5.3 Element Plus组件使用

el-select组件配置

<el-select 
  v-model="selectedEmployee" 
  class="w-64 custom-select" 
  filterable 
  placeholder="搜索员工姓名..."
  @change="handleEmployeeChange"
>
  <el-option 
    v-for="employee in filteredEmployeeList" 
    :key="employee.staff_name" 
    :label="`${employee.staff_name} (${employee.staff_no})`" 
    :value="employee.staff_name"
  />
</el-select>

关键属性:

  • v-model:双向绑定选中的值
  • filterable:启用搜索功能
  • placeholder:占位提示文本
  • @change:选择变化事件处理

📈 待实现功能

6.1 详细指标展示

  • 实现各指标的详细数据展示(C101-C403)
  • 添加指标得分明细表格
  • 实现指标数据导出功能

6.2 历史数据对比

  • 实现历史考核期次数据对比
  • 添加趋势图表展示
  • 实现环比、同比分析

6.3 团队绩效分析

  • 实现团队整体绩效分析
  • 添加团队对比图表
  • 实现团队排名趋势分析

6.4 数据导出功能

  • 实现个人绩效报告导出
  • 支持PDF格式导出
  • 支持Excel格式导出

🧪 测试验证

7.1 功能测试

测试1:员工选择功能测试

测试步骤:

  1. 打开个人绩效页面
  2. 点击"选择员工"下拉框
  3. 输入员工姓名进行搜索
  4. 选择员工后查看数据是否正确加载

预期结果:

  • 下拉框显示所有员工(根据归属团队筛选)
  • 搜索功能正常工作
  • 选择员工后,页面显示该员工的绩效数据

测试2:图表显示测试

测试步骤:

  1. 选择员工后,查看雷达图是否正确显示
  2. 查看柱状图是否正确显示
  3. 鼠标悬停在图表上,查看tooltip是否显示详细信息

预期结果:

  • 雷达图显示个人得分和团队平均
  • 柱状图显示个人得分和团队最高
  • tooltip显示详细的得分、占比和人员信息

测试3:排序功能测试

测试步骤:

  1. 打开个人绩效页面,查看默认选中的员工
  2. 选择不同的归属团队
  3. 查看员工列表是否按团队内部总分排序

预期结果:

  • 默认选中总分最高的员工
  • 选择团队后,默认选中该团队总分最高的员工
  • 员工列表按总分降序排列

7.2 性能测试

测试1:大数据量测试

测试数据:

  • 员工数量:100+
  • 考核期次:5个
  • 数据时点:每个考核期次3个

测试步骤:

  1. 加载所有数据
  2. 查看页面响应速度
  3. 查看图表渲染速度

预期结果:

  • 页面加载时间 < 2秒
  • 图表渲染时间 < 1秒
  • 无明显卡顿

测试2:并发测试

测试场景:

  • 10个用户同时访问个人绩效页面

预期结果:

  • 所有用户都能正常访问
  • 数据加载正确
  • 无明显性能下降

⚠️ 注意事项

8.1 数据类型转换

问题: 后端返回的数值字段可能是字符串类型,需要转换为数值类型。

解决方案: 使用 parseFloat()Number() 进行类型转换,并提供默认值。

const score = parseFloat(employee.total_score) || 0;

8.2 图表实例管理

问题: 图表实例未正确销毁会导致内存泄漏和显示异常。

解决方案: 在初始化新图表前,先销毁旧图表实例。

if (radarChart) {
  radarChart.destroy();
  radarChart = null;
}

8.3 响应式数据更新

问题: 直接修改响应式数据可能不会触发视图更新。

解决方案: 使用 refcomputed 管理响应式数据,确保数据变化时视图正确更新。

const employeeList = ref<any[]>([]);
const filteredEmployeeList = computed(() => {
  return employeeList.value.filter(...);
});

8.4 样式作用域

问题: Vue组件的样式可能影响全局样式,或被全局样式覆盖。

解决方案: 使用 scoped 属性限制样式作用域,或使用更具体的选择器。

<style scoped>
.filter-label {
  white-space: nowrap;
}
</style>

8.5 异步数据加载

问题: 异步数据加载完成前,图表可能已经初始化,导致数据为空。

解决方案: 在数据加载完成后再初始化图表。

const loadData = async () => {
  await fetchEmployeeData();
  initCharts();
};


📝 今日总结

9.1 完成的工作

  1. 个人绩效页面开发

    • 实现员工选择功能,支持模糊搜索和团队筛选
    • 显示员工基本信息和绩效数据
    • 实现指标得分展示及占比计算
  2. 数据可视化图表开发

    • 开发指标得分雷达图(vs 团队平均)
    • 开发得分对比柱状图(vs 团队最高)
    • 实现图表交互优化(tooltip显示详细数据)
  3. 数据排序优化

    • 默认按总分从高到低排序
    • 选择团队后按团队内部总分排序
    • 默认选择总分最高的员工
  4. 问题修复

    • 修复界面布局问题(标签换行、文本对齐)
    • 修复图表数据显示问题
    • 修复数据排序问题

9.2 技术收获

  1. Chart.js图表库使用

    • 掌握雷达图和柱状图的配置方法
    • 学会使用tooltip回调函数显示自定义信息
    • 理解图表实例的销毁与重建机制
  2. Vue 3响应式数据管理

    • 熟练使用ref和computed管理响应式数据
    • 理解computed的缓存机制
    • 掌握异步数据加载的最佳实践
  3. Element Plus组件使用

    • 掌握el-select组件的配置和使用
    • 学会使用filterable属性实现搜索功能
    • 理解组件事件处理机制

9.3 下一步计划

  1. 详细指标展示

    • 实现各指标的详细数据展示
    • 添加指标得分明细表格
    • 实现指标数据导出功能
  2. 历史数据对比

    • 实现历史考核期次数据对比
    • 添加趋势图表展示
    • 实现环比、同比分析
  3. 团队绩效分析

    • 实现团队整体绩效分析
    • 添加团队对比图表
    • 实现团队排名趋势分析
  4. 性能优化

    • 优化大数据量加载速度
    • 实现数据分页和懒加载
    • 优化图表渲染性能