feat: 初始化员工缺勤分析系统项目

搭建完整的前后端分离架构,实现数据概览、预测分析、聚类分析等核心功能模块

  详细版:
  feat: 初始化员工缺勤分析系统项目

  - 后端:基于 Flask 搭建 RESTful API,包含数据概览、特征分析、预测模型、聚类分析四大模块
  - 前端:基于 Vue.js 构建单页应用,实现 Dashboard、预测、聚类、因子分析等页面
  - 模型:集成随机森林、XGBoost、LightGBM、Stacking 等多种机器学习模型
  - 文档:完成需求规格说明、系统架构设计、接口设计、数据设计、UI原型设计等文档
This commit is contained in:
2026-03-08 14:48:26 +08:00
commit a39d8b2fd2
48 changed files with 9546 additions and 0 deletions

View 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">
: {{ 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>缺勤时长 &lt; 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>缺勤时长 &gt; 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>