feat: 将数据集从国外员工缺勤数据替换为中国企业缺勤模拟数据

- 新增中国企业员工缺勤模拟数据集生成脚本(generate_dataset.py),覆盖7个行业、180家企业、2600名员工
  - 重构 config.py,更新特征字段为中文名称,调整目标列、员工ID、行业类型等配置
  - 重构 clustering.py,简化聚类逻辑,更新聚类特征和群体命名(高压通勤型、健康波动型等)
  - 重构 feature_mining.py,更新相关性分析和群体比较维度(按行业、班次、婚姻状态等)
  - 新增 model_features.py 定义模型训练特征
  - 更新 preprocessing.py 和 train_model.py 适配新数据结构
  - 更新各 API 路由默认参数(model: random_forest, dimension: industry)
  - 前端更新主题样式和各视图组件适配中文字段
  - 更新系统名称为 China Enterprise Absence Analysis System
This commit is contained in:
2026-03-11 10:46:58 +08:00
parent a39d8b2fd2
commit e63267cef6
39 changed files with 15731 additions and 5648 deletions

View File

@@ -9,11 +9,12 @@
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.5",
"element-plus": "^2.4.4",
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.6.2",
"echarts": "^5.4.3",
"axios": "^1.6.2"
"element-plus": "^2.4.4",
"vue": "^3.4.0",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.2",

View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@element-plus/icons-vue':
specifier: ^2.3.2
version: 2.3.2(vue@3.5.29)
axios:
specifier: ^1.6.2
version: 1.13.6

View File

@@ -1,74 +1,345 @@
<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>
<div class="shell" :class="{ 'shell-collapsed': isSidebarCollapsed }">
<aside class="shell-sidebar">
<div class="brand-block">
<div class="brand-mark">HR</div>
<div v-if="!isSidebarCollapsed">
<div class="brand-title">企业缺勤分析台</div>
<div class="brand-subtitle">Human Resource Insight Console</div>
</div>
</div>
<div class="sidebar-panel">
<div v-if="!isSidebarCollapsed" class="sidebar-label">导航</div>
<el-menu :default-active="activeMenu" router class="nav-menu">
<el-menu-item index="/dashboard">
<el-icon class="nav-icon"><Grid /></el-icon>
<span class="nav-label">数据概览</span>
</el-menu-item>
<el-menu-item index="/analysis">
<el-icon class="nav-icon"><DataAnalysis /></el-icon>
<span class="nav-label">影响因素</span>
</el-menu-item>
<el-menu-item index="/prediction">
<el-icon class="nav-icon"><TrendCharts /></el-icon>
<span class="nav-label">缺勤预测</span>
</el-menu-item>
<el-menu-item index="/clustering">
<el-icon class="nav-icon"><UserFilled /></el-icon>
<span class="nav-label">员工画像</span>
</el-menu-item>
</el-menu>
</div>
<div v-if="!isSidebarCollapsed" class="sidebar-note">
<div class="sidebar-label">系统摘要</div>
<p>面向企业管理场景的缺勤趋势风险预测与群体画像展示</p>
</div>
</aside>
<main class="shell-main">
<header class="topbar">
<div class="topbar-main">
<el-button class="collapse-btn" circle @click="isSidebarCollapsed = !isSidebarCollapsed">
{{ isSidebarCollapsed ? '>' : '<' }}
</el-button>
<div>
<div class="topbar-title">{{ currentMeta.title || '企业缺勤分析台' }}</div>
<div class="topbar-subtitle">{{ currentMeta.subtitle }}</div>
</div>
</div>
<div class="topbar-badges">
<el-button class="theme-toggle" @click="toggleTheme">
{{ isDarkMode ? '浅色模式' : '深色模式' }}
</el-button>
<span class="topbar-badge">企业健康运营分析</span>
<span class="topbar-badge topbar-badge-accent">可视化决策界面</span>
</div>
</header>
<section class="main-content">
<router-view />
</section>
</main>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { DataAnalysis, Grid, TrendCharts, UserFilled } from '@element-plus/icons-vue'
const route = useRoute()
const activeMenu = computed(() => route.path)
const isSidebarCollapsed = ref(false)
const isDarkMode = ref(false)
const metaMap = {
'/dashboard': {
title: '数据概览',
subtitle: '从企业缺勤事件的总量时序与结构分布切入建立整体认知'
},
'/analysis': {
title: '影响因素',
subtitle: '观察模型最关注的驱动因素辅助解释缺勤风险的来源'
},
'/prediction': {
title: '缺勤预测',
subtitle: '围绕最核心的业务信号输入快速获得缺勤时长与风险等级'
},
'/clustering': {
title: '员工画像',
subtitle: '通过聚类划分典型群体为答辩演示提供更直观的人群视角'
}
}
const currentMeta = computed(() => metaMap[route.path] || { title: '企业缺勤分析台', subtitle: '' })
function applyTheme(isDark) {
const theme = isDark ? 'dark' : 'light'
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('ui-theme', theme)
}
function toggleTheme() {
isDarkMode.value = !isDarkMode.value
}
onMounted(() => {
const savedTheme = localStorage.getItem('ui-theme')
isDarkMode.value = savedTheme === 'dark'
applyTheme(isDarkMode.value)
})
watch(isDarkMode, value => {
applyTheme(value)
})
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif;
background-color: #f5f7fa;
}
.app-container {
<style scoped>
.shell {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
min-height: 100vh;
transition: grid-template-columns 0.28s ease;
}
.app-header {
background-color: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.shell.shell-collapsed {
grid-template-columns: 96px minmax(0, 1fr);
}
.shell-sidebar {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
gap: 22px;
height: 100vh;
padding: 26px 22px;
background: var(--sidebar-bg);
color: var(--sidebar-text);
transition: padding 0.28s ease;
border-right: 1px solid var(--sidebar-border);
}
.brand-block {
display: flex;
align-items: center;
padding: 0 20px;
height: 60px !important;
gap: 14px;
}
.logo {
font-size: 18px;
font-weight: bold;
color: #409EFF;
margin-right: 40px;
white-space: nowrap;
.brand-mark {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: 16px;
background: linear-gradient(135deg, #fef3c7, #fdba74);
color: #7c2d12;
font-weight: 800;
letter-spacing: 0.08em;
}
.brand-title {
font-size: 20px;
font-weight: 700;
color: var(--sidebar-text);
}
.brand-subtitle {
margin-top: 4px;
font-size: 12px;
color: var(--sidebar-text-subtle);
}
.sidebar-panel,
.sidebar-note {
padding: 18px;
border: 1px solid var(--sidebar-border);
border-radius: 22px;
background: var(--sidebar-surface);
backdrop-filter: blur(14px);
}
.sidebar-label {
margin-bottom: 14px;
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--sidebar-text-subtle);
}
.nav-menu {
border-bottom: none;
flex: 1;
border: none;
background: transparent;
}
.app-main {
padding: 20px;
background-color: #f5f7fa;
min-height: calc(100vh - 60px);
:deep(.nav-menu .el-menu-item) {
height: 48px;
margin-bottom: 8px;
border-radius: 14px;
color: var(--sidebar-text);
background: transparent;
transition: all 0.2s ease;
}
.nav-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
margin-right: 10px;
font-size: 17px;
color: var(--sidebar-text-subtle);
}
.nav-label {
font-size: 14px;
}
:deep(.nav-menu .el-menu-item.is-active) {
color: var(--sidebar-text);
background: var(--sidebar-menu-active);
}
:deep(.nav-menu .el-menu-item:hover) {
color: var(--sidebar-text);
background: var(--sidebar-menu-hover);
}
.sidebar-note p {
margin: 0;
font-size: 13px;
line-height: 1.7;
color: var(--sidebar-text-subtle);
}
.shell-main {
min-width: 0;
padding: 24px;
}
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-bottom: 22px;
}
.topbar-main {
display: flex;
align-items: flex-start;
gap: 14px;
}
.collapse-btn {
margin-top: 4px;
border: 1px solid var(--line-soft);
background: rgba(255, 255, 255, 0.82);
color: var(--brand-strong);
}
.topbar-title {
font-size: 30px;
font-weight: 700;
color: var(--text-main);
}
.topbar-subtitle {
margin-top: 8px;
max-width: 760px;
font-size: 14px;
line-height: 1.7;
color: var(--text-subtle);
}
.topbar-badges {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 10px;
}
.theme-toggle {
border: 1px solid var(--line-soft);
background: var(--surface);
color: var(--text-main);
}
.topbar-badge {
padding: 9px 14px;
border: 1px solid var(--line-soft);
border-radius: 999px;
background: var(--surface);
font-size: 12px;
color: var(--brand-strong);
}
.topbar-badge-accent {
color: var(--accent);
}
.main-content {
min-width: 0;
}
.shell-collapsed .shell-sidebar {
padding-left: 14px;
padding-right: 14px;
}
.shell-collapsed .brand-block {
justify-content: center;
}
.shell-collapsed .sidebar-panel {
padding: 14px 10px;
}
.shell-collapsed :deep(.nav-menu .el-menu-item) {
justify-content: center;
padding: 0;
}
.shell-collapsed .nav-icon {
margin-right: 0;
width: 20px;
}
.shell-collapsed .nav-label {
display: none;
}
@media (max-width: 1100px) {
.shell {
grid-template-columns: 1fr;
}
.shell-sidebar {
position: static;
height: auto;
}
}
</style>

View File

@@ -1,6 +1,7 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import './styles/theme.css'
import App from './App.vue'
import router from './router'

View File

@@ -0,0 +1,203 @@
:root {
--bg-base: #eef3f7;
--bg-soft: #f8fbfd;
--surface: rgba(255, 255, 255, 0.88);
--surface-strong: #ffffff;
--line-soft: rgba(23, 43, 77, 0.08);
--line-strong: rgba(23, 43, 77, 0.16);
--text-main: #18212f;
--text-subtle: #627086;
--brand: #0f766e;
--brand-strong: #115e59;
--accent: #c2410c;
--shadow-soft: 0 18px 50px rgba(15, 23, 42, 0.08);
--shadow-card: 0 12px 30px rgba(15, 23, 42, 0.06);
--radius-xl: 28px;
--radius-lg: 22px;
--radius-md: 16px;
--hero-text: #f8fffd;
--hero-text-subtle: rgba(248, 255, 253, 0.84);
--sidebar-bg: #f3f4f6;
--sidebar-surface: rgba(255, 255, 255, 0.72);
--sidebar-border: rgba(15, 23, 42, 0.08);
--sidebar-text: #1f2937;
--sidebar-text-subtle: #6b7280;
--sidebar-menu-hover: rgba(15, 23, 42, 0.05);
--sidebar-menu-active: rgba(15, 118, 110, 0.12);
}
:root[data-theme='dark'] {
--bg-base: #0f172a;
--bg-soft: #111827;
--surface: rgba(17, 24, 39, 0.82);
--surface-strong: #111827;
--line-soft: rgba(148, 163, 184, 0.16);
--line-strong: rgba(148, 163, 184, 0.26);
--text-main: #e5eef8;
--text-subtle: #9fb0c7;
--brand: #34d399;
--brand-strong: #6ee7b7;
--accent: #fb923c;
--shadow-soft: 0 18px 50px rgba(2, 6, 23, 0.4);
--shadow-card: 0 12px 30px rgba(2, 6, 23, 0.26);
--hero-text: #f8fafc;
--hero-text-subtle: rgba(226, 232, 240, 0.84);
--sidebar-bg: #111827;
--sidebar-surface: rgba(255, 255, 255, 0.03);
--sidebar-border: rgba(148, 163, 184, 0.14);
--sidebar-text: #e5eef8;
--sidebar-text-subtle: #94a3b8;
--sidebar-menu-hover: rgba(255, 255, 255, 0.06);
--sidebar-menu-active: rgba(52, 211, 153, 0.12);
}
* {
box-sizing: border-box;
}
html,
body,
#app {
min-height: 100%;
}
body {
margin: 0;
font-family: "Source Han Sans SC", "PingFang SC", "Microsoft YaHei UI", sans-serif;
color: var(--text-main);
background:
radial-gradient(circle at top left, rgba(15, 118, 110, 0.18), transparent 32%),
radial-gradient(circle at top right, rgba(194, 65, 12, 0.14), transparent 28%),
linear-gradient(180deg, var(--bg-soft) 0%, var(--bg-base) 100%);
transition: background 0.25s ease, color 0.25s ease;
}
a {
color: inherit;
}
.page-shell {
display: flex;
flex-direction: column;
gap: 20px;
}
.page-hero {
position: relative;
overflow: hidden;
padding: 28px 30px;
border: 1px solid rgba(255, 255, 255, 0.45);
border-radius: var(--radius-xl);
background:
linear-gradient(135deg, rgba(15, 118, 110, 0.94), rgba(21, 94, 89, 0.92) 52%, rgba(30, 41, 59, 0.92));
box-shadow: var(--shadow-soft);
color: var(--hero-text);
}
.page-hero::after {
content: '';
position: absolute;
inset: auto -80px -120px auto;
width: 240px;
height: 240px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
}
.page-eyebrow {
margin-bottom: 10px;
font-size: 12px;
letter-spacing: 0.22em;
text-transform: uppercase;
opacity: 0.72;
}
.page-title {
margin: 0;
font-size: 30px;
line-height: 1.15;
font-weight: 700;
}
.page-description {
max-width: 720px;
margin: 12px 0 0;
font-size: 14px;
line-height: 1.7;
color: var(--hero-text-subtle);
}
.glass-card.el-card,
.panel-card.el-card,
.metric-card.el-card {
border: 1px solid var(--line-soft);
border-radius: var(--radius-lg);
background: var(--surface);
box-shadow: var(--shadow-card);
backdrop-filter: blur(14px);
}
.glass-card .el-card__body,
.panel-card .el-card__body,
.metric-card .el-card__body {
padding: 22px;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.section-title {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--text-main);
}
.section-caption {
margin: 6px 0 0;
font-size: 13px;
color: var(--text-subtle);
}
.chart-frame {
height: 300px;
}
.soft-tag {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-radius: 999px;
font-size: 12px;
color: var(--brand-strong);
background: rgba(15, 118, 110, 0.1);
}
:root[data-theme='dark'] .soft-tag {
background: rgba(52, 211, 153, 0.14);
}
.soft-grid {
display: grid;
gap: 18px;
}
@media (max-width: 960px) {
.page-hero {
padding: 22px 20px;
}
.page-title {
font-size: 24px;
}
.chart-frame {
height: 260px;
}
}

View File

@@ -1,40 +1,58 @@
<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 class="page-shell">
<section class="page-hero cluster-hero">
<div class="page-eyebrow">Clustering</div>
<h1 class="page-title">员工画像与群体切片</h1>
<p class="page-description">
将员工划分为不同缺勤画像群体通过雷达图和散点图形成直观的人群对比展示
</p>
</section>
<el-card class="panel-card" shadow="never">
<div class="section-heading">
<div>
<h3 class="section-title">群体雷达画像</h3>
<p class="section-caption">以年龄司龄加班通勤BMI 和缺勤水平构建群体轮廓</p>
</div>
</template>
<div ref="radarChartRef" class="chart"></div>
<el-select v-model="nClusters" @change="loadData" class="cluster-select">
<el-option :label="2" :value="2" />
<el-option :label="3" :value="3" />
<el-option :label="4" :value="4" />
</el-select>
</div>
<div ref="radarChartRef" class="chart-frame"></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="占比(%)">
<el-row :gutter="20">
<el-col :xs="24" :xl="11">
<el-card class="panel-card" shadow="never">
<div class="section-heading">
<div>
<h3 class="section-title">聚类结果</h3>
<p class="section-caption">便于答辩时逐个介绍群体特征</p>
</div>
<span class="soft-tag">Profiles</span>
</div>
<el-table :data="clusterData" stripe class="cluster-table">
<el-table-column prop="name" label="群体名称" min-width="120" />
<el-table-column prop="member_count" label="人数" width="90" />
<el-table-column prop="percentage" label="占比(%)" width="90">
<template #default="{ row }">{{ row.percentage }}%</template>
</el-table-column>
<el-table-column prop="description" label="说明" min-width="180" />
</el-table>
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>
<span>聚类散点图</span>
</template>
<div ref="scatterChartRef" class="chart"></div>
<el-col :xs="24" :xl="13">
<el-card class="panel-card" shadow="never">
<div class="section-heading">
<div>
<h3 class="section-title">加班与缺勤散点图</h3>
<p class="section-caption">展示各聚类在加班强度与缺勤水平上的位置差异</p>
</div>
<span class="soft-tag">Scatter</span>
</div>
<div ref="scatterChartRef" class="chart-frame"></div>
</el-card>
</el-col>
</el-row>
@@ -42,7 +60,7 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { onMounted, ref } from 'vue'
import * as echarts from 'echarts'
import request from '@/api/request'
@@ -56,71 +74,59 @@ onMounted(() => {
})
async function loadData() {
initRadarChart()
initScatterChart()
await loadClusterResult()
await Promise.all([initRadarChart(), initScatterChart(), 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)
}
const data = await request.get(`/cluster/profile?n_clusters=${nClusters.value}`)
chart.setOption({
tooltip: {},
legend: { top: 6, data: data.clusters.map(item => item.name) },
radar: { indicator: data.dimensions.map(name => ({ name, max: 1 })), radius: '62%' },
series: [{ type: 'radar', data: data.clusters.map(item => ({ value: item.values, name: item.name })) }]
})
}
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)
}
const data = await request.get(`/cluster/scatter?n_clusters=${nClusters.value}`)
const grouped = {}
data.points.forEach(point => {
if (!grouped[point.cluster_id]) grouped[point.cluster_id] = []
grouped[point.cluster_id].push([point.x, point.y])
})
chart.setOption({
tooltip: { trigger: 'item' },
grid: { left: 36, right: 18, top: 20, bottom: 36, containLabel: true },
xAxis: { name: data.x_axis_name, splitLine: { lineStyle: { color: '#E5EBF2' } } },
yAxis: { name: data.y_axis_name, splitLine: { lineStyle: { color: '#E5EBF2' } } },
series: Object.entries(grouped).map(([id, points]) => ({
type: 'scatter',
data: points,
symbolSize: 9,
name: `群体${Number(id) + 1}`
}))
})
}
async function loadClusterResult() {
try {
const data = await request.get(`/cluster/result?n_clusters=${nClusters.value}`)
clusterData.value = data.clusters
} catch (e) {
console.error(e)
}
const data = await request.get(`/cluster/result?n_clusters=${nClusters.value}`)
clusterData.value = data.clusters
}
</script>
<style scoped>
.chart {
height: 350px;
.cluster-hero {
background:
linear-gradient(135deg, rgba(194, 65, 12, 0.92), rgba(124, 58, 237, 0.88) 55%, rgba(30, 41, 59, 0.94));
}
.cluster-select {
width: 100px;
}
.cluster-table {
--el-table-border-color: transparent;
}
</style>

View File

@@ -1,8 +1,17 @@
<template>
<div class="dashboard">
<div class="page-shell">
<section class="page-hero">
<div class="page-eyebrow">Overview</div>
<h1 class="page-title">企业缺勤全景概览</h1>
<p class="page-description">
通过总量时序结构分布三个层面快速识别缺勤风险的整体轮廓适合作为答辩时的第一屏总览
</p>
</section>
<el-row :gutter="20" class="kpi-row">
<el-col :span="6" v-for="kpi in kpiData" :key="kpi.title">
<el-card class="kpi-card">
<el-col :xs="24" :sm="12" :lg="6" v-for="kpi in kpiData" :key="kpi.title">
<el-card class="metric-card kpi-card" shadow="never">
<div class="kpi-index">{{ kpi.index }}</div>
<div class="kpi-title">{{ kpi.title }}</div>
<div class="kpi-value">{{ kpi.value }}</div>
<div class="kpi-unit">{{ kpi.unit }}</div>
@@ -11,39 +20,55 @@
</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-col :xs="24" :xl="12">
<el-card class="panel-card" shadow="never">
<div class="section-heading">
<div>
<h3 class="section-title">月度缺勤事件趋势</h3>
<p class="section-caption">观察不同月份的事件量与时长波动</p>
</div>
<span class="soft-tag">Trend</span>
</div>
<div ref="trendChartRef" class="chart-frame"></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-col :xs="24" :xl="12">
<el-card class="panel-card" shadow="never">
<div class="section-heading">
<div>
<h3 class="section-title">星期分布</h3>
<p class="section-caption">识别工作周内的缺勤集中区间</p>
</div>
<span class="soft-tag">Weekday</span>
</div>
<div ref="weekdayChartRef" class="chart-frame"></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-row :gutter="20">
<el-col :xs="24" :xl="12">
<el-card class="panel-card" shadow="never">
<div class="section-heading">
<div>
<h3 class="section-title">请假原因大类分布</h3>
<p class="section-caption">呈现引发缺勤的主要业务原因结构</p>
</div>
<span class="soft-tag">Reason Mix</span>
</div>
<div ref="reasonChartRef" class="chart-frame"></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-col :xs="24" :xl="12">
<el-card class="panel-card" shadow="never">
<div class="section-heading">
<div>
<h3 class="section-title">季节影响分布</h3>
<p class="section-caption">展示季节变化与缺勤总量之间的关系</p>
</div>
<span class="soft-tag">Season</span>
</div>
<div ref="seasonChartRef" class="chart-frame"></div>
</el-card>
</el-col>
</el-row>
@@ -53,7 +78,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import * as echarts from 'echarts'
import { getStats, getTrend, getWeekday, getReasons, getSeasons } from '@/api/overview'
import { getReasons, getSeasons, getStats, getTrend, getWeekday } from '@/api/overview'
const trendChartRef = ref(null)
const weekdayChartRef = ref(null)
@@ -61,20 +86,20 @@ const reasonChartRef = ref(null)
const seasonChartRef = ref(null)
const kpiData = ref([
{ title: '总记录数', value: '-', unit: '条' },
{ title: '员工数', value: '-', unit: '人' },
{ title: '平均缺勤时长', value: '-', unit: '小时' },
{ title: '高风险占比', value: '-', unit: '%' }
{ index: '01', title: '缺勤事件数', value: '-', unit: '条' },
{ index: '02', title: '员工覆盖数', value: '-', unit: '人' },
{ index: '03', title: '平均缺勤时长', value: '-', unit: '小时' },
{ index: '04', 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: '%' }
{ index: '01', title: '缺勤事件数', value: stats.total_records, unit: '条' },
{ index: '02', title: '员工覆盖数', value: stats.total_employees, unit: '人' },
{ index: '03', title: '平均缺勤时长', value: stats.avg_absent_hours, unit: '小时' },
{ index: '04', title: '高风险事件占比', value: (stats.high_risk_ratio * 100).toFixed(1), unit: '%' }
]
} catch (e) {
console.error('Failed to load stats:', e)
@@ -88,102 +113,105 @@ onMounted(async () => {
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)
}
const data = await getTrend()
chart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 32, right: 18, top: 30, bottom: 30, containLabel: true },
xAxis: { type: 'category', data: data.months, axisLine: { lineStyle: { color: '#B6C1CE' } } },
yAxis: { type: 'value', name: '小时', splitLine: { lineStyle: { color: '#E5EBF2' } } },
series: [{
type: 'line',
smooth: true,
data: data.total_hours,
areaStyle: { opacity: 0.18, color: '#0F766E' },
lineStyle: { width: 3, color: '#0F766E' },
itemStyle: { color: '#0F766E' }
}]
})
}
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)
}
const data = await getWeekday()
chart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 32, right: 18, top: 30, bottom: 30, containLabel: true },
xAxis: { type: 'category', data: data.weekdays, axisLine: { lineStyle: { color: '#B6C1CE' } } },
yAxis: { type: 'value', name: '小时', splitLine: { lineStyle: { color: '#E5EBF2' } } },
series: [{ type: 'bar', barWidth: 34, data: data.total_hours, itemStyle: { color: '#C2410C', borderRadius: [10, 10, 0, 0] } }]
})
}
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)
}
const data = await getReasons()
chart.setOption({
tooltip: { trigger: 'item' },
legend: { bottom: 0, icon: 'circle' },
series: [{
type: 'pie',
radius: ['42%', '72%'],
center: ['50%', '45%'],
data: data.reasons.map(item => ({ value: item.count, name: item.name }))
}]
})
}
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)
}
const data = await getSeasons()
chart.setOption({
tooltip: { trigger: 'item' },
color: ['#0F766E', '#3B82F6', '#F59E0B', '#DC2626'],
series: [{ type: 'pie', radius: ['38%', '70%'], data: data.seasons.map(item => ({ value: item.total_hours, name: item.name })) }]
})
}
</script>
<style scoped>
.kpi-row {
margin-bottom: 20px;
margin-bottom: 2px;
}
.kpi-card {
text-align: center;
padding: 10px;
position: relative;
overflow: hidden;
min-height: 156px;
}
.kpi-card::after {
content: '';
position: absolute;
right: -24px;
bottom: -24px;
width: 88px;
height: 88px;
border-radius: 24px;
background: linear-gradient(135deg, rgba(15, 118, 110, 0.12), rgba(194, 65, 12, 0.08));
}
.kpi-index {
margin-bottom: 14px;
font-size: 12px;
letter-spacing: 0.18em;
color: #91a0b5;
}
.kpi-title {
font-size: 14px;
color: #909399;
color: var(--text-subtle);
}
.kpi-value {
font-size: 28px;
font-weight: bold;
color: #409EFF;
margin: 10px 0;
margin-top: 14px;
font-size: 34px;
font-weight: 700;
color: var(--text-main);
}
.kpi-unit {
font-size: 12px;
color: #909399;
}
.chart-card {
height: 350px;
}
.chart {
height: 280px;
margin-top: 8px;
font-size: 13px;
color: #8a97ab;
}
</style>

View File

@@ -1,32 +1,53 @@
<template>
<div class="factor-analysis">
<el-card>
<template #header>
<span>特征重要性排序</span>
</template>
<div ref="importanceChartRef" class="chart"></div>
<div class="page-shell">
<section class="page-hero analysis-hero">
<div class="page-eyebrow">Analysis</div>
<h1 class="page-title">缺勤驱动因素洞察</h1>
<p class="page-description">
将模型特征重要性变量相关关系与群体差异放在同一界面展示形成更完整的解释链路
</p>
</section>
<el-card class="panel-card" shadow="never">
<div class="section-heading">
<div>
<h3 class="section-title">缺勤影响因素排序</h3>
<p class="section-caption">用于展示模型最关注的驱动信号及其主次关系</p>
</div>
<span class="soft-tag">Importance</span>
</div>
<div ref="importanceChartRef" class="chart-frame"></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-row :gutter="20">
<el-col :xs="24" :xl="12">
<el-card class="panel-card" shadow="never">
<div class="section-heading">
<div>
<h3 class="section-title">核心特征相关性</h3>
<p class="section-caption">帮助说明关键指标之间的联动关系</p>
</div>
<span class="soft-tag">Correlation</span>
</div>
<div ref="correlationChartRef" class="chart-frame"></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-col :xs="24" :xl="12">
<el-card class="panel-card" shadow="never">
<div class="section-heading">
<div>
<h3 class="section-title">群体对比分析</h3>
<p class="section-caption">从行业排班和健康等维度比较平均缺勤时长</p>
</div>
<el-select v-model="dimension" @change="loadComparison" class="dimension-select">
<el-option label="所属行业" value="industry" />
<el-option label="班次类型" value="shift_type" />
<el-option label="岗位序列" value="job_family" />
<el-option label="婚姻状态" value="marital_status" />
<el-option label="慢性病史" value="chronic_disease" />
</el-select>
</div>
<div ref="compareChartRef" class="chart-frame"></div>
</el-card>
</el-col>
</el-row>
@@ -34,14 +55,14 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { onMounted, ref } 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')
const dimension = ref('industry')
onMounted(() => {
initImportanceChart()
@@ -51,39 +72,33 @@ onMounted(() => {
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)
}
const data = await request.get('/analysis/importance')
const features = data.features || []
chart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: '24%', right: 18, top: 24, bottom: 16 },
xAxis: { type: 'value', splitLine: { lineStyle: { color: '#E5EBF2' } } },
yAxis: { type: 'category', data: features.map(item => item.name_cn).reverse(), axisLine: { show: false } },
series: [{
type: 'bar',
barWidth: 18,
data: features.map(item => item.importance).reverse(),
itemStyle: { color: '#0F766E', borderRadius: [0, 10, 10, 0] }
}]
})
}
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)
}
const data = await request.get('/analysis/correlation')
chart.setOption({
tooltip: {},
grid: { left: 40, right: 20, top: 20, bottom: 40 },
xAxis: { type: 'category', data: data.features },
yAxis: { type: 'category', data: data.features },
visualMap: { min: -1, max: 1, calculable: true, orient: 'horizontal', left: 'center', bottom: 0, inRange: { color: ['#14532d', '#f8fafc', '#7f1d1d'] } },
series: [{ type: 'heatmap', data: flattenMatrix(data.matrix, data.features) }]
})
}
function flattenMatrix(matrix, features) {
@@ -98,22 +113,24 @@ function flattenMatrix(matrix, features) {
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)
}
const data = await request.get(`/analysis/compare?dimension=${dimension.value}`)
chart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 32, right: 18, top: 30, bottom: 48, containLabel: true },
xAxis: { type: 'category', data: data.groups.map(item => item.name), axisLabel: { interval: 0, rotate: 18 } },
yAxis: { type: 'value', name: '平均缺勤时长(小时)', splitLine: { lineStyle: { color: '#E5EBF2' } } },
series: [{ type: 'bar', data: data.groups.map(item => item.avg_hours), itemStyle: { color: '#C2410C', borderRadius: [10, 10, 0, 0] } }]
})
}
</script>
<style scoped>
.chart {
height: 300px;
.analysis-hero {
background:
linear-gradient(135deg, rgba(30, 64, 175, 0.95), rgba(15, 118, 110, 0.92) 58%, rgba(30, 41, 59, 0.94));
}
.dimension-select {
width: 180px;
}
</style>

View File

@@ -1,221 +1,284 @@
<template>
<div class="prediction">
<div class="page-shell prediction">
<section class="page-hero prediction-hero">
<div class="page-eyebrow">Prediction</div>
<h1 class="page-title">核心因子驱动的缺勤预测</h1>
<p class="page-description">
仅保留对结果最关键的输入项让演示流程更聚焦也让答辩老师更容易理解模型的业务逻辑
</p>
</section>
<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-col :xs="24" :xl="15">
<div class="prediction-input-grid">
<el-card class="panel-card intro-card" shadow="never">
<div class="section-heading" style="margin-bottom: 0">
<div>
<h3 class="section-title">中国企业缺勤风险输入</h3>
<p class="section-caption">使用卡片分区组织核心因子演示时更清晰</p>
</div>
<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">
<div class="form-tip">
系统会自动补齐企业背景健康生活与组织属性等次级信息页面仅保留对预测结果影响最大的核心字段
</div>
</el-card>
<el-card class="panel-card factor-card" shadow="never">
<div class="section-heading">
<div>
<h3 class="section-title">缺勤事件核心信息</h3>
<p class="section-caption">决定本次缺勤时长的直接事件属性</p>
</div>
<span class="soft-tag">Event</span>
</div>
<el-form :model="form" label-width="118px" size="small">
<el-row :gutter="18">
<el-col :span="12">
<el-form-item label="请假类型">
<el-select v-model="form.leave_type" style="width: 100%">
<el-option v-for="item in leaveTypes" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="原因大类">
<el-select v-model="form.leave_reason_category" style="width: 100%">
<el-option v-for="item in leaveReasons" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<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 v-model="form.absence_month" style="width: 100%">
<el-option v-for="month in 12" :key="month" :label="`${month}月`" :value="month" />
</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-col>
<el-col :span="12">
<el-form-item label="星期">
<el-select v-model="form.weekday" style="width: 100%">
<el-option v-for="item in weekdays" :key="item.value" :label="item.label" :value="item.value" />
</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-col>
<el-col :span="12">
<el-form-item label="节假日前后">
<el-radio-group v-model="form.near_holiday_flag">
<el-radio :value="1"></el-radio>
<el-radio :value="0"></el-radio>
</el-radio-group>
</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-col>
<el-col :span="12">
<el-form-item label="医院证明">
<el-radio-group v-model="form.medical_certificate_flag">
<el-radio :value="1"></el-radio>
<el-radio :value="0"></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-col>
</el-row>
</el-form>
</el-card>
<el-card class="panel-card factor-card" shadow="never">
<div class="section-heading">
<div>
<h3 class="section-title">工作压力与排班</h3>
<p class="section-caption">体现通勤加班和排班对缺勤的影响</p>
</div>
<span class="soft-tag">Workload</span>
</div>
<el-form :model="form" label-width="118px" size="small">
<el-row :gutter="18">
<el-col :span="12">
<el-form-item label="班次类型">
<el-select v-model="form.shift_type" style="width: 100%">
<el-option v-for="item in shiftTypes" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="夜班岗位">
<el-radio-group v-model="form.is_night_shift">
<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-col>
<el-col :span="12">
<el-form-item label="月均加班时长">
<el-input-number v-model="form.monthly_overtime_hours" :min="0" :max="100" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="通勤时长(分钟)">
<el-input-number v-model="form.commute_minutes" :min="5" :max="150" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="慢性病史">
<el-radio-group v-model="form.chronic_disease_flag">
<el-radio :value="1"></el-radio>
<el-radio :value="0"></el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-card>
<el-card class="panel-card factor-card" shadow="never">
<div class="section-heading">
<div>
<h3 class="section-title">家庭与补充因素</h3>
<p class="section-caption">作为结果修正项为预测增加业务语境</p>
</div>
<span class="soft-tag">Context</span>
</div>
<el-form :model="form" label-width="118px" size="small">
<el-row :gutter="18">
<el-col :span="12">
<el-form-item label="子女数量">
<el-input-number v-model="form.son" :min="0" :max="5" style="width: 100%" />
<el-input-number v-model="form.children_count" :min="0" :max="3" 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-col>
<el-col :span="12">
<el-form-item label="所属行业">
<el-select v-model="form.industry" style="width: 100%">
<el-option v-for="item in industries" :key="item" :label="item" :value="item" />
</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-col>
<el-col :span="12">
<el-form-item label="婚姻状态">
<el-select v-model="form.marital_status" style="width: 100%">
<el-option v-for="item in maritalStatuses" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-card>
<el-card class="panel-card factor-card action-card" shadow="never">
<div class="section-heading">
<div>
<h3 class="section-title">预测设置</h3>
<p class="section-caption">支持自动选择最优模型或查看模型对比</p>
</div>
<span class="soft-tag">Action</span>
</div>
<el-form :model="form" label-width="118px" size="small">
<el-row :gutter="18">
<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"
>
<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>
<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-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-row>
<div class="action-row">
<el-button type="primary" @click="handlePredict" :loading="loading">开始预测</el-button>
<el-button @click="handleCompare" :loading="compareLoading">模型对比</el-button>
</div>
</el-form>
</el-card>
</div>
</el-col>
<el-col :span="10">
<el-card>
<el-col :xs="24" :xl="9">
<el-card class="panel-card result-card merged-result-card" shadow="never">
<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 class="section-heading" style="margin-bottom: 0">
<div>
<h3 class="section-title">预测结果与风险说明</h3>
<p class="section-caption">在同一张卡片内查看预测值模型信息和风险区间说明</p>
</div>
<span class="soft-tag">Result</span>
</div>
<div style="margin-top: 8px; color: #909399; font-size: 12px">
置信度: {{ (result.confidence * 100).toFixed(0) }}%
</template>
<div class="merged-result-grid">
<div>
<div v-if="result" class="result-container">
<div class="result-stack">
<div class="result-info-card result-info-primary">
<div class="mini-label">预测缺勤时长</div>
<div class="result-value">{{ result.predicted_hours }}</div>
<div class="result-unit">小时</div>
</div>
<div class="result-info-card">
<div class="mini-label">风险等级</div>
<el-tag :type="riskTagType" size="large">{{ result.risk_label }}</el-tag>
</div>
<div class="result-info-card">
<div class="mini-label">使用模型</div>
<div class="mini-value">{{ result.model_name_cn }}</div>
</div>
<div class="result-info-card">
<div class="mini-label">置信度</div>
<div class="mini-value">{{ (result.confidence * 100).toFixed(0) }}%</div>
</div>
</div>
</div>
<el-empty v-else description="输入中国企业员工场景后开始预测" />
</div>
<div class="risk-legend-cards">
<div class="risk-level-card risk-level-low">
<div class="risk-card-head">
<el-tag type="success" size="small">低风险</el-tag>
</div>
<div class="risk-card-rule">缺勤时长 &lt; 4 小时</div>
<div class="risk-card-desc">通常为短时请假或轻度波动</div>
</div>
<div class="risk-level-card risk-level-medium">
<div class="risk-card-head">
<el-tag type="warning" size="small">中风险</el-tag>
</div>
<div class="risk-card-rule">缺勤时长 4 - 8 小时</div>
<div class="risk-card-desc">属于需要关注的常规风险区间</div>
</div>
<div class="risk-level-card risk-level-high">
<div class="risk-card-head">
<el-tag type="danger" size="small">高风险</el-tag>
</div>
<div class="risk-card-rule">缺勤时长 &gt; 8 小时</div>
<div class="risk-card-desc">通常对应较强事件驱动或持续性风险</div>
</div>
</div>
</div>
<el-empty v-else description="请输入参数后点击预测" />
</el-card>
<el-card v-if="compareResults.length > 0" style="margin-top: 20px">
<el-card v-if="compareResults.length > 0" class="panel-card compare-card" shadow="never">
<template #header>
<span>模型对比结果</span>
<div class="section-heading" style="margin-bottom: 0">
<div>
<h3 class="section-title">模型对比结果</h3>
<p class="section-caption">选择最适合展示的候选模型</p>
</div>
<span class="soft-tag">Compare</span>
</div>
</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">
<el-table-column prop="predicted_hours" label="预测时长" width="90">
<template #default="{ row }">{{ row.predicted_hours }}h</template>
</el-table-column>
<el-table-column prop="risk_label" label="风险等级" width="80">
@@ -233,54 +296,47 @@
</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 { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import request from '@/api/request'
const industries = ['制造业', '互联网', '零售连锁', '物流运输', '金融服务', '医药健康', '建筑工程']
const shiftTypes = ['标准白班', '两班倒', '三班倒', '弹性班']
const maritalStatuses = ['未婚', '已婚', '离异/其他']
const leaveTypes = ['病假', '事假', '年假', '调休', '婚假', '丧假', '产检育儿假', '工伤假', '其他']
const leaveReasons = ['身体不适', '家庭事务', '子女照护', '交通受阻', '突发事件', '职业疲劳', '就医复查']
const weekdays = [
{ label: '周一', value: 1 },
{ label: '周二', value: 2 },
{ label: '周三', value: 3 },
{ label: '周四', value: 4 },
{ label: '周五', value: 5 },
{ label: '周六', value: 6 },
{ label: '周日', value: 7 }
]
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
industry: '制造业',
shift_type: '标准白班',
marital_status: '已婚',
children_count: 1,
monthly_overtime_hours: 26,
commute_minutes: 42,
is_night_shift: 0,
chronic_disease_flag: 0,
absence_month: 5,
weekday: 2,
leave_type: '病假',
leave_reason_category: '身体不适',
near_holiday_flag: 0,
medical_certificate_flag: 1
}
const form = ref({ ...defaultForm })
@@ -317,8 +373,6 @@ async function loadModels() {
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
}
@@ -328,16 +382,11 @@ async function handlePredict() {
loading.value = true
try {
const params = { ...form.value }
if (selectedModel.value) {
params.model_type = selectedModel.value
}
if (selectedModel.value) params.model_type = selectedModel.value
result.value = await request.post('/predict/single', params)
if (showCompare.value) {
await handleCompare()
}
if (showCompare.value) await handleCompare()
} catch (e) {
ElMessage.error('预测失败: ' + e.message)
ElMessage.error(`预测失败: ${e.message}`)
} finally {
loading.value = false
}
@@ -349,7 +398,7 @@ async function handleCompare() {
const res = await request.post('/predict/compare', form.value)
compareResults.value = res.results || []
} catch (e) {
ElMessage.error('对比失败: ' + e.message)
ElMessage.error(`对比失败: ${e.message}`)
} finally {
compareLoading.value = false
}
@@ -362,32 +411,156 @@ onMounted(() => {
<style scoped>
.result-container {
display: block;
padding: 8px 0 4px;
}
.prediction-hero {
background:
linear-gradient(135deg, rgba(15, 23, 42, 0.96), rgba(15, 118, 110, 0.92) 50%, rgba(194, 65, 12, 0.88));
}
.prediction-input-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.intro-card {
grid-column: 1 / -1;
}
.action-card {
grid-column: 1 / -1;
}
.result-card,
.compare-card {
height: 100%;
}
.compare-card {
margin-top: 20px;
}
.merged-result-card {
min-height: 100%;
}
.merged-result-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
align-items: start;
}
.result-stack {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.result-info-card {
padding: 18px 14px;
border: 1px solid var(--line-soft);
border-radius: 18px;
background: rgba(255, 255, 255, 0.76);
text-align: center;
padding: 30px 0;
}
.result-info-primary {
grid-column: 1 / -1;
padding: 24px 18px;
border: 1px solid rgba(15, 118, 110, 0.14);
background: linear-gradient(135deg, rgba(15, 118, 110, 0.12), rgba(58, 122, 254, 0.08));
}
.mini-label {
margin-bottom: 10px;
font-size: 12px;
color: var(--text-subtle);
}
.mini-value {
font-size: 16px;
font-weight: 700;
color: var(--text-main);
}
.form-tip {
margin-top: 14px;
padding: 12px 14px;
font-size: 13px;
line-height: 1.6;
color: #606266;
background: #f4f8ff;
border-left: 3px solid #3A7AFE;
border-radius: 6px;
}
.factor-card :deep(.el-form-item) {
margin-bottom: 18px;
}
.action-row {
display: flex;
gap: 12px;
margin-top: 14px;
}
.result-value {
font-size: 48px;
font-weight: bold;
color: #409EFF;
color: #3A7AFE;
}
.result-unit {
font-size: 16px;
margin-top: 8px;
font-size: 14px;
color: #909399;
}
.risk-legend {
font-size: 13px;
.risk-legend-cards {
display: grid;
gap: 12px;
}
.risk-item {
display: flex;
align-items: center;
gap: 10px;
.risk-level-card {
padding: 16px;
border-radius: 18px;
border: 1px solid var(--line-soft);
background: rgba(255, 255, 255, 0.76);
}
.risk-level-low {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.08), rgba(255, 255, 255, 0.9));
}
.risk-level-medium {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.1), rgba(255, 255, 255, 0.9));
}
.risk-level-high {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.1), rgba(255, 255, 255, 0.9));
}
.risk-card-head {
margin-bottom: 10px;
}
.risk-card-rule {
font-size: 15px;
font-weight: 700;
color: var(--text-main);
}
.risk-card-desc {
margin-top: 6px;
font-size: 13px;
line-height: 1.6;
color: var(--text-subtle);
}
.el-divider {
margin: 15px 0;
}
@@ -395,4 +568,29 @@ onMounted(() => {
:deep(.recommended-row) {
background-color: #f0f9eb;
}
:deep(.intro-card .el-card__header),
:deep(.result-card .el-card__header),
:deep(.compare-card .el-card__header) {
padding-bottom: 0;
border-bottom: none;
}
:deep(.factor-card .el-card__header) {
border-bottom: none;
padding-bottom: 0;
}
@media (max-width: 1200px) {
.prediction-input-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.merged-result-grid,
.result-stack {
grid-template-columns: 1fr;
}
}
</style>