¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <div class="app-container analytics-container"> |
| | | |
| | | <!-- å
³é®ææ å¡ç --> |
| | | <el-row :gutter="20" class="metrics-cards"> |
| | | <el-col :span="6" v-for="(item, index) in keyMetrics" :key="index"> |
| | | <el-card class="metric-card" :class="item.type"> |
| | | <div class="card-content"> |
| | | <div class="card-icon"> |
| | | <el-icon :size="32"> |
| | | <component :is="item.icon" /> |
| | | </el-icon> |
| | | </div> |
| | | <div class="card-info"> |
| | | <div class="card-number"> |
| | | <el-skeleton-item v-if="loading" variant="text" style="width: 60px; height: 32px;" /> |
| | | <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> |
| | | </div> |
| | | </el-card> |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <!-- å¾è¡¨åºå --> |
| | | <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="info">è¿12个æ</el-tag> |
| | | </div> |
| | | </template> |
| | | <div class="chart-container"> |
| | | <div ref="turnoverChartRef" class="chart"></div> |
| | | </div> |
| | | </el-card> |
| | | </el-col> |
| | | |
| | | <!-- é¨é¨äººååå¸ --> |
| | | <el-col :span="12"> |
| | | <el-card class="chart-card"> |
| | | <template #header> |
| | | <div class="card-header"> |
| | | <span>é¨é¨äººååå¸</span> |
| | | <el-tag type="success">å½åç¶æ</el-tag> |
| | | </div> |
| | | </template> |
| | | <div class="chart-container"> |
| | | <div ref="departmentChartRef" class="chart"></div> |
| | | </div> |
| | | </el-card> |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <!-- 第äºè¡å¾è¡¨ --> |
| | | <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 #header> |
| | | <div class="card-header"> |
| | | <span>åå·¥æµå¤±åå åæ</span> |
| | | <el-tag type="danger">年度ç»è®¡</el-tag> |
| | | </div> |
| | | </template> |
| | | <div class="chart-container"> |
| | | <div ref="attritionChartRef" class="chart"></div> |
| | | </div> |
| | | </el-card> |
| | | </el-col> |
| | | </el-row> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { ref, reactive, onMounted, onUnmounted } 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' |
| | | |
| | | // ååºå¼æ°æ® |
| | | const loading = ref(false) |
| | | const autoRefreshEnabled = ref(true) |
| | | const autoRefreshInterval = ref(null) |
| | | |
| | | // å¾è¡¨å¼ç¨ |
| | | const turnoverChartRef = ref(null) |
| | | const departmentChartRef = ref(null) |
| | | const staffingChartRef = ref(null) |
| | | const attritionChartRef = ref(null) |
| | | |
| | | // å¾è¡¨å®ä¾ |
| | | let turnoverChart = null |
| | | let departmentChart = null |
| | | let staffingChart = null |
| | | let attritionChart = null |
| | | |
| | | // èªå¨æ´æ°é´éï¼10åéï¼ |
| | | const AUTO_REFRESH_INTERVAL = 10 * 60 * 1000 |
| | | |
| | | // å
³é®ææ æ°æ® |
| | | const keyMetrics = ref([ |
| | | { |
| | | label: 'åå·¥æµå¨ç', |
| | | value: 0, |
| | | unit: '%', |
| | | icon: 'TrendCharts', |
| | | type: 'primary', |
| | | trend: 0 |
| | | }, |
| | | { |
| | | label: 'åå·¥æµå¤±ç', |
| | | value: 0, |
| | | unit: '%', |
| | | icon: 'User', |
| | | type: 'danger', |
| | | trend: 0 |
| | | }, |
| | | { |
| | | label: 'ç¼å¶è¾¾æç', |
| | | value: 0, |
| | | unit: '%', |
| | | icon: 'DataAnalysis', |
| | | type: 'success', |
| | | trend: 0 |
| | | }, |
| | | { |
| | | label: 'å¨èåå·¥æ°', |
| | | value: 0, |
| | | unit: '人', |
| | | icon: 'PieChart', |
| | | type: 'warning', |
| | | trend: 0 |
| | | } |
| | | ]) |
| | | |
| | | // é¨é¨æ°æ® |
| | | const departmentData = ref([]) |
| | | |
| | | // å¯å¨èªå¨å·æ° |
| | | const startAutoRefresh = () => { |
| | | if (autoRefreshInterval.value) { |
| | | clearInterval(autoRefreshInterval.value) |
| | | } |
| | | if (autoRefreshEnabled.value) { |
| | | autoRefreshInterval.value = setInterval(() => { |
| | | refreshData() |
| | | }, AUTO_REFRESH_INTERVAL) |
| | | } |
| | | } |
| | | |
| | | // 忢èªå¨å·æ° |
| | | const stopAutoRefresh = () => { |
| | | if (autoRefreshInterval.value) { |
| | | clearInterval(autoRefreshInterval.value) |
| | | autoRefreshInterval.value = null |
| | | } |
| | | } |
| | | |
| | | // 忢èªå¨å·æ°ç¶æ |
| | | const toggleAutoRefresh = (value) => { |
| | | if (value) { |
| | | startAutoRefresh() |
| | | } else { |
| | | stopAutoRefresh() |
| | | } |
| | | } |
| | | |
| | | // çææ¨¡ææ°æ® |
| | | 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 = Math.floor(Math.random() * 50 + 200) |
| | | 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() |
| | | renderAllCharts() |
| | | |
| | | if (!autoRefreshEnabled.value) { |
| | | ElMessage.success('æ°æ®å·æ°æå') |
| | | } |
| | | } catch (error) { |
| | | console.error('å·æ°æ°æ®å¤±è´¥:', error) |
| | | ElMessage.error('å·æ°æ°æ®å¤±è´¥') |
| | | } finally { |
| | | loading.value = false |
| | | } |
| | | } |
| | | |
| | | // åå§åå¾è¡¨ |
| | | const initCharts = () => { |
| | | setTimeout(() => { |
| | | if (turnoverChartRef.value) { |
| | | turnoverChart = echarts.init(turnoverChartRef.value) |
| | | } |
| | | if (departmentChartRef.value) { |
| | | departmentChart = echarts.init(departmentChartRef.value) |
| | | } |
| | | if (staffingChartRef.value) { |
| | | staffingChart = echarts.init(staffingChartRef.value) |
| | | } |
| | | if (attritionChartRef.value) { |
| | | attritionChart = echarts.init(attritionChartRef.value) |
| | | } |
| | | |
| | | renderAllCharts() |
| | | }, 300) |
| | | } |
| | | |
| | | // æ¸²æææå¾è¡¨ |
| | | const renderAllCharts = () => { |
| | | renderTurnoverChart() |
| | | renderDepartmentChart() |
| | | renderStaffingChart() |
| | | renderAttritionChart() |
| | | } |
| | | |
| | | // 渲æåå·¥æµå¨çè¶å¿å¾ |
| | | 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)) |
| | | |
| | | const option = { |
| | | title: { |
| | | text: 'åå·¥æµå¨çè¶å¿', |
| | | left: 'center', |
| | | textStyle: { fontSize: 16, fontWeight: 'normal' } |
| | | }, |
| | | tooltip: { |
| | | trigger: 'axis', |
| | | axisPointer: { type: 'cross' } |
| | | }, |
| | | legend: { |
| | | data: ['æµå¨ç', 'æµå¤±ç'], |
| | | bottom: 10 |
| | | }, |
| | | grid: { |
| | | left: '3%', |
| | | right: '4%', |
| | | bottom: '15%', |
| | | top: '15%', |
| | | containLabel: true |
| | | }, |
| | | xAxis: { |
| | | type: 'category', |
| | | data: months, |
| | | boundaryGap: false |
| | | }, |
| | | yAxis: { |
| | | type: 'value', |
| | | axisLabel: { formatter: '{value}%' } |
| | | }, |
| | | series: [ |
| | | { |
| | | name: 'æµå¨ç', |
| | | type: 'line', |
| | | data: turnoverData, |
| | | smooth: true, |
| | | lineStyle: { color: '#409EFF' }, |
| | | itemStyle: { color: '#409EFF' } |
| | | }, |
| | | { |
| | | name: 'æµå¤±ç', |
| | | type: 'line', |
| | | data: attritionData, |
| | | smooth: true, |
| | | lineStyle: { color: '#F56C6C' }, |
| | | itemStyle: { color: '#F56C6C' } |
| | | } |
| | | ] |
| | | } |
| | | |
| | | turnoverChart.setOption(option) |
| | | } |
| | | |
| | | // 渲æé¨é¨äººååå¸å¾ |
| | | const renderDepartmentChart = () => { |
| | | if (!departmentChart) return |
| | | |
| | | const data = departmentData.value.map(item => ({ |
| | | name: item.department, |
| | | value: item.currentStaff |
| | | })) |
| | | |
| | | const option = { |
| | | title: { |
| | | text: 'é¨é¨äººååå¸', |
| | | left: 'center', |
| | | textStyle: { fontSize: 16, fontWeight: 'normal' } |
| | | }, |
| | | tooltip: { |
| | | trigger: 'item', |
| | | formatter: '{a} <br/>{b}: {c}人 ({d}%)' |
| | | }, |
| | | legend: { |
| | | orient: 'vertical', |
| | | left: 'left', |
| | | top: 'middle' |
| | | }, |
| | | series: [ |
| | | { |
| | | name: 'äººåæ°é', |
| | | type: 'pie', |
| | | radius: ['40%', '70%'], |
| | | center: ['60%', '50%'], |
| | | data: data, |
| | | emphasis: { |
| | | itemStyle: { |
| | | shadowBlur: 10, |
| | | shadowOffsetX: 0, |
| | | shadowColor: 'rgba(0, 0, 0, 0.5)' |
| | | } |
| | | } |
| | | } |
| | | ] |
| | | } |
| | | |
| | | departmentChart.setOption(option) |
| | | } |
| | | |
| | | // 渲æç¼å¶è¾¾æçå¾ |
| | | const renderStaffingChart = () => { |
| | | if (!staffingChart) return |
| | | |
| | | const departments = departmentData.value.map(item => item.department) |
| | | const rates = departmentData.value.map(item => item.staffingRate) |
| | | |
| | | const option = { |
| | | title: { |
| | | text: 'ç¼å¶è¾¾æç', |
| | | left: 'center', |
| | | textStyle: { fontSize: 16, fontWeight: 'normal' } |
| | | }, |
| | | tooltip: { |
| | | trigger: 'axis', |
| | | axisPointer: { type: 'shadow' } |
| | | }, |
| | | grid: { |
| | | left: '3%', |
| | | right: '4%', |
| | | bottom: '15%', |
| | | top: '15%', |
| | | containLabel: true |
| | | }, |
| | | xAxis: { |
| | | type: 'category', |
| | | data: departments, |
| | | axisLabel: { rotate: 45 } |
| | | }, |
| | | yAxis: { |
| | | type: 'value', |
| | | axisLabel: { formatter: '{value}%' }, |
| | | max: 100 |
| | | }, |
| | | series: [ |
| | | { |
| | | name: 'è¾¾æç', |
| | | type: 'bar', |
| | | data: rates, |
| | | itemStyle: { |
| | | color: function(params) { |
| | | const value = params.value |
| | | if (value >= 90) return '#67C23A' |
| | | if (value >= 80) return '#E6A23C' |
| | | return '#F56C6C' |
| | | } |
| | | } |
| | | } |
| | | ] |
| | | } |
| | | |
| | | staffingChart.setOption(option) |
| | | } |
| | | |
| | | // 渲æåå·¥æµå¤±åå åæå¾ |
| | | const renderAttritionChart = () => { |
| | | if (!attritionChart) return |
| | | |
| | | const reasons = ['èªèµå¾
é', 'èä¸åå±', 'å·¥ä½ç¯å¢', '个人åå ', 'å
¶ä»'] |
| | | const data = reasons.map(() => Math.floor(Math.random() * 20 + 5)) |
| | | |
| | | const option = { |
| | | title: { |
| | | text: 'åå·¥æµå¤±åå åæ', |
| | | left: 'center', |
| | | textStyle: { fontSize: 16, fontWeight: 'normal' } |
| | | }, |
| | | tooltip: { |
| | | trigger: 'item', |
| | | formatter: '{a} <br/>{b}: {c}人 ({d}%)' |
| | | }, |
| | | legend: { |
| | | orient: 'vertical', |
| | | left: 'left', |
| | | top: 'middle' |
| | | }, |
| | | series: [ |
| | | { |
| | | name: 'æµå¤±äººæ°', |
| | | type: 'pie', |
| | | radius: '50%', |
| | | center: ['60%', '50%'], |
| | | data: reasons.map((reason, index) => ({ |
| | | name: reason, |
| | | value: data[index] |
| | | })), |
| | | emphasis: { |
| | | itemStyle: { |
| | | shadowBlur: 10, |
| | | shadowOffsetX: 0, |
| | | shadowColor: 'rgba(0, 0, 0, 0.5)' |
| | | } |
| | | } |
| | | } |
| | | ] |
| | | } |
| | | |
| | | attritionChart.setOption(option) |
| | | } |
| | | |
| | | // çå½å¨æ |
| | | onMounted(() => { |
| | | generateMockData() |
| | | initCharts() |
| | | startAutoRefresh() |
| | | }) |
| | | |
| | | onUnmounted(() => { |
| | | stopAutoRefresh() |
| | | }) |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .analytics-container { |
| | | padding: 20px; |
| | | background-color: #f5f7fa; |
| | | min-height: 100vh; |
| | | } |
| | | |
| | | .page-header { |
| | | text-align: center; |
| | | margin-bottom: 30px; |
| | | padding: 20px; |
| | | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
| | | border-radius: 12px; |
| | | color: white; |
| | | } |
| | | |
| | | .page-header h2 { |
| | | color: white; |
| | | margin-bottom: 10px; |
| | | font-size: 28px; |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .page-header p { |
| | | color: rgba(255, 255, 255, 0.9); |
| | | font-size: 14px; |
| | | margin: 0 0 15px 0; |
| | | } |
| | | |
| | | .header-controls { |
| | | display: flex; |
| | | justify-content: center; |
| | | align-items: center; |
| | | gap: 20px; |
| | | } |
| | | |
| | | .refresh-btn { |
| | | margin-left: 20px; |
| | | } |
| | | |
| | | .metrics-cards { |
| | | margin-bottom: 30px; |
| | | } |
| | | |
| | | .metric-card { |
| | | border-radius: 12px; |
| | | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); |
| | | transition: all 0.3s ease; |
| | | border: none; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .metric-card:hover { |
| | | transform: translateY(-5px); |
| | | box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15); |
| | | } |
| | | |
| | | .metric-card.primary { |
| | | border-left: 4px solid #409EFF; |
| | | background: linear-gradient(135deg, #409EFF 0%, #36a3f7 100%); |
| | | } |
| | | |
| | | .metric-card.danger { |
| | | border-left: 4px solid #F56C6C; |
| | | background: linear-gradient(135deg, #F56C6C 0%, #f78989 100%); |
| | | } |
| | | |
| | | .metric-card.success { |
| | | border-left: 4px solid #67C23A; |
| | | background: linear-gradient(135deg, #67C23A 0%, #85ce61 100%); |
| | | } |
| | | |
| | | .metric-card.warning { |
| | | border-left: 4px solid #E6A23C; |
| | | background: linear-gradient(135deg, #E6A23C 0%, #ebb563 100%); |
| | | } |
| | | |
| | | .card-content { |
| | | display: flex; |
| | | align-items: center; |
| | | padding: 20px; |
| | | } |
| | | |
| | | .card-icon { |
| | | margin-right: 20px; |
| | | color: white; |
| | | } |
| | | |
| | | .card-info { |
| | | flex: 1; |
| | | } |
| | | |
| | | .card-number { |
| | | font-size: 32px; |
| | | font-weight: 600; |
| | | color: white; |
| | | margin-bottom: 5px; |
| | | } |
| | | |
| | | .card-label { |
| | | font-size: 14px; |
| | | color: rgba(255, 255, 255, 0.9); |
| | | margin-bottom: 5px; |
| | | } |
| | | |
| | | .card-trend { |
| | | font-size: 12px; |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 4px; |
| | | } |
| | | |
| | | .card-trend.positive { |
| | | color: #67C23A; |
| | | } |
| | | |
| | | .card-trend.negative { |
| | | color: #F56C6C; |
| | | } |
| | | |
| | | .charts-section { |
| | | margin-bottom: 30px; |
| | | } |
| | | |
| | | .chart-card { |
| | | border-radius: 12px; |
| | | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); |
| | | border: none; |
| | | } |
| | | |
| | | .card-header { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | padding: 15px 20px; |
| | | border-bottom: 1px solid #ebeef5; |
| | | } |
| | | |
| | | .chart-container { |
| | | height: 350px; |
| | | padding: 20px; |
| | | } |
| | | |
| | | .chart { |
| | | width: 100%; |
| | | height: 100%; |
| | | } |
| | | |
| | | /* ååºå¼è®¾è®¡ */ |
| | | @media (max-width: 768px) { |
| | | .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; |
| | | } |
| | | } |
| | | |
| | | @media (max-width: 480px) { |
| | | .page-header h2 { |
| | | font-size: 20px; |
| | | } |
| | | |
| | | .card-number { |
| | | font-size: 24px; |
| | | } |
| | | |
| | | .chart-container { |
| | | height: 250px; |
| | | } |
| | | } |
| | | </style> |