feat: 初始化员工缺勤分析系统项目
搭建完整的前后端分离架构,实现数据概览、预测分析、聚类分析等核心功能模块 详细版: feat: 初始化员工缺勤分析系统项目 - 后端:基于 Flask 搭建 RESTful API,包含数据概览、特征分析、预测模型、聚类分析四大模块 - 前端:基于 Vue.js 构建单页应用,实现 Dashboard、预测、聚类、因子分析等页面 - 模型:集成随机森林、XGBoost、LightGBM、Stacking 等多种机器学习模型 - 文档:完成需求规格说明、系统架构设计、接口设计、数据设计、UI原型设计等文档
This commit is contained in:
43
frontend/.gitignore
vendored
Normal file
43
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
dist-ssr/
|
||||
*.local
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor directories and files
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
.nyc_output/
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>员工缺勤分析与预测系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "absenteeism-analysis-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "员工缺勤分析与预测系统前端",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.2.5",
|
||||
"element-plus": "^2.4.4",
|
||||
"echarts": "^5.4.3",
|
||||
"axios": "^1.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.5.2",
|
||||
"vite": "^5.0.10"
|
||||
}
|
||||
}
|
||||
1132
frontend/pnpm-lock.yaml
generated
Normal file
1132
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
74
frontend/src/App.vue
Normal file
74
frontend/src/App.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<el-container class="app-container">
|
||||
<el-header class="app-header">
|
||||
<div class="logo">员工缺勤分析与预测系统</div>
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
mode="horizontal"
|
||||
router
|
||||
class="nav-menu"
|
||||
>
|
||||
<el-menu-item index="/dashboard">数据概览</el-menu-item>
|
||||
<el-menu-item index="/analysis">影响因素</el-menu-item>
|
||||
<el-menu-item index="/prediction">缺勤预测</el-menu-item>
|
||||
<el-menu-item index="/clustering">员工画像</el-menu-item>
|
||||
</el-menu>
|
||||
</el-header>
|
||||
<el-main class="app-main">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const activeMenu = computed(() => route.path)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
height: 60px !important;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #409EFF;
|
||||
margin-right: 40px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
border-bottom: none;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: 20px;
|
||||
background-color: #f5f7fa;
|
||||
min-height: calc(100vh - 60px);
|
||||
}
|
||||
</style>
|
||||
21
frontend/src/api/overview.js
Normal file
21
frontend/src/api/overview.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import request from './request'
|
||||
|
||||
export function getStats() {
|
||||
return request.get('/overview/stats')
|
||||
}
|
||||
|
||||
export function getTrend() {
|
||||
return request.get('/overview/trend')
|
||||
}
|
||||
|
||||
export function getWeekday() {
|
||||
return request.get('/overview/weekday')
|
||||
}
|
||||
|
||||
export function getReasons() {
|
||||
return request.get('/overview/reasons')
|
||||
}
|
||||
|
||||
export function getSeasons() {
|
||||
return request.get('/overview/seasons')
|
||||
}
|
||||
21
frontend/src/api/request.js
Normal file
21
frontend/src/api/request.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
request.interceptors.response.use(
|
||||
response => {
|
||||
const res = response.data
|
||||
if (res.code !== 200) {
|
||||
return Promise.reject(new Error(res.message || 'Error'))
|
||||
}
|
||||
return res.data
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
12
frontend/src/main.js
Normal file
12
frontend/src/main.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(ElementPlus)
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
44
frontend/src/router/index.js
Normal file
44
frontend/src/router/index.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/dashboard'
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
meta: { title: '数据概览' }
|
||||
},
|
||||
{
|
||||
path: '/analysis',
|
||||
name: 'FactorAnalysis',
|
||||
component: () => import('@/views/FactorAnalysis.vue'),
|
||||
meta: { title: '影响因素分析' }
|
||||
},
|
||||
{
|
||||
path: '/prediction',
|
||||
name: 'Prediction',
|
||||
component: () => import('@/views/Prediction.vue'),
|
||||
meta: { title: '缺勤预测' }
|
||||
},
|
||||
{
|
||||
path: '/clustering',
|
||||
name: 'Clustering',
|
||||
component: () => import('@/views/Clustering.vue'),
|
||||
meta: { title: '员工画像' }
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
document.title = to.meta.title || '员工缺勤分析与预测系统'
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
126
frontend/src/views/Clustering.vue
Normal file
126
frontend/src/views/Clustering.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="clustering">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center">
|
||||
<span>员工群体画像</span>
|
||||
<el-select v-model="nClusters" @change="loadData" style="width: 120px">
|
||||
<el-option :label="2" :value="2" />
|
||||
<el-option :label="3" :value="3" />
|
||||
<el-option :label="4" :value="4" />
|
||||
</el-select>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="radarChartRef" class="chart"></div>
|
||||
</el-card>
|
||||
|
||||
<el-row :gutter="20" style="margin-top: 20px">
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>聚类结果</span>
|
||||
</template>
|
||||
<el-table :data="clusterData" stripe>
|
||||
<el-table-column prop="name" label="群体名称" />
|
||||
<el-table-column prop="member_count" label="人数" />
|
||||
<el-table-column prop="percentage" label="占比(%)">
|
||||
<template #default="{ row }">{{ row.percentage }}%</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>聚类散点图</span>
|
||||
</template>
|
||||
<div ref="scatterChartRef" class="chart"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import request from '@/api/request'
|
||||
|
||||
const radarChartRef = ref(null)
|
||||
const scatterChartRef = ref(null)
|
||||
const nClusters = ref(3)
|
||||
const clusterData = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
initRadarChart()
|
||||
initScatterChart()
|
||||
await loadClusterResult()
|
||||
}
|
||||
|
||||
async function initRadarChart() {
|
||||
const chart = echarts.init(radarChartRef.value)
|
||||
try {
|
||||
const data = await request.get(`/cluster/profile?n_clusters=${nClusters.value}`)
|
||||
chart.setOption({
|
||||
tooltip: {},
|
||||
legend: { data: data.clusters.map(c => c.name) },
|
||||
radar: {
|
||||
indicator: data.dimensions.map(d => ({ name: d, max: 1 }))
|
||||
},
|
||||
series: [{
|
||||
type: 'radar',
|
||||
data: data.clusters.map(c => ({
|
||||
value: c.values,
|
||||
name: c.name
|
||||
}))
|
||||
}]
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function initScatterChart() {
|
||||
const chart = echarts.init(scatterChartRef.value)
|
||||
try {
|
||||
const data = await request.get(`/cluster/scatter?n_clusters=${nClusters.value}`)
|
||||
const grouped = {}
|
||||
data.points.forEach(p => {
|
||||
if (!grouped[p.cluster_id]) grouped[p.cluster_id] = []
|
||||
grouped[p.cluster_id].push([p.x, p.y])
|
||||
})
|
||||
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'item' },
|
||||
xAxis: { name: data.x_axis_name },
|
||||
yAxis: { name: data.y_axis_name },
|
||||
series: Object.entries(grouped).map(([id, points]) => ({
|
||||
type: 'scatter',
|
||||
data: points,
|
||||
name: `群体${Number(id) + 1}`
|
||||
}))
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadClusterResult() {
|
||||
try {
|
||||
const data = await request.get(`/cluster/result?n_clusters=${nClusters.value}`)
|
||||
clusterData.value = data.clusters
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 350px;
|
||||
}
|
||||
</style>
|
||||
189
frontend/src/views/Dashboard.vue
Normal file
189
frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<el-row :gutter="20" class="kpi-row">
|
||||
<el-col :span="6" v-for="kpi in kpiData" :key="kpi.title">
|
||||
<el-card class="kpi-card">
|
||||
<div class="kpi-title">{{ kpi.title }}</div>
|
||||
<div class="kpi-value">{{ kpi.value }}</div>
|
||||
<div class="kpi-unit">{{ kpi.unit }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-card class="chart-card">
|
||||
<template #header>
|
||||
<span>月度缺勤趋势</span>
|
||||
</template>
|
||||
<div ref="trendChartRef" class="chart"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card class="chart-card">
|
||||
<template #header>
|
||||
<span>星期分布</span>
|
||||
</template>
|
||||
<div ref="weekdayChartRef" class="chart"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" style="margin-top: 20px">
|
||||
<el-col :span="12">
|
||||
<el-card class="chart-card">
|
||||
<template #header>
|
||||
<span>缺勤原因分布</span>
|
||||
</template>
|
||||
<div ref="reasonChartRef" class="chart"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card class="chart-card">
|
||||
<template #header>
|
||||
<span>季节分布</span>
|
||||
</template>
|
||||
<div ref="seasonChartRef" class="chart"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { getStats, getTrend, getWeekday, getReasons, getSeasons } from '@/api/overview'
|
||||
|
||||
const trendChartRef = ref(null)
|
||||
const weekdayChartRef = ref(null)
|
||||
const reasonChartRef = ref(null)
|
||||
const seasonChartRef = ref(null)
|
||||
|
||||
const kpiData = ref([
|
||||
{ title: '总记录数', value: '-', unit: '条' },
|
||||
{ title: '员工总数', value: '-', unit: '人' },
|
||||
{ title: '平均缺勤时长', value: '-', unit: '小时' },
|
||||
{ title: '高风险占比', value: '-', unit: '%' }
|
||||
])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const stats = await getStats()
|
||||
kpiData.value = [
|
||||
{ title: '总记录数', value: stats.total_records, unit: '条' },
|
||||
{ title: '员工总数', value: stats.total_employees, unit: '人' },
|
||||
{ title: '平均缺勤时长', value: stats.avg_absent_hours, unit: '小时' },
|
||||
{ title: '高风险占比', value: (stats.high_risk_ratio * 100).toFixed(1), unit: '%' }
|
||||
]
|
||||
} catch (e) {
|
||||
console.error('Failed to load stats:', e)
|
||||
}
|
||||
|
||||
initTrendChart()
|
||||
initWeekdayChart()
|
||||
initReasonChart()
|
||||
initSeasonChart()
|
||||
})
|
||||
|
||||
async function initTrendChart() {
|
||||
const chart = echarts.init(trendChartRef.value)
|
||||
try {
|
||||
const data = await getTrend()
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: data.months },
|
||||
yAxis: { type: 'value', name: '小时' },
|
||||
series: [{ type: 'line', smooth: true, data: data.total_hours, areaStyle: { opacity: 0.3 } }]
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function initWeekdayChart() {
|
||||
const chart = echarts.init(weekdayChartRef.value)
|
||||
try {
|
||||
const data = await getWeekday()
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: data.weekdays },
|
||||
yAxis: { type: 'value', name: '小时' },
|
||||
series: [{ type: 'bar', data: data.total_hours, itemStyle: { color: '#409EFF' } }]
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function initReasonChart() {
|
||||
const chart = echarts.init(reasonChartRef.value)
|
||||
try {
|
||||
const data = await getReasons()
|
||||
const topReasons = data.reasons.slice(0, 8)
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { orient: 'vertical', right: 10 },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
data: topReasons.map(r => ({ value: r.count, name: r.name }))
|
||||
}]
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function initSeasonChart() {
|
||||
const chart = echarts.init(seasonChartRef.value)
|
||||
try {
|
||||
const data = await getSeasons()
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'item' },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
data: data.seasons.map(s => ({ value: s.total_hours, name: s.name }))
|
||||
}]
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kpi-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.kpi-title {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #409EFF;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.kpi-unit {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
height: 350px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
height: 280px;
|
||||
}
|
||||
</style>
|
||||
119
frontend/src/views/FactorAnalysis.vue
Normal file
119
frontend/src/views/FactorAnalysis.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="factor-analysis">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>特征重要性排序</span>
|
||||
</template>
|
||||
<div ref="importanceChartRef" class="chart"></div>
|
||||
</el-card>
|
||||
|
||||
<el-row :gutter="20" style="margin-top: 20px">
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>相关性热力图</span>
|
||||
</template>
|
||||
<div ref="correlationChartRef" class="chart"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>群体对比分析</span>
|
||||
</template>
|
||||
<el-select v-model="dimension" @change="loadComparison" style="margin-bottom: 20px">
|
||||
<el-option label="饮酒习惯" value="drinker" />
|
||||
<el-option label="吸烟习惯" value="smoker" />
|
||||
<el-option label="学历" value="education" />
|
||||
</el-select>
|
||||
<div ref="compareChartRef" class="chart"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import request from '@/api/request'
|
||||
|
||||
const importanceChartRef = ref(null)
|
||||
const correlationChartRef = ref(null)
|
||||
const compareChartRef = ref(null)
|
||||
const dimension = ref('drinker')
|
||||
|
||||
onMounted(() => {
|
||||
initImportanceChart()
|
||||
initCorrelationChart()
|
||||
loadComparison()
|
||||
})
|
||||
|
||||
async function initImportanceChart() {
|
||||
const chart = echarts.init(importanceChartRef.value)
|
||||
try {
|
||||
const data = await request.get('/analysis/importance')
|
||||
const features = data.features || []
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: '20%' },
|
||||
xAxis: { type: 'value' },
|
||||
yAxis: { type: 'category', data: features.map(f => f.name_cn).reverse() },
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: features.map(f => f.importance).reverse(),
|
||||
itemStyle: { color: '#409EFF' }
|
||||
}]
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function initCorrelationChart() {
|
||||
const chart = echarts.init(correlationChartRef.value)
|
||||
try {
|
||||
const data = await request.get('/analysis/correlation')
|
||||
chart.setOption({
|
||||
tooltip: {},
|
||||
xAxis: { type: 'category', data: data.features },
|
||||
yAxis: { type: 'category', data: data.features },
|
||||
visualMap: { min: -1, max: 1, calculable: true, inRange: { color: ['#313695', '#fff', '#a50026'] } },
|
||||
series: [{ type: 'heatmap', data: flattenMatrix(data.matrix, data.features) }]
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
function flattenMatrix(matrix, features) {
|
||||
const result = []
|
||||
for (let i = 0; i < features.length; i++) {
|
||||
for (let j = 0; j < features.length; j++) {
|
||||
result.push([i, j, matrix[i][j]])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function loadComparison() {
|
||||
const chart = echarts.init(compareChartRef.value)
|
||||
try {
|
||||
const data = await request.get(`/analysis/compare?dimension=${dimension.value}`)
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: data.groups.map(g => g.name) },
|
||||
yAxis: { type: 'value', name: '平均缺勤时长(小时)' },
|
||||
series: [{ type: 'bar', data: data.groups.map(g => g.avg_hours), itemStyle: { color: '#67C23A' } }]
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 300px;
|
||||
}
|
||||
</style>
|
||||
398
frontend/src/views/Prediction.vue
Normal file
398
frontend/src/views/Prediction.vue
Normal file
@@ -0,0 +1,398 @@
|
||||
<template>
|
||||
<div class="prediction">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="14">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center">
|
||||
<span>参数输入</span>
|
||||
<el-button size="small" @click="resetForm">重置</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :model="form" label-width="120px" size="small">
|
||||
<el-divider content-position="left">时间信息</el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="缺勤月份">
|
||||
<el-select v-model="form.month_of_absence" style="width: 100%">
|
||||
<el-option v-for="m in 12" :key="m" :label="m + '月'" :value="m" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="星期几">
|
||||
<el-select v-model="form.day_of_week" style="width: 100%">
|
||||
<el-option label="周一" :value="2" />
|
||||
<el-option label="周二" :value="3" />
|
||||
<el-option label="周三" :value="4" />
|
||||
<el-option label="周四" :value="5" />
|
||||
<el-option label="周五" :value="6" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="季节">
|
||||
<el-select v-model="form.seasons" style="width: 100%">
|
||||
<el-option label="夏季" :value="1" />
|
||||
<el-option label="秋季" :value="2" />
|
||||
<el-option label="冬季" :value="3" />
|
||||
<el-option label="春季" :value="4" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="缺勤原因">
|
||||
<el-select v-model="form.reason_for_absence" style="width: 100%">
|
||||
<el-option label="医疗咨询" :value="23" />
|
||||
<el-option label="牙科咨询" :value="28" />
|
||||
<el-option label="理疗" :value="27" />
|
||||
<el-option label="医疗随访" :value="22" />
|
||||
<el-option label="实验室检查" :value="25" />
|
||||
<el-option label="无故缺勤" :value="26" />
|
||||
<el-option label="献血" :value="24" />
|
||||
<el-option label="传染病" :value="1" />
|
||||
<el-option label="呼吸系统疾病" :value="10" />
|
||||
<el-option label="消化系统疾病" :value="11" />
|
||||
<el-option label="肌肉骨骼疾病" :value="13" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-divider content-position="left">个人信息</el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="年龄">
|
||||
<el-input-number v-model="form.age" :min="18" :max="60" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="工龄">
|
||||
<el-input-number v-model="form.service_time" :min="1" :max="30" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="学历">
|
||||
<el-select v-model="form.education" style="width: 100%">
|
||||
<el-option label="高中" :value="1" />
|
||||
<el-option label="本科" :value="2" />
|
||||
<el-option label="研究生" :value="3" />
|
||||
<el-option label="博士" :value="4" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="BMI指数">
|
||||
<el-input-number v-model="form.bmi" :min="18" :max="40" :precision="1" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-divider content-position="left">工作信息</el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="交通费用">
|
||||
<el-input-number v-model="form.transportation_expense" :min="100" :max="400" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="通勤距离">
|
||||
<el-input-number v-model="form.distance" :min="1" :max="60" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="工作负荷">
|
||||
<el-input-number v-model="form.work_load" :min="200" :max="350" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="达标率">
|
||||
<el-input-number v-model="form.hit_target" :min="80" :max="100" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="违纪记录">
|
||||
<el-radio-group v-model="form.disciplinary_failure">
|
||||
<el-radio :value="0">无</el-radio>
|
||||
<el-radio :value="1">有</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-divider content-position="left">生活习惯</el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="饮酒习惯">
|
||||
<el-radio-group v-model="form.social_drinker">
|
||||
<el-radio :value="0">否</el-radio>
|
||||
<el-radio :value="1">是</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="吸烟习惯">
|
||||
<el-radio-group v-model="form.social_smoker">
|
||||
<el-radio :value="0">否</el-radio>
|
||||
<el-radio :value="1">是</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="子女数量">
|
||||
<el-input-number v-model="form.son" :min="0" :max="5" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="宠物数量">
|
||||
<el-input-number v-model="form.pet" :min="0" :max="10" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-divider content-position="left">预测设置</el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="选择模型">
|
||||
<el-select v-model="selectedModel" style="width: 100%" :loading="modelsLoading">
|
||||
<el-option label="自动选择最优" value="" />
|
||||
<el-option
|
||||
v-for="model in availableModels"
|
||||
:key="model.name"
|
||||
:label="model.name_cn"
|
||||
:value="model.name"
|
||||
>
|
||||
<span>{{ model.name_cn }}</span>
|
||||
<span style="float: right; color: #909399; font-size: 12px">
|
||||
R²: {{ model.metrics?.r2?.toFixed(2) || '-' }}
|
||||
</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="模型对比">
|
||||
<el-switch v-model="showCompare" active-text="显示" inactive-text="隐藏" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item style="margin-top: 20px">
|
||||
<el-button type="primary" @click="handlePredict" :loading="loading" size="default">
|
||||
开始预测
|
||||
</el-button>
|
||||
<el-button @click="handleCompare" :loading="compareLoading" size="default">
|
||||
模型对比
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="10">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>预测结果</span>
|
||||
</template>
|
||||
<div v-if="result" class="result-container">
|
||||
<div class="result-value">{{ result.predicted_hours }}</div>
|
||||
<div class="result-unit">小时</div>
|
||||
<el-tag :type="riskTagType" size="large" style="margin-top: 20px">
|
||||
{{ result.risk_label }}
|
||||
</el-tag>
|
||||
<div style="margin-top: 20px; color: #909399">
|
||||
模型: {{ result.model_name_cn }}
|
||||
</div>
|
||||
<div style="margin-top: 8px; color: #909399; font-size: 12px">
|
||||
置信度: {{ (result.confidence * 100).toFixed(0) }}%
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="请输入参数后点击预测" />
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="compareResults.length > 0" style="margin-top: 20px">
|
||||
<template #header>
|
||||
<span>模型对比结果</span>
|
||||
</template>
|
||||
<el-table :data="compareResults" size="small" :row-class-name="getRowClass">
|
||||
<el-table-column prop="model_name_cn" label="模型" width="100" />
|
||||
<el-table-column prop="predicted_hours" label="预测时长" width="80">
|
||||
<template #default="{ row }">{{ row.predicted_hours }}h</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="risk_label" label="风险等级" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getRiskType(row.risk_level)" size="small">{{ row.risk_label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="r2" label="R²" width="70">
|
||||
<template #default="{ row }">{{ row.r2?.toFixed(3) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="推荐" width="60">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.recommended" type="success" size="small">推荐</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card style="margin-top: 20px">
|
||||
<template #header>
|
||||
<span>风险等级说明</span>
|
||||
</template>
|
||||
<div class="risk-legend">
|
||||
<div class="risk-item">
|
||||
<el-tag type="success" size="small">低风险</el-tag>
|
||||
<span>缺勤时长 < 4小时</span>
|
||||
</div>
|
||||
<div class="risk-item">
|
||||
<el-tag type="warning" size="small">中风险</el-tag>
|
||||
<span>缺勤时长 4-8小时</span>
|
||||
</div>
|
||||
<div class="risk-item">
|
||||
<el-tag type="danger" size="small">高风险</el-tag>
|
||||
<span>缺勤时长 > 8小时</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import request from '@/api/request'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const defaultForm = {
|
||||
reason_for_absence: 23,
|
||||
month_of_absence: 7,
|
||||
day_of_week: 3,
|
||||
seasons: 1,
|
||||
transportation_expense: 200,
|
||||
distance: 20,
|
||||
service_time: 5,
|
||||
age: 30,
|
||||
work_load: 250,
|
||||
hit_target: 95,
|
||||
disciplinary_failure: 0,
|
||||
education: 1,
|
||||
son: 0,
|
||||
pet: 0,
|
||||
bmi: 25,
|
||||
social_drinker: 0,
|
||||
social_smoker: 0
|
||||
}
|
||||
|
||||
const form = ref({ ...defaultForm })
|
||||
const result = ref(null)
|
||||
const loading = ref(false)
|
||||
const compareLoading = ref(false)
|
||||
const compareResults = ref([])
|
||||
const showCompare = ref(false)
|
||||
const selectedModel = ref('')
|
||||
const availableModels = ref([])
|
||||
const modelsLoading = ref(false)
|
||||
|
||||
const riskTagType = computed(() => {
|
||||
if (!result.value) return 'info'
|
||||
return getRiskType(result.value.risk_level)
|
||||
})
|
||||
|
||||
function getRiskType(level) {
|
||||
return level === 'low' ? 'success' : level === 'medium' ? 'warning' : 'danger'
|
||||
}
|
||||
|
||||
function getRowClass({ row }) {
|
||||
return row.recommended ? 'recommended-row' : ''
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.value = { ...defaultForm }
|
||||
result.value = null
|
||||
compareResults.value = []
|
||||
}
|
||||
|
||||
async function loadModels() {
|
||||
modelsLoading.value = true
|
||||
try {
|
||||
const res = await request.get('/predict/models')
|
||||
availableModels.value = res.models || []
|
||||
} catch (e) {
|
||||
console.error('Failed to load models:', e)
|
||||
} finally {
|
||||
modelsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePredict() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { ...form.value }
|
||||
if (selectedModel.value) {
|
||||
params.model_type = selectedModel.value
|
||||
}
|
||||
result.value = await request.post('/predict/single', params)
|
||||
|
||||
if (showCompare.value) {
|
||||
await handleCompare()
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('预测失败: ' + e.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCompare() {
|
||||
compareLoading.value = true
|
||||
try {
|
||||
const res = await request.post('/predict/compare', form.value)
|
||||
compareResults.value = res.results || []
|
||||
} catch (e) {
|
||||
ElMessage.error('对比失败: ' + e.message)
|
||||
} finally {
|
||||
compareLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadModels()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.result-container {
|
||||
text-align: center;
|
||||
padding: 30px 0;
|
||||
}
|
||||
|
||||
.result-value {
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.result-unit {
|
||||
font-size: 16px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.risk-legend {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.risk-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.el-divider {
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
:deep(.recommended-row) {
|
||||
background-color: #f0f9eb;
|
||||
}
|
||||
</style>
|
||||
21
frontend/vite.config.js
Normal file
21
frontend/vite.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user