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:
@@ -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",
|
||||
|
||||
3
frontend/pnpm-lock.yaml
generated
3
frontend/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
203
frontend/src/styles/theme.css
Normal file
203
frontend/src/styles/theme.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
R²: {{ model.metrics?.r2?.toFixed(2) || '-' }}
|
||||
</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-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">缺勤时长 < 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">缺勤时长 > 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>缺勤时长 < 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 { 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>
|
||||
|
||||
Reference in New Issue
Block a user