feat: 添加 JD-R 理论分析模块与 SHAP 可解释性分析功能
- 后端新增 JD-R(工作要求-资源)理论维度数据生成,包含工作要求、工作资源、
个人资源、中介变量共 16 个新特征列
- 新增 JD-R 分析服务与 API(维度统计、倦怠投入分析、双路径中介分析、
分组轮廓、风险分布)
- 新增 SHAP 可解释性分析模块(全局重要性、局部解释、特征交互、依赖图)
- 预测服务增加风险分类模型加载与概率预测能力
- 前端新增 JD-R 分析页面(JDRAnalysis.vue),含雷达图、散点图、路径分析等可视化
- 预测页面增加风险概率展示与 SHAP 特征解释
- 路由与导航菜单同步更新
This commit is contained in:
1720
frontend/package-lock.json
generated
Normal file
1720
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,10 @@
|
||||
<el-icon class="nav-icon"><UserFilled /></el-icon>
|
||||
<span class="nav-label">员工画像</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/jdr-analysis">
|
||||
<el-icon class="nav-icon"><Reading /></el-icon>
|
||||
<span class="nav-label">JD-R分析</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</div>
|
||||
|
||||
@@ -67,7 +71,7 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { DataAnalysis, Grid, TrendCharts, UserFilled } from '@element-plus/icons-vue'
|
||||
import { DataAnalysis, Grid, TrendCharts, UserFilled, Reading } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const activeMenu = computed(() => route.path)
|
||||
@@ -90,6 +94,10 @@ const metaMap = {
|
||||
'/clustering': {
|
||||
title: '员工画像',
|
||||
subtitle: '通过聚类划分典型群体,为答辩演示提供更直观的人群视角。'
|
||||
},
|
||||
'/jdr-analysis': {
|
||||
title: 'JD-R理论分析',
|
||||
subtitle: '基于工作要求-资源理论的可解释分析,揭示缺勤的心理学驱动因素。'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
21
frontend/src/api/jdr.js
Normal file
21
frontend/src/api/jdr.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import request from './request'
|
||||
|
||||
export function getDimensions() {
|
||||
return request.get('/jdr/dimensions')
|
||||
}
|
||||
|
||||
export function getBurnoutEngagement() {
|
||||
return request.get('/jdr/burnout-engagement')
|
||||
}
|
||||
|
||||
export function getPathAnalysis() {
|
||||
return request.get('/jdr/path-analysis')
|
||||
}
|
||||
|
||||
export function getProfile(dimension) {
|
||||
return request.get(`/jdr/profile?dimension=${dimension}`)
|
||||
}
|
||||
|
||||
export function getRiskDistribution() {
|
||||
return request.get('/jdr/risk-distribution')
|
||||
}
|
||||
17
frontend/src/api/shap.js
Normal file
17
frontend/src/api/shap.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import request from './request'
|
||||
|
||||
export function getGlobalImportance(model) {
|
||||
return request.get(`/shap/global?model=${model || 'random_forest'}`)
|
||||
}
|
||||
|
||||
export function getLocalExplanation(data) {
|
||||
return request.post('/shap/local', data)
|
||||
}
|
||||
|
||||
export function getInteractions(model, topN) {
|
||||
return request.get(`/shap/interaction?model=${model || 'random_forest'}&top_n=${topN || 10}`)
|
||||
}
|
||||
|
||||
export function getDependence(feature, model) {
|
||||
return request.get(`/shap/dependence?feature=${feature}&model=${model || 'random_forest'}`)
|
||||
}
|
||||
@@ -28,6 +28,12 @@ const routes = [
|
||||
name: 'Clustering',
|
||||
component: () => import('@/views/Clustering.vue'),
|
||||
meta: { title: '员工画像' }
|
||||
},
|
||||
{
|
||||
path: '/jdr-analysis',
|
||||
name: 'JDRAnalysis',
|
||||
component: () => import('@/views/JDRAnalysis.vue'),
|
||||
meta: { title: 'JD-R理论分析' }
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
670
frontend/src/views/JDRAnalysis.vue
Normal file
670
frontend/src/views/JDRAnalysis.vue
Normal file
@@ -0,0 +1,670 @@
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
<div v-if="dimensionData" ref="radarChartRef" style="height: 380px"></div>
|
||||
<el-empty v-else description="加载中..." />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="12">
|
||||
<el-card class="panel-card" shadow="never">
|
||||
<template #header>
|
||||
<h3 class="section-title">维度分布统计</h3>
|
||||
<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">
|
||||
<div class="dim-stat-label">{{ item.label }}</div>
|
||||
<div class="dim-stat-value">{{ dimensionData[key]?.mean || '-' }}</div>
|
||||
<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-value">{{ dimensionData.balance.mean }}</div>
|
||||
<div class="dim-stat-sub">正向比例: {{ dimensionData.balance.positive_ratio }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="加载中..." />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</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>
|
||||
</template>
|
||||
<div v-if="burnoutData" ref="burnoutChartRef" style="height: 380px"></div>
|
||||
<el-empty v-else description="加载中..." />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="12">
|
||||
<el-card class="panel-card" shadow="never">
|
||||
<template #header>
|
||||
<h3 class="section-title">关键相关性</h3>
|
||||
<p class="section-caption">JD-R 维度与缺勤时长之间的关联强度</p>
|
||||
</template>
|
||||
<div v-if="burnoutData" ref="corrChartRef" style="height: 380px"></div>
|
||||
<el-empty v-else description="加载中..." />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</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>
|
||||
</template>
|
||||
<div class="path-diagram">
|
||||
<div class="path-flow">
|
||||
<div class="path-section">
|
||||
<div class="path-title">健康损伤路径</div>
|
||||
<div class="path-nodes">
|
||||
<div class="path-node node-demand">工作要求</div>
|
||||
<div class="path-arrow">→</div>
|
||||
<div class="path-node node-burnout">工作倦怠</div>
|
||||
<div class="path-arrow">→</div>
|
||||
<div class="path-node node-absence">缺勤时长</div>
|
||||
</div>
|
||||
<div v-if="pathData?.health_impairment" class="path-stats">
|
||||
<span>直接效应: {{ pathData.health_impairment.direct_effect_demands }}</span>
|
||||
<span>中介效应: {{ pathData.health_impairment.indirect_via_burnout }}</span>
|
||||
<span>中介比例: {{ (pathData.health_impairment.mediation_ratio * 100).toFixed(1) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="path-divider"></div>
|
||||
<div class="path-section">
|
||||
<div class="path-title">激励路径</div>
|
||||
<div class="path-nodes">
|
||||
<div class="path-node node-resource">工作资源</div>
|
||||
<div class="path-arrow">→</div>
|
||||
<div class="path-node node-engagement">工作投入</div>
|
||||
<div class="path-arrow">→</div>
|
||||
<div class="path-node node-absence-low">低缺勤</div>
|
||||
</div>
|
||||
<div v-if="pathData?.motivational" class="path-stats">
|
||||
<span>直接效应: {{ pathData.motivational.direct_effect_resources }}</span>
|
||||
<span>中介效应: {{ pathData.motivational.indirect_via_engagement }}</span>
|
||||
<span>中介比例: {{ (pathData.motivational.mediation_ratio * 100).toFixed(1) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="10">
|
||||
<el-card class="panel-card" shadow="never">
|
||||
<template #header>
|
||||
<h3 class="section-title">风险等级分布</h3>
|
||||
<p class="section-caption">全员缺勤风险等级统计</p>
|
||||
</template>
|
||||
<div v-if="riskData" ref="riskChartRef" style="height: 320px"></div>
|
||||
<el-empty v-else description="加载中..." />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 4: SHAP 解释 -->
|
||||
<el-tab-pane label="SHAP 解释" 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>
|
||||
</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-select>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="shapGlobalData" ref="shapGlobalRef" style="height: 420px"></div>
|
||||
<el-empty v-else description="加载中..." />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="10">
|
||||
<el-card class="panel-card" shadow="never">
|
||||
<template #header>
|
||||
<h3 class="section-title">维度贡献占比</h3>
|
||||
<p class="section-caption">按 JD-R 理论维度聚合的 SHAP 贡献</p>
|
||||
</template>
|
||||
<div v-if="shapGlobalData" ref="shapDimPieRef" style="height: 420px"></div>
|
||||
<el-empty v-else description="加载中..." />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20" style="margin-top: 20px">
|
||||
<el-col :xs="24" :lg="12">
|
||||
<el-card class="panel-card" shadow="never">
|
||||
<template #header>
|
||||
<h3 class="section-title">特征依赖图</h3>
|
||||
<p class="section-caption">选择特征查看其取值与 SHAP 值的关系</p>
|
||||
</template>
|
||||
<div style="margin-bottom: 12px">
|
||||
<el-select v-model="dependenceFeature" size="small" style="width: 200px" @change="loadDependence">
|
||||
<el-option v-for="f in shapTopFeatures" :key="f.name" :label="f.name_cn" :value="f.name" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div v-if="shapGlobalData" ref="shapDependenceRef" style="height: 320px"></div>
|
||||
<el-empty v-else description="加载中..." />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="12">
|
||||
<el-card class="panel-card" shadow="never">
|
||||
<template #header>
|
||||
<h3 class="section-title">特征交互强度</h3>
|
||||
<p class="section-caption">Top 特征对的交互效应</p>
|
||||
</template>
|
||||
<div v-if="shapGlobalData" ref="shapInteractionRef" style="height: 320px"></div>
|
||||
<el-empty v-else description="加载中..." />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getDimensions, getBurnoutEngagement, getPathAnalysis, getRiskDistribution } from '@/api/jdr'
|
||||
import { getGlobalImportance, getInteractions, getDependence } from '@/api/shap'
|
||||
|
||||
const activeTab = ref('dimensions')
|
||||
|
||||
// 数据
|
||||
const dimensionData = ref(null)
|
||||
const burnoutData = ref(null)
|
||||
const pathData = ref(null)
|
||||
const riskData = ref(null)
|
||||
const shapGlobalData = ref(null)
|
||||
const shapTopFeatures = ref([])
|
||||
const shapModel = ref('random_forest')
|
||||
const dependenceFeature = ref('月均加班时长')
|
||||
|
||||
// 图表 DOM ref
|
||||
const radarChartRef = ref(null)
|
||||
const burnoutChartRef = ref(null)
|
||||
const corrChartRef = ref(null)
|
||||
const riskChartRef = ref(null)
|
||||
const shapGlobalRef = ref(null)
|
||||
const shapDimPieRef = ref(null)
|
||||
const shapDependenceRef = ref(null)
|
||||
const shapInteractionRef = ref(null)
|
||||
|
||||
const dimensionLabels = {
|
||||
demands: { label: '工作要求指数', color: '#ef4444' },
|
||||
resources: { label: '工作资源指数', color: '#22c55e' },
|
||||
personal: { label: '个人资源指数', color: '#3b82f6' },
|
||||
}
|
||||
|
||||
function getOrCreateChart(el) {
|
||||
if (!el) return null
|
||||
let chart = echarts.getInstanceByDom(el)
|
||||
if (!chart) chart = echarts.init(el)
|
||||
return chart
|
||||
}
|
||||
|
||||
// 延迟渲染:等待 Vue 将 v-if 的 DOM 插入到页面
|
||||
function scheduleRender(fn) {
|
||||
requestAnimationFrame(() => {
|
||||
nextTick().then(fn)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Tab 1: 维度分析 ──
|
||||
async function loadDimensions() {
|
||||
try {
|
||||
dimensionData.value = await getDimensions()
|
||||
scheduleRender(renderRadarChart)
|
||||
} catch (e) {
|
||||
ElMessage.error('加载维度数据失败')
|
||||
}
|
||||
}
|
||||
|
||||
function renderRadarChart() {
|
||||
const chart = getOrCreateChart(radarChartRef.value)
|
||||
if (!chart || !dimensionData.value) { console.warn('radarChart: DOM or data missing'); return }
|
||||
|
||||
const dims = dimensionData.value
|
||||
const indicators = [
|
||||
{ name: '工作要求', max: 10 },
|
||||
{ name: '工作资源', max: 5 },
|
||||
{ name: '个人资源', max: 5 },
|
||||
{ name: 'JD-R平衡度', max: 5 },
|
||||
]
|
||||
const values = [
|
||||
dims.demands?.mean || 0,
|
||||
dims.resources?.mean || 0,
|
||||
dims.personal?.mean || 0,
|
||||
dims.balance?.mean || 0,
|
||||
]
|
||||
|
||||
chart.setOption({
|
||||
tooltip: {},
|
||||
radar: { indicator: indicators, shape: 'circle', splitNumber: 5 },
|
||||
series: [{
|
||||
type: 'radar',
|
||||
data: [{
|
||||
value: values,
|
||||
name: 'JD-R 维度均值',
|
||||
areaStyle: { color: 'rgba(15, 118, 110, 0.2)' },
|
||||
lineStyle: { color: '#0f766e', width: 2 },
|
||||
itemStyle: { color: '#0f766e' },
|
||||
}],
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
// ── Tab 2: 倦怠与投入 ──
|
||||
async function loadBurnout() {
|
||||
try {
|
||||
burnoutData.value = await getBurnoutEngagement()
|
||||
scheduleRender(() => { renderBurnoutChart(); renderCorrChart() })
|
||||
} catch (e) {
|
||||
ElMessage.error('加载倦怠/投入数据失败')
|
||||
}
|
||||
}
|
||||
|
||||
function renderBurnoutChart() {
|
||||
const chart = getOrCreateChart(burnoutChartRef.value)
|
||||
if (!chart || !burnoutData.value) { console.warn('burnoutChart: DOM or data missing'); return }
|
||||
|
||||
const bDist = burnoutData.value.burnout?.distribution || []
|
||||
const eDist = burnoutData.value.engagement?.distribution || []
|
||||
const categories = bDist.map(d => d.range)
|
||||
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['工作倦怠', '工作投入'] },
|
||||
xAxis: { type: 'category', data: categories },
|
||||
yAxis: { type: 'value', name: '人数' },
|
||||
series: [
|
||||
{
|
||||
name: '工作倦怠', type: 'bar', data: bDist.map(d => d.count),
|
||||
itemStyle: { color: '#ef4444' }, barWidth: '30%',
|
||||
},
|
||||
{
|
||||
name: '工作投入', type: 'bar', data: eDist.map(d => d.count),
|
||||
itemStyle: { color: '#22c55e' }, barWidth: '30%',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function renderCorrChart() {
|
||||
const chart = getOrCreateChart(corrChartRef.value)
|
||||
if (!chart || !burnoutData.value?.correlations) { console.warn('corrChart: DOM or data missing'); return }
|
||||
|
||||
const corrs = burnoutData.value.correlations
|
||||
const items = [
|
||||
{ name: '要求→倦怠', value: corrs.demands_vs_burnout },
|
||||
{ name: '资源→投入', value: corrs.resources_vs_engagement },
|
||||
{ name: '倦怠→缺勤', value: corrs.burnout_vs_absence_hours },
|
||||
{ name: '投入→缺勤', value: corrs.engagement_vs_absence_hours },
|
||||
{ name: '要求→缺勤', value: corrs.demands_vs_absence_hours },
|
||||
{ name: '资源→缺勤', value: corrs.resources_vs_absence_hours },
|
||||
].filter(i => i.value !== undefined)
|
||||
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
xAxis: { type: 'value', name: '相关系数', min: -1, max: 1 },
|
||||
yAxis: { type: 'category', data: items.map(i => i.name) },
|
||||
series: [{
|
||||
type: 'bar', data: items.map(i => ({
|
||||
value: i.value,
|
||||
itemStyle: { color: i.value >= 0 ? '#22c55e' : '#ef4444' },
|
||||
})),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
// ── Tab 3: 双路径 ──
|
||||
async function loadPathAndRisk() {
|
||||
try {
|
||||
const [path, risk] = await Promise.all([getPathAnalysis(), getRiskDistribution()])
|
||||
pathData.value = path
|
||||
riskData.value = risk
|
||||
scheduleRender(renderRiskChart)
|
||||
} catch (e) {
|
||||
ElMessage.error('加载路径分析失败')
|
||||
}
|
||||
}
|
||||
|
||||
function renderRiskChart() {
|
||||
const chart = getOrCreateChart(riskChartRef.value)
|
||||
if (!chart || !riskData.value?.levels) { console.warn('riskChart: DOM or data missing'); return }
|
||||
|
||||
const levels = riskData.value.levels
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
series: [{
|
||||
type: 'pie', radius: ['40%', '70%'],
|
||||
data: levels.map(l => ({
|
||||
name: l.label, value: l.count,
|
||||
itemStyle: { color: l.color },
|
||||
})),
|
||||
label: { formatter: '{b}\n{d}%' },
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
// ── Tab 4: SHAP ──
|
||||
async function loadShapGlobal() {
|
||||
try {
|
||||
const data = await getGlobalImportance(shapModel.value)
|
||||
if (data.error) { ElMessage.error(data.error); return }
|
||||
shapGlobalData.value = data
|
||||
shapTopFeatures.value = data.top_features || []
|
||||
if (shapTopFeatures.value.length && !dependenceFeature.value) {
|
||||
dependenceFeature.value = shapTopFeatures.value[0].name
|
||||
}
|
||||
scheduleRender(() => {
|
||||
renderShapGlobalChart()
|
||||
renderShapDimPie()
|
||||
loadDependence()
|
||||
loadInteractions()
|
||||
})
|
||||
} catch (e) {
|
||||
ElMessage.error('加载 SHAP 数据失败')
|
||||
}
|
||||
}
|
||||
|
||||
function renderShapGlobalChart() {
|
||||
const chart = getOrCreateChart(shapGlobalRef.value)
|
||||
if (!chart || !shapGlobalData.value?.top_features) { console.warn('shapGlobal: DOM or data missing'); return }
|
||||
|
||||
const features = shapGlobalData.value.top_features.slice(0, 15).reverse()
|
||||
const dimColors = {
|
||||
job_demands: '#ef4444',
|
||||
job_resources: '#22c55e',
|
||||
personal_resources: '#3b82f6',
|
||||
mediators: '#f59e0b',
|
||||
event_context: '#8b5cf6',
|
||||
other: '#6b7280',
|
||||
}
|
||||
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
grid: { left: 140, right: 30, top: 10, bottom: 30 },
|
||||
xAxis: { type: 'value', name: 'Mean |SHAP|' },
|
||||
yAxis: { type: 'category', data: features.map(f => f.name_cn) },
|
||||
series: [{
|
||||
type: 'bar', data: features.map(f => ({
|
||||
value: f.importance,
|
||||
itemStyle: { color: dimColors[f.dimension] || '#6b7280' },
|
||||
})),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
function renderShapDimPie() {
|
||||
const chart = getOrCreateChart(shapDimPieRef.value)
|
||||
if (!chart || !shapGlobalData.value?.dimensions) { console.warn('shapDimPie: DOM or data missing'); return }
|
||||
|
||||
const dims = shapGlobalData.value.dimensions
|
||||
const dimColorMap = {
|
||||
job_demands: '#ef4444',
|
||||
job_resources: '#22c55e',
|
||||
personal_resources: '#3b82f6',
|
||||
mediators: '#f59e0b',
|
||||
event_context: '#8b5cf6',
|
||||
}
|
||||
const pieData = Object.entries(dims).map(([key, info]) => {
|
||||
const total = info.features.reduce((s, f) => s + f.importance, 0)
|
||||
return { name: info.name_cn, value: parseFloat(total.toFixed(4)), itemStyle: { color: dimColorMap[key] || '#6b7280' } }
|
||||
})
|
||||
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
series: [{
|
||||
type: 'pie', radius: ['35%', '65%'],
|
||||
data: pieData,
|
||||
label: { formatter: '{b}\n{d}%' },
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
async function loadDependence() {
|
||||
if (!dependenceFeature.value) return
|
||||
try {
|
||||
const data = await getDependence(dependenceFeature.value, shapModel.value)
|
||||
if (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)}` },
|
||||
grid: { left: 60, right: 20, top: 20, bottom: 40 },
|
||||
xAxis: { type: 'value', name: data.feature_cn },
|
||||
yAxis: { type: 'value', name: 'SHAP value' },
|
||||
series: [{
|
||||
type: 'scatter', data: points, symbolSize: 5,
|
||||
itemStyle: { color: '#0f766e', opacity: 0.6 },
|
||||
}],
|
||||
})
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function loadInteractions() {
|
||||
try {
|
||||
const data = await getInteractions(shapModel.value, 10)
|
||||
if (data.error || !data.top_interactions) return
|
||||
await nextTick()
|
||||
const chart = getOrCreateChart(shapInteractionRef.value)
|
||||
if (!chart) return
|
||||
|
||||
const interactions = data.top_interactions.slice(0, 8)
|
||||
chart.setOption({
|
||||
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}`) },
|
||||
series: [{
|
||||
type: 'bar', data: interactions.map(i => i.strength),
|
||||
itemStyle: { color: '#f59e0b' },
|
||||
}],
|
||||
})
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// Tab 切换时重新渲染图表(等待 DOM 就绪)
|
||||
watch(activeTab, async (tab) => {
|
||||
await nextTick()
|
||||
requestAnimationFrame(async () => {
|
||||
await nextTick()
|
||||
if (tab === 'dimensions') {
|
||||
if (dimensionData.value) renderRadarChart()
|
||||
else loadDimensions()
|
||||
}
|
||||
if (tab === 'burnout') {
|
||||
if (burnoutData.value) { renderBurnoutChart(); renderCorrChart() }
|
||||
else loadBurnout()
|
||||
}
|
||||
if (tab === 'path') {
|
||||
if (riskData.value) renderRiskChart()
|
||||
else loadPathAndRisk()
|
||||
}
|
||||
if (tab === 'shap') {
|
||||
if (shapGlobalData.value) { renderShapGlobalChart(); renderShapDimPie(); loadDependence(); loadInteractions() }
|
||||
else loadShapGlobal()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadDimensions()
|
||||
loadBurnout()
|
||||
loadPathAndRisk()
|
||||
loadShapGlobal()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.jdr-hero {
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 0.96), rgba(59, 130, 246, 0.92) 50%, rgba(15, 118, 110, 0.88));
|
||||
}
|
||||
|
||||
.jdr-tabs {
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.jdr-tabs :deep(.el-tabs__content) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dim-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.dim-stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 18px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
}
|
||||
|
||||
.dim-stat-label {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.dim-stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.dim-stat-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.dim-stat-balance {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.08), rgba(255, 255, 255, 0.9));
|
||||
}
|
||||
|
||||
.path-diagram {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.path-flow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.path-section {
|
||||
padding: 24px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
}
|
||||
|
||||
.path-title {
|
||||
margin-bottom: 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.path-nodes {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.path-node {
|
||||
padding: 12px 20px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.node-demand { background: #ef4444; }
|
||||
.node-burnout { background: #f59e0b; }
|
||||
.node-absence { background: #dc2626; }
|
||||
.node-resource { background: #22c55e; }
|
||||
.node-engagement { background: #0f766e; }
|
||||
.node-absence-low { background: #3b82f6; }
|
||||
|
||||
.path-arrow {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.path-stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
font-size: 13px;
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.path-divider {
|
||||
height: 1px;
|
||||
background: var(--line-soft);
|
||||
margin: 0 40px;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
:deep(.panel-card .el-card__header) {
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
</style>
|
||||
@@ -297,15 +297,36 @@
|
||||
</el-table>
|
||||
</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>
|
||||
</div>
|
||||
<span class="soft-tag">Explain</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="shap-dimension-badges">
|
||||
<span v-for="(val, dim) in shapLocalData.dimension_contribution" :key="dim"
|
||||
class="shap-badge" :class="val >= 0 ? 'shap-badge-positive' : 'shap-badge-negative'">
|
||||
{{ dim }}: {{ val >= 0 ? '+' : '' }}{{ val }}
|
||||
</span>
|
||||
</div>
|
||||
<div ref="shapForceRef" style="height: 320px"></div>
|
||||
</el-card>
|
||||
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref } 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 = ['标准白班', '两班倒', '三班倒', '弹性班']
|
||||
@@ -348,6 +369,8 @@ const showCompare = ref(false)
|
||||
const selectedModel = ref('')
|
||||
const availableModels = ref([])
|
||||
const modelsLoading = ref(false)
|
||||
const shapLocalData = ref(null)
|
||||
const shapForceRef = ref(null)
|
||||
|
||||
const riskTagType = computed(() => {
|
||||
if (!result.value) return 'info'
|
||||
@@ -366,6 +389,7 @@ function resetForm() {
|
||||
form.value = { ...defaultForm }
|
||||
result.value = null
|
||||
compareResults.value = []
|
||||
shapLocalData.value = null
|
||||
}
|
||||
|
||||
async function loadModels() {
|
||||
@@ -385,6 +409,8 @@ async function handlePredict() {
|
||||
if (selectedModel.value) params.model_type = selectedModel.value
|
||||
result.value = await request.post('/predict/single', params)
|
||||
if (showCompare.value) await handleCompare()
|
||||
// 加载 SHAP 局部解释
|
||||
loadShapLocal(params)
|
||||
} catch (e) {
|
||||
ElMessage.error(`预测失败: ${e.message}`)
|
||||
} finally {
|
||||
@@ -392,6 +418,42 @@ 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 }
|
||||
let chart = echarts.getInstanceByDom(el)
|
||||
if (!chart) chart = echarts.init(el)
|
||||
|
||||
const features = shapLocalData.value.features.slice(0, 12)
|
||||
const sorted = [...features].sort((a, b) => b.shap_value - a.shap_value)
|
||||
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
grid: { left: 120, right: 30, top: 10, bottom: 30 },
|
||||
xAxis: { type: 'value', name: 'SHAP值' },
|
||||
yAxis: { type: 'category', data: sorted.map(f => f.name_cn) },
|
||||
series: [{
|
||||
type: 'bar', data: sorted.map(f => ({
|
||||
value: f.shap_value,
|
||||
itemStyle: { color: f.shap_value >= 0 ? '#ef4444' : '#3b82f6' },
|
||||
})),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
async function handleCompare() {
|
||||
compareLoading.value = true
|
||||
try {
|
||||
@@ -593,4 +655,34 @@ onMounted(() => {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.shap-card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.shap-dimension-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.shap-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.shap-badge-positive {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.shap-badge-negative {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user