Files
forsetsystem/frontend/src/views/Prediction.vue
shenjianZ e63267cef6 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
2026-03-11 10:46:58 +08:00

597 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>
<el-row :gutter="20">
<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>
<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.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.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-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-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-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 :value="0"></el-radio>
</el-radio-group>
</el-form-item>
</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.children_count" :min="0" :max="3" style="width: 100%" />
</el-form-item>
</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-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">
<span>{{ model.name_cn }}</span>
<span style="float: right; color: #909399; font-size: 12px">: {{ model.metrics?.r2?.toFixed(2) || '-' }}</span>
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="模型对比">
<el-switch v-model="showCompare" active-text="显示" inactive-text="隐藏" />
</el-form-item>
</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 :xs="24" :xl="9">
<el-card class="panel-card result-card merged-result-card" shadow="never">
<template #header>
<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>
</template>
<div class="merged-result-grid">
<div>
<div v-if="result" class="result-container">
<div class="result-stack">
<div class="result-info-card result-info-primary">
<div class="mini-label">预测缺勤时长</div>
<div class="result-value">{{ result.predicted_hours }}</div>
<div class="result-unit">小时</div>
</div>
<div class="result-info-card">
<div class="mini-label">风险等级</div>
<el-tag :type="riskTagType" size="large">{{ result.risk_label }}</el-tag>
</div>
<div class="result-info-card">
<div class="mini-label">使用模型</div>
<div class="mini-value">{{ result.model_name_cn }}</div>
</div>
<div class="result-info-card">
<div class="mini-label">置信度</div>
<div class="mini-value">{{ (result.confidence * 100).toFixed(0) }}%</div>
</div>
</div>
</div>
<el-empty v-else description="输入中国企业员工场景后开始预测" />
</div>
<div class="risk-legend-cards">
<div class="risk-level-card risk-level-low">
<div class="risk-card-head">
<el-tag type="success" size="small">低风险</el-tag>
</div>
<div class="risk-card-rule">缺勤时长 &lt; 4 小时</div>
<div class="risk-card-desc">通常为短时请假或轻度波动</div>
</div>
<div class="risk-level-card risk-level-medium">
<div class="risk-card-head">
<el-tag type="warning" size="small">中风险</el-tag>
</div>
<div class="risk-card-rule">缺勤时长 4 - 8 小时</div>
<div class="risk-card-desc">属于需要关注的常规风险区间</div>
</div>
<div class="risk-level-card risk-level-high">
<div class="risk-card-head">
<el-tag type="danger" size="small">高风险</el-tag>
</div>
<div class="risk-card-rule">缺勤时长 &gt; 8 小时</div>
<div class="risk-card-desc">通常对应较强事件驱动或持续性风险</div>
</div>
</div>
</div>
</el-card>
<el-card v-if="compareResults.length > 0" 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>
</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="90">
<template #default="{ row }">{{ row.predicted_hours }}h</template>
</el-table-column>
<el-table-column prop="risk_label" label="风险等级" width="80">
<template #default="{ row }">
<el-tag :type="getRiskType(row.risk_level)" size="small">{{ row.risk_label }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="r2" label="R²" width="70">
<template #default="{ row }">{{ row.r2?.toFixed(3) }}</template>
</el-table-column>
<el-table-column label="推荐" width="60">
<template #default="{ row }">
<el-tag v-if="row.recommended" type="success" size="small">推荐</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
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 = {
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 })
const result = ref(null)
const loading = ref(false)
const compareLoading = ref(false)
const compareResults = ref([])
const showCompare = ref(false)
const selectedModel = ref('')
const availableModels = ref([])
const modelsLoading = ref(false)
const riskTagType = computed(() => {
if (!result.value) return 'info'
return getRiskType(result.value.risk_level)
})
function getRiskType(level) {
return level === 'low' ? 'success' : level === 'medium' ? 'warning' : 'danger'
}
function getRowClass({ row }) {
return row.recommended ? 'recommended-row' : ''
}
function resetForm() {
form.value = { ...defaultForm }
result.value = null
compareResults.value = []
}
async function loadModels() {
modelsLoading.value = true
try {
const res = await request.get('/predict/models')
availableModels.value = res.models || []
} finally {
modelsLoading.value = false
}
}
async function handlePredict() {
loading.value = true
try {
const params = { ...form.value }
if (selectedModel.value) params.model_type = selectedModel.value
result.value = await request.post('/predict/single', params)
if (showCompare.value) await handleCompare()
} catch (e) {
ElMessage.error(`预测失败: ${e.message}`)
} finally {
loading.value = false
}
}
async function handleCompare() {
compareLoading.value = true
try {
const res = await request.post('/predict/compare', form.value)
compareResults.value = res.results || []
} catch (e) {
ElMessage.error(`对比失败: ${e.message}`)
} finally {
compareLoading.value = false
}
}
onMounted(() => {
loadModels()
})
</script>
<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;
}
.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: #3A7AFE;
}
.result-unit {
margin-top: 8px;
font-size: 14px;
color: #909399;
}
.risk-legend-cards {
display: grid;
gap: 12px;
}
.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;
}
: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>