feat: 添加 JD-R 理论分析模块与 SHAP 可解释性分析功能

- 后端新增 JD-R(工作要求-资源)理论维度数据生成,包含工作要求、工作资源、
    个人资源、中介变量共 16 个新特征列
  - 新增 JD-R 分析服务与 API(维度统计、倦怠投入分析、双路径中介分析、
    分组轮廓、风险分布)
  - 新增 SHAP 可解释性分析模块(全局重要性、局部解释、特征交互、依赖图)
  - 预测服务增加风险分类模型加载与概率预测能力
  - 前端新增 JD-R 分析页面(JDRAnalysis.vue),含雷达图、散点图、路径分析等可视化
  - 预测页面增加风险概率展示与 SHAP 特征解释
  - 路由与导航菜单同步更新
This commit is contained in:
shuo
2026-04-04 07:15:46 +08:00
parent eab1a62ffb
commit e8235bf3ca
30 changed files with 6302 additions and 10 deletions

1720
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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理论分析' }
}
]

View 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">&rarr;</div>
<div class="path-node node-burnout">工作倦怠</div>
<div class="path-arrow">&rarr;</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">&rarr;</div>
<div class="path-node node-engagement">工作投入</div>
<div class="path-arrow">&rarr;</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>

View File

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