Polish absence analysis demo experience

This commit is contained in:
shuo
2026-04-27 11:59:35 +08:00
parent 27c394fd8c
commit 304441c888
14 changed files with 1257 additions and 257 deletions

View File

@@ -2,10 +2,9 @@
<div class="shell" :class="{ 'shell-collapsed': isSidebarCollapsed }">
<aside class="shell-sidebar">
<div class="brand-block">
<div class="brand-mark">HR</div>
<div class="brand-mark"></div>
<div v-if="!isSidebarCollapsed">
<div class="brand-title">企业缺勤分析台</div>
<div class="brand-subtitle">Human Resource Insight Console</div>
</div>
</div>
@@ -30,14 +29,14 @@
</el-menu-item>
<el-menu-item index="/jdr-analysis">
<el-icon class="nav-icon"><Reading /></el-icon>
<span class="nav-label">JD-R分析</span>
<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>
<p>缺勤趋势风险预测与群体画像</p>
</div>
</aside>
@@ -56,8 +55,6 @@
<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>
@@ -81,23 +78,23 @@ const isDarkMode = ref(false)
const metaMap = {
'/dashboard': {
title: '数据概览',
subtitle: '从企业缺勤事件总量时序与结构分布切入建立整体认知'
subtitle: '缺勤事件总量趋势与结构分布'
},
'/analysis': {
title: '影响因素',
subtitle: '观察模型最关注的驱动因素辅助解释缺勤风险的来源'
subtitle: '关键驱动因素与群体差异'
},
'/prediction': {
title: '缺勤预测',
subtitle: '围绕最核心的业务信号输入快速获得缺勤时长与风险等级'
subtitle: '缺勤时长风险等级与模型对比'
},
'/clustering': {
title: '员工画像',
subtitle: '通过聚类划分典型群体为答辩演示提供更直观的人群视角'
subtitle: '典型群体划分与画像分析'
},
'/jdr-analysis': {
title: 'JD-R理论分析',
subtitle: '基于工作要求-资源理论的可解释分析揭示缺勤的心理学驱动因素'
title: '理论分析',
subtitle: '工作要求资源支持与缺勤风险'
}
}
@@ -164,8 +161,28 @@ watch(isDarkMode, value => {
border-radius: 16px;
background: linear-gradient(135deg, #fef3c7, #fdba74);
color: #7c2d12;
font-weight: 800;
letter-spacing: 0.08em;
position: relative;
}
.brand-mark::before,
.brand-mark::after {
content: '';
position: absolute;
border-radius: 999px;
background: rgba(124, 45, 18, 0.9);
}
.brand-mark::before {
width: 20px;
height: 20px;
}
.brand-mark::after {
width: 8px;
height: 8px;
right: 11px;
bottom: 11px;
background: rgba(255, 255, 255, 0.72);
}
.brand-title {
@@ -174,12 +191,6 @@ watch(isDarkMode, value => {
color: var(--sidebar-text);
}
.brand-subtitle {
margin-top: 4px;
font-size: 12px;
color: var(--sidebar-text-subtle);
}
.sidebar-panel,
.sidebar-note {
padding: 18px;
@@ -296,19 +307,6 @@ watch(isDarkMode, value => {
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;
}

View File

@@ -33,7 +33,7 @@ const routes = [
path: '/jdr-analysis',
name: 'JDRAnalysis',
component: () => import('@/views/JDRAnalysis.vue'),
meta: { title: 'JD-R理论分析' }
meta: { title: '理论分析' }
}
]

View File

@@ -104,14 +104,6 @@ a {
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;
@@ -168,21 +160,6 @@ a {
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;

View File

@@ -1,10 +1,9 @@
<template>
<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>
@@ -12,7 +11,7 @@
<div class="section-heading">
<div>
<h3 class="section-title">群体雷达画像</h3>
<p class="section-caption">年龄司龄加班通勤BMI 缺勤水平构建群体轮廓</p>
<p class="section-caption">年龄司龄加班通勤BMI 缺勤水平</p>
</div>
<el-select v-model="nClusters" @change="loadData" class="cluster-select">
<el-option :label="2" :value="2" />
@@ -29,9 +28,8 @@
<div class="section-heading">
<div>
<h3 class="section-title">聚类结果</h3>
<p class="section-caption">便于答辩时逐个介绍群体特征</p>
<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" />
@@ -39,7 +37,7 @@
<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-column prop="description" label="群体特征" min-width="180" />
</el-table>
</el-card>
</el-col>
@@ -48,9 +46,8 @@
<div class="section-heading">
<div>
<h3 class="section-title">加班与缺勤散点图</h3>
<p class="section-caption">展示各聚类在加班强度与缺勤水平上的位置差异</p>
<p class="section-caption">加班强度与缺勤水平分布</p>
</div>
<span class="soft-tag">Scatter</span>
</div>
<div ref="scatterChartRef" class="chart-frame"></div>
</el-card>
@@ -60,7 +57,7 @@
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import * as echarts from 'echarts'
import request from '@/api/request'
@@ -68,19 +65,44 @@ const radarChartRef = ref(null)
const scatterChartRef = ref(null)
const nClusters = ref(3)
const clusterData = ref([])
const loadVersion = ref(0)
let radarChart = null
let scatterChart = null
onMounted(() => {
loadData()
})
onBeforeUnmount(() => {
radarChart?.dispose()
scatterChart?.dispose()
})
async function loadData() {
await Promise.all([initRadarChart(), initScatterChart(), loadClusterResult()])
const currentClusters = nClusters.value
const version = ++loadVersion.value
const [profile, scatter, result] = await Promise.all([
request.get(`/cluster/profile?n_clusters=${currentClusters}`),
request.get(`/cluster/scatter?n_clusters=${currentClusters}`),
request.get(`/cluster/result?n_clusters=${currentClusters}`)
])
if (version !== loadVersion.value || currentClusters !== nClusters.value) {
return
}
renderRadarChart(profile)
renderScatterChart(scatter)
clusterData.value = result.clusters
}
async function initRadarChart() {
const chart = echarts.init(radarChartRef.value)
const data = await request.get(`/cluster/profile?n_clusters=${nClusters.value}`)
chart.setOption({
function renderRadarChart(data) {
if (!radarChartRef.value) return
if (!radarChart) {
radarChart = echarts.init(radarChartRef.value)
}
radarChart.clear()
radarChart.setOption({
tooltip: {},
legend: { top: 6, data: data.clusters.map(item => item.name) },
radar: { indicator: data.dimensions.map(name => ({ name, max: 1 })), radius: '62%' },
@@ -88,15 +110,18 @@ async function initRadarChart() {
})
}
async function initScatterChart() {
const chart = echarts.init(scatterChartRef.value)
const data = await request.get(`/cluster/scatter?n_clusters=${nClusters.value}`)
function renderScatterChart(data) {
if (!scatterChartRef.value) return
if (!scatterChart) {
scatterChart = echarts.init(scatterChartRef.value)
}
scatterChart.clear()
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({
scatterChart.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' } } },
@@ -109,11 +134,6 @@ async function initScatterChart() {
}))
})
}
async function loadClusterResult() {
const data = await request.get(`/cluster/result?n_clusters=${nClusters.value}`)
clusterData.value = data.clusters
}
</script>
<style scoped>

View File

@@ -1,10 +1,9 @@
<template>
<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>
@@ -25,9 +24,8 @@
<div class="section-heading">
<div>
<h3 class="section-title">月度缺勤事件趋势</h3>
<p class="section-caption">观察不同月份的事件量与时长波动</p>
<p class="section-caption">月度事件量与时长波动</p>
</div>
<span class="soft-tag">Trend</span>
</div>
<div ref="trendChartRef" class="chart-frame"></div>
</el-card>
@@ -37,9 +35,8 @@
<div class="section-heading">
<div>
<h3 class="section-title">星期分布</h3>
<p class="section-caption">识别工作周内的缺勤集中区间</p>
<p class="section-caption">工作周缺勤分布</p>
</div>
<span class="soft-tag">Weekday</span>
</div>
<div ref="weekdayChartRef" class="chart-frame"></div>
</el-card>
@@ -52,9 +49,8 @@
<div class="section-heading">
<div>
<h3 class="section-title">请假原因大类分布</h3>
<p class="section-caption">呈现引发缺勤的主要业务原因结构</p>
<p class="section-caption">主要请假原因结构</p>
</div>
<span class="soft-tag">Reason Mix</span>
</div>
<div ref="reasonChartRef" class="chart-frame"></div>
</el-card>
@@ -64,9 +60,8 @@
<div class="section-heading">
<div>
<h3 class="section-title">季节影响分布</h3>
<p class="section-caption">展示季节变化与缺勤总量之间的关系</p>
<p class="section-caption">季节与缺勤总量</p>
</div>
<span class="soft-tag">Season</span>
</div>
<div ref="seasonChartRef" class="chart-frame"></div>
</el-card>

View File

@@ -1,10 +1,9 @@
<template>
<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>
@@ -12,9 +11,8 @@
<div class="section-heading">
<div>
<h3 class="section-title">缺勤影响因素排序</h3>
<p class="section-caption">用于展示模型最关注的驱动信号及其主次关系</p>
<p class="section-caption">模型特征重要性</p>
</div>
<span class="soft-tag">Importance</span>
</div>
<div ref="importanceChartRef" class="chart-frame"></div>
</el-card>
@@ -25,9 +23,8 @@
<div class="section-heading">
<div>
<h3 class="section-title">核心特征相关性</h3>
<p class="section-caption">帮助说明关键指标之间的联动关系</p>
<p class="section-caption">关键指标联动关系</p>
</div>
<span class="soft-tag">Correlation</span>
</div>
<div ref="correlationChartRef" class="chart-frame"></div>
</el-card>
@@ -37,7 +34,7 @@
<div class="section-heading">
<div>
<h3 class="section-title">群体对比分析</h3>
<p class="section-caption">从行业排班和健康等维度比较平均缺勤时长</p>
<p class="section-caption">不同维度下的平均缺勤时长</p>
</div>
<el-select v-model="dimension" @change="loadComparison" class="dimension-select">
<el-option label="所属行业" value="industry" />

View File

@@ -1,22 +1,20 @@
<template>
<div class="page-shell jdr-page">
<section class="page-hero jdr-hero">
<div class="page-eyebrow">JD-R Theory</div>
<h1 class="page-title">JD-R 理论驱动的可解释分析</h1>
<h1 class="page-title">JD-R 理论分析</h1>
<p class="page-description">
基于工作要求-资源模型从心理学理论视角解析员工缺勤的深层驱动因素提供可解释的干预建议
基于工作要求-资源模型分析工作压力资源支持与缺勤风险之间的关系
</p>
</section>
<el-tabs v-model="activeTab" type="border-card" class="jdr-tabs">
<!-- Tab 1: JD-R 维度分析 -->
<el-tab-pane label="维度分析" name="dimensions">
<el-row :gutter="20">
<el-col :xs="24" :lg="12">
<el-card class="panel-card" shadow="never">
<template #header>
<h3 class="section-title">JD-R 维度雷达图</h3>
<p class="section-caption">工作要求工作资源与个人资源三维度均值对比</p>
<h3 class="section-title">维度雷达图</h3>
<p class="section-caption">工作要求工作资源与个人资源</p>
</template>
<div v-if="dimensionData" ref="radarChartRef" style="height: 380px"></div>
<el-empty v-else description="加载中..." />
@@ -26,7 +24,7 @@
<el-card class="panel-card" shadow="never">
<template #header>
<h3 class="section-title">维度分布统计</h3>
<p class="section-caption">各维度的均值标准差与平衡度</p>
<p class="section-caption">均值标准差与平衡度</p>
</template>
<div v-if="dimensionData" class="dim-stats">
<div v-for="(item, key) in dimensionLabels" :key="key" class="dim-stat-row">
@@ -35,7 +33,7 @@
<div class="dim-stat-sub">std: {{ dimensionData[key]?.std || '-' }}</div>
</div>
<div v-if="dimensionData?.balance" class="dim-stat-row dim-stat-balance">
<div class="dim-stat-label">JD-R 平衡度</div>
<div class="dim-stat-label">平衡度</div>
<div class="dim-stat-value">{{ dimensionData.balance.mean }}</div>
<div class="dim-stat-sub">正向比例: {{ dimensionData.balance.positive_ratio }}%</div>
</div>
@@ -46,14 +44,13 @@
</el-row>
</el-tab-pane>
<!-- Tab 2: 倦怠与投入 -->
<el-tab-pane label="倦怠与投入" name="burnout">
<el-row :gutter="20">
<el-col :xs="24" :lg="12">
<el-card class="panel-card" shadow="never">
<template #header>
<h3 class="section-title">倦怠与投入分布</h3>
<p class="section-caption">工作倦怠(1-7)和工作投入(1-7)的分布对比</p>
<p class="section-caption">工作倦怠与工作投入分布</p>
</template>
<div v-if="burnoutData" ref="burnoutChartRef" style="height: 380px"></div>
<el-empty v-else description="加载中..." />
@@ -63,7 +60,7 @@
<el-card class="panel-card" shadow="never">
<template #header>
<h3 class="section-title">关键相关性</h3>
<p class="section-caption">JD-R 维度与缺勤时长之间的关联强度</p>
<p class="section-caption">理论维度与缺勤时长</p>
</template>
<div v-if="burnoutData" ref="corrChartRef" style="height: 380px"></div>
<el-empty v-else description="加载中..." />
@@ -72,14 +69,13 @@
</el-row>
</el-tab-pane>
<!-- Tab 3: 双路径分析 -->
<el-tab-pane label="双路径分析" name="path">
<el-row :gutter="20">
<el-col :xs="24" :lg="14">
<el-card class="panel-card" shadow="never">
<template #header>
<h3 class="section-title">JD-R 双路径理论模型</h3>
<p class="section-caption">健康损伤路径(需求倦怠缺勤)与激励路径(资源投入低缺勤)</p>
<h3 class="section-title">双路径理论模型</h3>
<p class="section-caption">健康损伤路径与激励路径</p>
</template>
<div class="path-diagram">
<div class="path-flow">
@@ -122,7 +118,7 @@
<el-card class="panel-card" shadow="never">
<template #header>
<h3 class="section-title">风险等级分布</h3>
<p class="section-caption">全员缺勤风险等级统计</p>
<p class="section-caption">全员风险等级统计</p>
</template>
<div v-if="riskData" ref="riskChartRef" style="height: 320px"></div>
<el-empty v-else description="加载中..." />
@@ -131,22 +127,21 @@
</el-row>
</el-tab-pane>
<!-- Tab 4: SHAP 解释 -->
<el-tab-pane label="SHAP 解释" name="shap">
<el-tab-pane label="特征贡献" name="shap">
<el-row :gutter="20">
<el-col :xs="24" :lg="14">
<el-card class="panel-card" shadow="never">
<template #header>
<div class="section-heading" style="margin-bottom:0">
<div>
<h3 class="section-title">全局特征重要性 (SHAP)</h3>
<p class="section-caption"> SHAP 值排列的特征贡献</p>
<h3 class="section-title">全局特征重要性</h3>
<p class="section-caption">特征贡献排序</p>
</div>
<el-select v-model="shapModel" size="small" style="width: 160px" @change="loadShapGlobal">
<el-option label="随机森林" value="random_forest" />
<el-option label="XGBoost" value="xgboost" />
<el-option label="LightGBM" value="lightgbm" />
<el-option label="GBDT" value="gradient_boosting" />
<el-option label="增强树模型一" value="xgboost" />
<el-option label="增强树模型二" value="lightgbm" />
<el-option label="梯度提升树" value="gradient_boosting" />
</el-select>
</div>
</template>
@@ -158,7 +153,7 @@
<el-card class="panel-card" shadow="never">
<template #header>
<h3 class="section-title">维度贡献占比</h3>
<p class="section-caption"> JD-R 理论维度聚合的 SHAP 贡献</p>
<p class="section-caption">理论维度聚合贡献</p>
</template>
<div v-if="shapGlobalData" ref="shapDimPieRef" style="height: 420px"></div>
<el-empty v-else description="加载中..." />
@@ -170,7 +165,7 @@
<el-card class="panel-card" shadow="never">
<template #header>
<h3 class="section-title">特征依赖图</h3>
<p class="section-caption">选择特征查看其取值与 SHAP 值的关系</p>
<p class="section-caption">特征取值与贡献关系</p>
</template>
<div style="margin-bottom: 12px">
<el-select v-model="dependenceFeature" size="small" style="width: 200px" @change="loadDependence">
@@ -185,7 +180,7 @@
<el-card class="panel-card" shadow="never">
<template #header>
<h3 class="section-title">特征交互强度</h3>
<p class="section-caption">Top 特征对交互效应</p>
<p class="section-caption">主要特征对交互效应</p>
</template>
<div v-if="shapGlobalData" ref="shapInteractionRef" style="height: 320px"></div>
<el-empty v-else description="加载中..." />
@@ -239,6 +234,18 @@ function getOrCreateChart(el) {
return chart
}
function clearShapViews() {
shapGlobalData.value = null
shapTopFeatures.value = []
const refs = [shapGlobalRef.value, shapDimPieRef.value, shapDependenceRef.value, shapInteractionRef.value]
refs.forEach(el => {
const chart = getOrCreateChart(el)
if (chart) {
chart.clear()
}
})
}
// 延迟渲染:等待 Vue 将 v-if 的 DOM 插入到页面
function scheduleRender(fn) {
requestAnimationFrame(() => {
@@ -265,7 +272,7 @@ function renderRadarChart() {
{ name: '工作要求', max: 10 },
{ name: '工作资源', max: 5 },
{ name: '个人资源', max: 5 },
{ name: 'JD-R平衡度', max: 5 },
{ name: '平衡度', max: 5 },
]
const values = [
dims.demands?.mean || 0,
@@ -281,7 +288,7 @@ function renderRadarChart() {
type: 'radar',
data: [{
value: values,
name: 'JD-R 维度均值',
name: '维度均值',
areaStyle: { color: 'rgba(15, 118, 110, 0.2)' },
lineStyle: { color: '#0f766e', width: 2 },
itemStyle: { color: '#0f766e' },
@@ -386,12 +393,16 @@ function renderRiskChart() {
// ── Tab 4: SHAP ──
async function loadShapGlobal() {
if (activeTab.value !== 'shap') return
clearShapViews()
try {
const data = await getGlobalImportance(shapModel.value)
if (data.error) { ElMessage.error(data.error); return }
if (data.error) {
ElMessage.error(data.error)
return
}
shapGlobalData.value = data
shapTopFeatures.value = data.top_features || []
if (shapTopFeatures.value.length && !dependenceFeature.value) {
if (shapTopFeatures.value.length) {
dependenceFeature.value = shapTopFeatures.value[0].name
}
scheduleRender(() => {
@@ -401,7 +412,7 @@ async function loadShapGlobal() {
loadInteractions()
})
} catch (e) {
ElMessage.error('加载 SHAP 数据失败')
ElMessage.error('加载特征贡献数据失败')
}
}
@@ -422,7 +433,7 @@ function renderShapGlobalChart() {
chart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 140, right: 30, top: 10, bottom: 30 },
xAxis: { type: 'value', name: 'Mean |SHAP|' },
xAxis: { type: 'value', name: '平均贡献值' },
yAxis: { type: 'category', data: features.map(f => f.name_cn) },
series: [{
type: 'bar', data: features.map(f => ({
@@ -465,17 +476,22 @@ async function loadDependence() {
if (!dependenceFeature.value) return
try {
const data = await getDependence(dependenceFeature.value, shapModel.value)
if (data.error) return
if (data.error) {
const chart = getOrCreateChart(shapDependenceRef.value)
if (chart) chart.clear()
ElMessage.error(data.error)
return
}
await nextTick()
const chart = getOrCreateChart(shapDependenceRef.value)
if (!chart) return
const points = data.values.map((v, i) => [v, data.shap_values[i]])
chart.setOption({
tooltip: { trigger: 'item', formatter: (p) => `值: ${p.data[0].toFixed(2)}<br/>SHAP: ${p.data[1].toFixed(4)}` },
tooltip: { trigger: 'item', formatter: (p) => `值: ${p.data[0].toFixed(2)}<br/>贡献值: ${p.data[1].toFixed(4)}` },
grid: { left: 60, right: 20, top: 20, bottom: 40 },
xAxis: { type: 'value', name: data.feature_cn },
yAxis: { type: 'value', name: 'SHAP value' },
yAxis: { type: 'value', name: '贡献值' },
series: [{
type: 'scatter', data: points, symbolSize: 5,
itemStyle: { color: '#0f766e', opacity: 0.6 },
@@ -488,7 +504,12 @@ async function loadInteractions() {
if (activeTab.value !== 'shap') return
try {
const data = await getInteractions(shapModel.value, 10)
if (data.error || !data.top_interactions) return
if (data.error || !data.top_interactions) {
const chart = getOrCreateChart(shapInteractionRef.value)
if (chart) chart.clear()
if (data.error) ElMessage.error(data.error)
return
}
await nextTick()
const chart = getOrCreateChart(shapInteractionRef.value)
if (!chart) return
@@ -498,7 +519,7 @@ async function loadInteractions() {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 160, right: 20, top: 10, bottom: 30 },
xAxis: { type: 'value', name: '交互强度' },
yAxis: { type: 'category', data: interactions.map(i => `${i.feature_1_cn} x ${i.feature_2_cn}`) },
yAxis: { type: 'category', data: interactions.map(i => `${i.feature_1_cn}${i.feature_2_cn}`) },
series: [{
type: 'bar', data: interactions.map(i => i.strength),
itemStyle: { color: '#f59e0b' },

View File

@@ -1,10 +1,9 @@
<template>
<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>
@@ -15,12 +14,12 @@
<div class="section-heading" style="margin-bottom: 0">
<div>
<h3 class="section-title">中国企业缺勤风险输入</h3>
<p class="section-caption">使用卡片分区组织核心因子演示时更清晰</p>
<p class="section-caption">核心字段分区录入</p>
</div>
<el-button size="small" @click="resetForm">重置</el-button>
</div>
<div class="form-tip">
系统会自动补齐企业背景健康生活与组织属性等次级信息页面仅保留对预测结果影响最大的核心字段
后端将结合默认企业背景健康生活与组织属性完成特征补齐
</div>
</el-card>
@@ -28,9 +27,8 @@
<div class="section-heading">
<div>
<h3 class="section-title">缺勤事件核心信息</h3>
<p class="section-caption">决定本次缺勤时长的直接事件属性</p>
<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">
@@ -86,9 +84,8 @@
<div class="section-heading">
<div>
<h3 class="section-title">工作压力与排班</h3>
<p class="section-caption">体现通勤加班和排班对缺勤的影响</p>
<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">
@@ -133,9 +130,8 @@
<div class="section-heading">
<div>
<h3 class="section-title">家庭与补充因素</h3>
<p class="section-caption">作为结果修正项为预测增加业务语境</p>
<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">
@@ -166,9 +162,8 @@
<div class="section-heading">
<div>
<h3 class="section-title">预测设置</h3>
<p class="section-caption">支持自动选择最优模型或查看模型对比</p>
<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">
@@ -203,10 +198,9 @@
<template #header>
<div class="section-heading" style="margin-bottom: 0">
<div>
<h3 class="section-title">预测结果与风险说明</h3>
<p class="section-caption">在同一张卡片内查看预测值模型信息和风险区间说明</p>
<h3 class="section-title">预测结果与风险等级</h3>
<p class="section-caption">缺勤时长风险等级与模型信息</p>
</div>
<span class="soft-tag">Result</span>
</div>
</template>
<div class="merged-result-grid">
@@ -264,19 +258,90 @@
</div>
</div>
</div>
<div v-if="result?.jdr_snapshot" class="insight-stack">
<section class="insight-block">
<div class="insight-heading">
<div>
<h3 class="section-title">JD-R 快照</h3>
<p class="section-caption">工作要求资源与平衡度</p>
</div>
</div>
<div class="snapshot-grid">
<div v-for="item in jdrSnapshotCards" :key="item.key" class="snapshot-card">
<div class="snapshot-label">{{ item.label }}</div>
<div class="snapshot-score">{{ item.score }}</div>
<el-tag :type="item.tone" size="small">{{ item.status }}</el-tag>
</div>
</div>
</section>
<section v-if="mechanismSummary" class="insight-block">
<div class="insight-heading">
<div>
<h3 class="section-title">风险机制</h3>
<p class="section-caption">当前样本的主要风险路径</p>
</div>
<el-tag v-if="mechanismSummary.pathway_label" :type="mechanismSummary.pathway_tone || 'info'" effect="light">
{{ mechanismSummary.pathway_label }}
</el-tag>
</div>
<div class="summary-text">
<p>{{ mechanismSummary.conclusion }}</p>
<p>{{ mechanismSummary.mechanism }}</p>
<p>{{ mechanismSummary.pathway_detail }}</p>
<p v-if="mechanismSummary.buffer_text">{{ mechanismSummary.buffer_text }}</p>
<p v-if="mechanismSummary.scenario_hint">{{ mechanismSummary.scenario_hint }}</p>
</div>
<div v-if="mechanismSummary.top_drivers?.length" class="driver-group">
<div class="driver-label">主要推高因素</div>
<div class="driver-chip-row">
<span v-for="item in mechanismSummary.top_drivers" :key="`up-${item.name}`" class="driver-chip driver-chip-up">
{{ item.name_cn }}
</span>
</div>
</div>
<div v-if="mechanismSummary.protective_factors?.length" class="driver-group">
<div class="driver-label">缓冲因素</div>
<div class="driver-chip-row">
<span v-for="item in mechanismSummary.protective_factors" :key="`down-${item.name}`" class="driver-chip driver-chip-down">
{{ item.name_cn }}
</span>
</div>
</div>
</section>
<section v-if="interventionGroups.length" class="insight-block">
<div class="insight-heading">
<div>
<h3 class="section-title">干预建议</h3>
<p class="section-caption">管理关注方向</p>
</div>
</div>
<div class="suggestion-grid">
<div v-for="group in interventionGroups" :key="group.category" class="suggestion-card">
<div class="suggestion-title">{{ group.category }}</div>
<div class="suggestion-list">
<p v-for="item in group.items" :key="item">{{ item }}</p>
</div>
</div>
</div>
</section>
</div>
</el-card>
<el-card v-if="compareResults.length > 0" class="panel-card compare-card" shadow="never">
<el-card v-if="shouldShowCompareCard" v-loading="compareLoading" class="panel-card compare-card" shadow="never">
<template #header>
<div class="section-heading" style="margin-bottom: 0">
<div>
<h3 class="section-title">模型对比结果</h3>
<p class="section-caption">选择最适合展示的候选模型</p>
<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 v-if="compareResults.length > 0" :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="90">
<template #default="{ row }">{{ row.predicted_hours }}h</template>
@@ -295,16 +360,16 @@
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无模型对比结果" />
</el-card>
<el-card v-if="shapLocalData" class="panel-card shap-card" shadow="never">
<template #header>
<div class="section-heading" style="margin-bottom: 0">
<div>
<h3 class="section-title">SHAP 预测解释</h3>
<p class="section-caption">每个特征对本次预测的贡献度红色推高/蓝色拉低</p>
<h3 class="section-title">特征贡献</h3>
<p class="section-caption">特征贡献方向与贡献强度</p>
</div>
<span class="soft-tag">Explain</span>
</div>
</template>
<div class="shap-dimension-badges">
@@ -322,11 +387,10 @@
</template>
<script setup>
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import * as echarts from 'echarts'
import request from '@/api/request'
import { getLocalExplanation } from '@/api/shap'
const industries = ['制造业', '互联网', '零售连锁', '物流运输', '金融服务', '医药健康', '建筑工程']
const shiftTypes = ['标准白班', '两班倒', '三班倒', '弹性班']
@@ -377,6 +441,23 @@ const riskTagType = computed(() => {
return getRiskType(result.value.risk_level)
})
const jdrSnapshotCards = computed(() => {
const snapshot = result.value?.jdr_snapshot
if (!snapshot) return []
return [
snapshot.job_demands,
snapshot.job_resources,
snapshot.personal_resources,
snapshot.balance,
snapshot.burnout_risk,
snapshot.engagement,
].filter(Boolean)
})
const mechanismSummary = computed(() => result.value?.mechanism_summary || null)
const interventionGroups = computed(() => result.value?.intervention_suggestions || [])
const shouldShowCompareCard = computed(() => showCompare.value || compareLoading.value || compareResults.value.length > 0)
function getRiskType(level) {
return level === 'low' ? 'success' : level === 'medium' ? 'warning' : 'danger'
}
@@ -408,9 +489,13 @@ async function handlePredict() {
const params = { ...form.value }
if (selectedModel.value) params.model_type = selectedModel.value
result.value = await request.post('/predict/single', params)
shapLocalData.value = result.value?.shap_local || null
if (shapLocalData.value?.features?.length) {
requestAnimationFrame(() => {
nextTick().then(renderShapForce)
})
}
if (showCompare.value) await handleCompare()
// 加载 SHAP 局部解释
loadShapLocal(params)
} catch (e) {
ElMessage.error(`预测失败: ${e.message}`)
} finally {
@@ -418,19 +503,6 @@ async function handlePredict() {
}
}
async function loadShapLocal(params) {
try {
const modelType = params.model_type || ''
const data = await getLocalExplanation({ ...params, model_type: modelType })
if (data && !data.error) {
shapLocalData.value = data
requestAnimationFrame(() => {
nextTick().then(renderShapForce)
})
}
} catch (e) { /* ignore */ }
}
function renderShapForce() {
const el = shapForceRef.value
if (!el || !shapLocalData.value?.features) { console.warn('shapForce: DOM or data missing'); return }
@@ -443,7 +515,7 @@ function renderShapForce() {
chart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 120, right: 30, top: 10, bottom: 30 },
xAxis: { type: 'value', name: 'SHAP值' },
xAxis: { type: 'value', name: '贡献值' },
yAxis: { type: 'category', data: sorted.map(f => f.name_cn) },
series: [{
type: 'bar', data: sorted.map(f => ({
@@ -457,6 +529,7 @@ function renderShapForce() {
async function handleCompare() {
compareLoading.value = true
try {
showCompare.value = true
const res = await request.post('/predict/compare', form.value)
compareResults.value = res.results || []
} catch (e) {
@@ -469,6 +542,16 @@ async function handleCompare() {
onMounted(() => {
loadModels()
})
watch(showCompare, (visible) => {
if (!visible) {
compareResults.value = []
return
}
if (!compareLoading.value && compareResults.value.length === 0) {
handleCompare()
}
})
</script>
<style scoped>
@@ -643,10 +726,133 @@ onMounted(() => {
padding-bottom: 0;
}
.insight-stack {
display: grid;
gap: 16px;
margin-top: 18px;
}
.insight-block {
padding: 18px;
border: 1px solid var(--line-soft);
border-radius: 20px;
background: rgba(255, 255, 255, 0.76);
}
.insight-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.snapshot-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.snapshot-card {
padding: 14px;
border-radius: 16px;
background: linear-gradient(135deg, rgba(58, 122, 254, 0.06), rgba(15, 118, 110, 0.08));
border: 1px solid rgba(58, 122, 254, 0.1);
}
.snapshot-label {
font-size: 13px;
color: var(--text-subtle);
}
.snapshot-score {
margin: 8px 0 12px;
font-size: 24px;
font-weight: 700;
color: var(--text-main);
}
.summary-text {
display: grid;
gap: 10px;
}
.summary-text p,
.suggestion-list p {
margin: 0;
font-size: 13px;
line-height: 1.7;
color: var(--text-main);
}
.driver-group + .driver-group {
margin-top: 12px;
}
.driver-group {
margin-top: 14px;
}
.driver-label,
.suggestion-title {
margin-bottom: 8px;
font-size: 13px;
font-weight: 700;
color: var(--text-main);
}
.driver-chip-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.driver-chip {
padding: 5px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.driver-chip-up {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.18);
}
.driver-chip-down {
color: #2563eb;
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.18);
}
.suggestion-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.suggestion-card {
padding: 14px;
border-radius: 16px;
border: 1px solid var(--line-soft);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(244, 248, 255, 0.9));
}
.suggestion-list {
display: grid;
gap: 8px;
}
@media (max-width: 1200px) {
.prediction-input-grid {
grid-template-columns: 1fr;
}
.snapshot-grid,
.suggestion-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
@@ -654,6 +860,15 @@ onMounted(() => {
.result-stack {
grid-template-columns: 1fr;
}
.snapshot-grid,
.suggestion-grid {
grid-template-columns: 1fr;
}
.insight-heading {
flex-direction: column;
}
}
.shap-card {