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

43
frontend/.gitignore vendored Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

74
frontend/src/App.vue Normal file
View 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>

View 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')
}

View 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
View 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')

View 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

View 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>

View 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>

View 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>

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>

21
frontend/vite.config.js Normal file
View 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
}
}
}
})