| | |
| | | <template> |
| | | <div class="app-container analytics-container"> |
| | | <div class="app-container analytics-container" v-loading="loading"> |
| | | |
| | | <!-- 关键指标卡片 --> |
| | | <el-row :gutter="20" class="metrics-cards"> |
| | |
| | | <span v-else>{{ item.value }}{{ item.unit }}</span> |
| | | </div> |
| | | <div class="card-label">{{ item.label }}</div> |
| | | <div class="card-trend" :class="item.trend > 0 ? 'positive' : 'negative'"> |
| | | <el-icon> |
| | | <component :is="item.trend > 0 ? 'ArrowUp' : 'ArrowDown'" /> |
| | | </el-icon> |
| | | {{ Math.abs(item.trend) }}% |
| | | </div> |
| | | <!-- <div class="card-trend" :class="item.trend > 0 ? 'positive' : 'negative'" v-if="item.showTrend !== false">--> |
| | | <!-- <el-icon>--> |
| | | <!-- <component :is="item.trend > 0 ? 'ArrowUp' : 'ArrowDown'" />--> |
| | | <!-- </el-icon>--> |
| | | <!-- {{ Math.abs(item.trend) }}%--> |
| | | <!-- </div>--> |
| | | </div> |
| | | </div> |
| | | </el-card> |
| | |
| | | |
| | | <!-- 第二行图表 --> |
| | | <el-row :gutter="20" class="charts-section"> |
| | | <!-- 编制达成率 --> |
| | | <el-col :span="12"> |
| | | <el-card class="chart-card"> |
| | | <template #header> |
| | | <div class="card-header"> |
| | | <span>编制达成率</span> |
| | | <el-tag type="warning">各部门对比</el-tag> |
| | | </div> |
| | | </template> |
| | | <div class="chart-container"> |
| | | <div ref="staffingChartRef" class="chart"></div> |
| | | </div> |
| | | </el-card> |
| | | </el-col> |
| | | |
| | | <!-- 员工流失原因分析 --> |
| | | <el-col :span="12"> |
| | | <el-card class="chart-card"> |
| | |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { ref, reactive, onMounted, onUnmounted } from 'vue' |
| | | import { ref, onMounted, onUnmounted, nextTick } from 'vue' |
| | | import { ElMessage } from 'element-plus' |
| | | import { |
| | | Refresh, |
| | | User, |
| | | TrendCharts, |
| | | DataAnalysis, |
| | | PieChart, |
| | | ArrowUp, |
| | | ArrowDown |
| | | } from '@element-plus/icons-vue' |
| | | import * as echarts from 'echarts' |
| | | import dayjs from 'dayjs' |
| | | import {listDept} from "@/api/system/dept.js"; |
| | | import { |
| | | findStaffAnalysisMonthlyTurnoverRateFor12Months, |
| | | findStaffLeaveReasonAnalysis, |
| | | findStaffAnalysisTotalStatistic |
| | | } from "@/api/personnelManagement/staffAnalytics.js"; |
| | | import { staffOnJobListPage } from "@/api/personnelManagement/staffOnJob.js"; |
| | | import { findStaffLeaveListPage } from "@/api/personnelManagement/staffLeave.js"; |
| | | |
| | | // 响应式数据 |
| | | const loading = ref(false) |
| | |
| | | trend: 0 |
| | | }, |
| | | { |
| | | label: '编制达成率', |
| | | value: 0, |
| | | unit: '%', |
| | | icon: 'DataAnalysis', |
| | | type: 'success', |
| | | trend: 0 |
| | | }, |
| | | { |
| | | label: '在职员工数', |
| | | value: 0, |
| | | unit: '人', |
| | | icon: 'PieChart', |
| | | type: 'warning', |
| | | trend: 0 |
| | | trend: 0, |
| | | showTrend: false |
| | | } |
| | | ]) |
| | | |
| | | // 部门数据 |
| | | const departmentData = ref([]) |
| | | // 员工流失原因分析数据 |
| | | const staffLeaveReasons = ref([]) |
| | | // 12个月员工流动流失率分析数据 |
| | | const turnoverRateStatistics = ref([]) |
| | | const turnoverRateStatisticsRaw = ref([]) |
| | | const turnoverSeriesNormalized = ref(false) |
| | | |
| | | const staffCounts = ref({ |
| | | onJobTotal: 0, |
| | | leaveTotal: 0, |
| | | totalStaff: 0, |
| | | leaveLast12Months: 0, |
| | | joinLeaveRatio: 1 |
| | | }) |
| | | |
| | | const safeNum = (val) => { |
| | | const num = Number(val) |
| | | return Number.isFinite(num) ? num : 0 |
| | | } |
| | | |
| | | const round2 = (val) => { |
| | | const num = safeNum(val) |
| | | return Number(num.toFixed(2)) |
| | | } |
| | | |
| | | const getListTotal = (res) => safeNum(res?.data?.total ?? res?.data?.count ?? 0) |
| | | |
| | | const getLast12MonthRanges = () => { |
| | | const end = dayjs() |
| | | const start = end.subtract(11, 'month').startOf('month') |
| | | const ranges = [] |
| | | for (let i = 0; i < 12; i++) { |
| | | const cur = start.add(i, 'month') |
| | | ranges.push({ |
| | | month: cur.format('YYYY-MM'), |
| | | start: cur.startOf('month').format('YYYY-MM-DD'), |
| | | end: cur.endOf('month').format('YYYY-MM-DD') |
| | | }) |
| | | } |
| | | return ranges |
| | | } |
| | | |
| | | // 获取部门数据 |
| | | const getDepartmentData = async () => { |
| | | try { |
| | | const res = await listDept() |
| | | if (res && res.data) { |
| | | departmentData.value = res.data |
| | | } |
| | | } catch (error) { |
| | | console.error('获取部门数据失败:', error) |
| | | } |
| | | } |
| | | |
| | | const getStaffLeaveReasonAnalysis = async () => { |
| | | try { |
| | | const res = await findStaffLeaveReasonAnalysis() |
| | | if (res && res.data) { |
| | | staffLeaveReasons.value = res.data || [] |
| | | } |
| | | } catch (error) { |
| | | console.error('获取员工流失原因分析失败:', error) |
| | | } |
| | | } |
| | | |
| | | // 修改为返回Promise的异步函数 |
| | | const getMonthlyTurnoverRateFor12Months = async () => { |
| | | try { |
| | | const res = await findStaffAnalysisMonthlyTurnoverRateFor12Months() |
| | | if (res && res.data) { |
| | | turnoverRateStatisticsRaw.value = res.data || [] |
| | | } |
| | | } catch (error) { |
| | | console.error('获取12个月员工流动流失率分析数据失败:', error) |
| | | } |
| | | } |
| | | |
| | | const getStaffAnalysisTotalStatistic = async () => { |
| | | try { |
| | | const ranges = getLast12MonthRanges() |
| | | const leave12Range = { |
| | | leaveDateStart: ranges[0].start, |
| | | leaveDateEnd: ranges[ranges.length - 1].end |
| | | } |
| | | |
| | | const [totalRes, onJobRes, leaveRes, leave12Res] = await Promise.all([ |
| | | findStaffAnalysisTotalStatistic(), |
| | | staffOnJobListPage({ current: 1, size: 1, staffState: 1 }), |
| | | findStaffLeaveListPage({ current: 1, size: 1 }), |
| | | findStaffLeaveListPage({ current: 1, size: 1, ...leave12Range }) |
| | | ]) |
| | | |
| | | const totalFlowRate = safeNum(totalRes?.data?.totalFlowRate) |
| | | const totalTurnoverRate = safeNum(totalRes?.data?.totalTurnoverRate) |
| | | const joinLeaveRatio = |
| | | totalTurnoverRate > 0 && totalFlowRate > 0 ? totalFlowRate / totalTurnoverRate : 1 |
| | | |
| | | const onJobTotal = getListTotal(onJobRes) |
| | | const leaveTotal = getListTotal(leaveRes) |
| | | const totalStaff = onJobTotal + leaveTotal |
| | | const leaveLast12Months = getListTotal(leave12Res) |
| | | |
| | | staffCounts.value = { |
| | | onJobTotal, |
| | | leaveTotal, |
| | | totalStaff, |
| | | leaveLast12Months, |
| | | joinLeaveRatio: safeNum(joinLeaveRatio) |
| | | } |
| | | |
| | | const turnoverRateNew = |
| | | totalStaff > 0 ? (leaveLast12Months / totalStaff) * 100 : 0 |
| | | const flowRateNew = |
| | | totalStaff > 0 ? (leaveLast12Months * staffCounts.value.joinLeaveRatio / totalStaff) * 100 : 0 |
| | | |
| | | keyMetrics.value[0].value = round2(flowRateNew) |
| | | keyMetrics.value[1].value = round2(turnoverRateNew) |
| | | keyMetrics.value[2].value = onJobTotal || safeNum(totalRes?.data?.currentOnJobCount) |
| | | } catch (error) { |
| | | console.error('获取员工分析总统计数据失败:', error) |
| | | } |
| | | } |
| | | |
| | | const getLeaveCountsByMonthForLast12Months = async () => { |
| | | const ranges = getLast12MonthRanges() |
| | | const tasks = ranges.map((r) => |
| | | findStaffLeaveListPage({ |
| | | current: 1, |
| | | size: 1, |
| | | leaveDateStart: r.start, |
| | | leaveDateEnd: r.end |
| | | }) |
| | | ) |
| | | |
| | | const results = await Promise.allSettled(tasks) |
| | | const counts = {} |
| | | results.forEach((res, idx) => { |
| | | const month = ranges[idx].month |
| | | counts[month] = res.status === 'fulfilled' ? getListTotal(res.value) : 0 |
| | | }) |
| | | const sum = Object.values(counts).reduce((acc, cur) => acc + safeNum(cur), 0) |
| | | return { counts, sum } |
| | | } |
| | | |
| | | const applyNormalizedTurnoverStatistics = async () => { |
| | | const raw = Array.isArray(turnoverRateStatisticsRaw.value) ? turnoverRateStatisticsRaw.value : [] |
| | | if (raw.length === 0) { |
| | | turnoverRateStatistics.value = [] |
| | | turnoverSeriesNormalized.value = false |
| | | return |
| | | } |
| | | |
| | | const totalStaff = safeNum(staffCounts.value.totalStaff) |
| | | const leaveLast12Months = safeNum(staffCounts.value.leaveLast12Months) |
| | | const ratio = safeNum(staffCounts.value.joinLeaveRatio) || 1 |
| | | |
| | | if (totalStaff <= 0 || leaveLast12Months < 0) { |
| | | turnoverRateStatistics.value = raw |
| | | turnoverSeriesNormalized.value = false |
| | | return |
| | | } |
| | | |
| | | try { |
| | | const { counts, sum } = await getLeaveCountsByMonthForLast12Months() |
| | | if (sum > leaveLast12Months) { |
| | | turnoverRateStatistics.value = raw |
| | | turnoverSeriesNormalized.value = false |
| | | return |
| | | } |
| | | |
| | | turnoverRateStatistics.value = raw.map((item) => { |
| | | const monthKey = String(item?.month ?? '') |
| | | const leaveCount = safeNum(counts[monthKey]) |
| | | const turnoverRate = totalStaff > 0 ? (leaveCount / totalStaff) * 100 : 0 |
| | | const flowRate = totalStaff > 0 ? (leaveCount * ratio / totalStaff) * 100 : 0 |
| | | return { |
| | | ...item, |
| | | flowRate: round2(flowRate), |
| | | turnoverRate: round2(turnoverRate) |
| | | } |
| | | }) |
| | | turnoverSeriesNormalized.value = true |
| | | } catch (e) { |
| | | turnoverRateStatistics.value = raw |
| | | turnoverSeriesNormalized.value = false |
| | | } |
| | | } |
| | | |
| | | // 启动自动刷新 |
| | | const startAutoRefresh = () => { |
| | |
| | | } |
| | | } |
| | | |
| | | // 生成模拟数据 |
| | | const generateMockData = () => { |
| | | // 生成关键指标数据 |
| | | keyMetrics.value[0].value = (Math.random() * 5 + 2).toFixed(1) |
| | | keyMetrics.value[0].trend = (Math.random() * 3 - 1.5).toFixed(1) |
| | | |
| | | keyMetrics.value[1].value = (Math.random() * 3 + 1).toFixed(1) |
| | | keyMetrics.value[1].trend = (Math.random() * 2 - 1).toFixed(1) |
| | | |
| | | keyMetrics.value[2].value = (Math.random() * 15 + 85).toFixed(1) |
| | | keyMetrics.value[2].trend = (Math.random() * 3 - 1.5).toFixed(1) |
| | | |
| | | keyMetrics.value[3].value = 20 |
| | | keyMetrics.value[3].trend = (Math.random() * 2 - 1).toFixed(1) |
| | | |
| | | // 生成部门数据 |
| | | const departments = ['技术部', '销售部', '人事部', '财务部', '生产部', '市场部'] |
| | | departmentData.value = departments.map(dept => ({ |
| | | department: dept, |
| | | currentStaff: Math.floor(Math.random() * 30 + 20), |
| | | plannedStaff: Math.floor(Math.random() * 10 + 35), |
| | | staffingRate: Math.floor(Math.random() * 20 + 80), |
| | | turnoverRate: (Math.random() * 4 + 1).toFixed(1), |
| | | attritionRate: (Math.random() * 2 + 0.5).toFixed(1), |
| | | newHires: Math.floor(Math.random() * 5 + 1), |
| | | resignations: Math.floor(Math.random() * 3 + 1), |
| | | status: Math.random() > 0.7 ? '异常' : '正常' |
| | | })) |
| | | } |
| | | |
| | | // 刷新数据 |
| | | // 修改为异步函数,确保数据加载完成后再渲染图表 |
| | | const refreshData = async () => { |
| | | loading.value = true |
| | | try { |
| | | // 模拟API调用延迟 |
| | | await new Promise(resolve => setTimeout(resolve, 500)) |
| | | |
| | | generateMockData() |
| | | loading.value = true |
| | | |
| | | // 等待所有数据加载完成 |
| | | await Promise.all([ |
| | | getDepartmentData(), |
| | | getStaffLeaveReasonAnalysis(), |
| | | getMonthlyTurnoverRateFor12Months(), |
| | | getStaffAnalysisTotalStatistic() |
| | | ]) |
| | | |
| | | await applyNormalizedTurnoverStatistics() |
| | | |
| | | await nextTick() |
| | | renderAllCharts() |
| | | |
| | | |
| | | if (!autoRefreshEnabled.value) { |
| | | ElMessage.success('数据刷新成功') |
| | | } |
| | | } catch (error) { |
| | | console.error('刷新数据失败:', error) |
| | | ElMessage.error('刷新数据失败') |
| | | console.error('数据刷新失败:', error) |
| | | ElMessage.error('数据刷新失败') |
| | | } finally { |
| | | loading.value = false |
| | | } |
| | |
| | | if (attritionChartRef.value) { |
| | | attritionChart = echarts.init(attritionChartRef.value) |
| | | } |
| | | |
| | | renderAllCharts() |
| | | |
| | | // 初始化时也先加载数据再渲染图表 |
| | | refreshData() |
| | | }, 300) |
| | | } |
| | | |
| | |
| | | renderAttritionChart() |
| | | } |
| | | |
| | | // 渲染员工流动率趋势图 |
| | | // 修改为使用API返回的实际数据 |
| | | const renderTurnoverChart = () => { |
| | | if (!turnoverChart) return |
| | | |
| | | const months = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'] |
| | | const turnoverData = months.map(() => (Math.random() * 5 + 2).toFixed(1)) |
| | | const attritionData = months.map(() => (Math.random() * 3 + 1).toFixed(1)) |
| | | |
| | | |
| | | // 使用API返回的实际数据 |
| | | const months = turnoverRateStatistics.value.map(item => item.month) |
| | | const turnoverData = turnoverRateStatistics.value.map(item => item.flowRate || 0) |
| | | const attritionData = turnoverRateStatistics.value.map(item => item.turnoverRate || 0) |
| | | |
| | | const option = { |
| | | title: { |
| | | text: '员工流动率趋势', |
| | |
| | | data: months, |
| | | boundaryGap: false |
| | | }, |
| | | yAxis: { |
| | | type: 'value', |
| | | axisLabel: { formatter: '{value}%' } |
| | | }, |
| | | yAxis: turnoverSeriesNormalized.value |
| | | ? { |
| | | type: 'value', |
| | | axisLabel: { formatter: '{value}%' }, |
| | | min: 0, |
| | | max: 100 |
| | | } |
| | | : { |
| | | type: 'value', |
| | | axisLabel: { formatter: '{value}%' } |
| | | }, |
| | | series: [ |
| | | { |
| | | name: '流动率', |
| | |
| | | } |
| | | ] |
| | | } |
| | | |
| | | |
| | | turnoverChart.setOption(option) |
| | | } |
| | | |
| | | // 渲染部门人员分布图 |
| | | const renderDepartmentChart = () => { |
| | | if (!departmentChart) return |
| | | |
| | | |
| | | const data = departmentData.value.map(item => ({ |
| | | name: item.department, |
| | | value: item.currentStaff |
| | | name: item.deptName, |
| | | value: item.staffCount |
| | | })) |
| | | |
| | | |
| | | const option = { |
| | | title: { |
| | | text: '部门人员分布', |
| | |
| | | } |
| | | ] |
| | | } |
| | | |
| | | |
| | | departmentChart.setOption(option) |
| | | } |
| | | |
| | | // 渲染编制达成率图 |
| | | const renderStaffingChart = () => { |
| | | if (!staffingChart) return |
| | | |
| | | const departments = departmentData.value.map(item => item.department) |
| | | |
| | | const departments = departmentData.value.map(item => item.deptName) |
| | | const rates = departmentData.value.map(item => item.staffingRate) |
| | | |
| | | |
| | | const option = { |
| | | title: { |
| | | text: '编制达成率', |
| | |
| | | } |
| | | ] |
| | | } |
| | | |
| | | |
| | | staffingChart.setOption(option) |
| | | } |
| | | |
| | | // 渲染员工流失原因分析图 |
| | | const renderAttritionChart = () => { |
| | | if (!attritionChart) return |
| | | |
| | | const reasons = ['薪资待遇', '职业发展', '工作环境', '个人原因', '其他'] |
| | | const data = reasons.map(() => Math.floor(Math.random() * 20 + 5)) |
| | | |
| | | |
| | | const reasons = staffLeaveReasons.value.map(item => item.reasonText) |
| | | const data = staffLeaveReasons.value.map(item => item.count) |
| | | |
| | | const option = { |
| | | title: { |
| | | text: '员工流失原因分析', |
| | |
| | | } |
| | | ] |
| | | } |
| | | |
| | | |
| | | attritionChart.setOption(option) |
| | | } |
| | | |
| | | |
| | | // 生命周期 |
| | | onMounted(() => { |
| | | generateMockData() |
| | | initCharts() |
| | | startAutoRefresh() |
| | | }) |
| | |
| | | .analytics-container { |
| | | padding: 10px; |
| | | } |
| | | |
| | | |
| | | .page-header { |
| | | padding: 15px; |
| | | } |
| | | |
| | | |
| | | .page-header h2 { |
| | | font-size: 24px; |
| | | } |
| | | |
| | | |
| | | .header-controls { |
| | | flex-direction: column; |
| | | gap: 15px; |
| | | } |
| | | |
| | | |
| | | .refresh-btn { |
| | | margin-left: 0; |
| | | } |
| | | |
| | | |
| | | .metrics-cards .el-col { |
| | | margin-bottom: 15px; |
| | | } |
| | | |
| | | |
| | | .charts-section .el-col { |
| | | margin-bottom: 20px; |
| | | } |
| | | |
| | | |
| | | .chart-container { |
| | | height: 300px; |
| | | } |
| | |
| | | .page-header h2 { |
| | | font-size: 20px; |
| | | } |
| | | |
| | | |
| | | .card-number { |
| | | font-size: 24px; |
| | | } |
| | | |
| | | |
| | | .chart-container { |
| | | height: 250px; |
| | | } |