feat: Bot KPI管理体系 — bot_source字段 + 11个财务Bot KPI + Bot KPI看板
- 新增 bot_source 字段到 kpi_definitions 表(DB迁移 + 模型字段) - 创建 bot_kpis.py API(GET /api/cma/bot-kpis + POST .../value) - 种子脚本 seed_finance_bot_kpis.py 插入11个财务Bot KPI - BotKpiDashboard.vue 看板组件(三区:核心产出5/质量3/用户反馈3) - 路由 /bot-kpis + 侧边栏菜单入口 - 复用五档评分引擎
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<div class="bot-kpi-page">
|
||||
<!-- 页面Header -->
|
||||
<div class="bot-header">
|
||||
<div class="header-left">
|
||||
<h3>🤖 财务Bot KPI看板</h3>
|
||||
<el-tag type="warning" size="small" effect="plain">2026年7月</el-tag>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button size="small" @click="refresh" :loading="loading">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 综合得分 -->
|
||||
<div class="overall-card">
|
||||
<div class="overall-score">
|
||||
<div class="score-ring" :class="overallStatus">
|
||||
<span class="score-num">{{ overallScore != null ? overallScore : '-' }}</span>
|
||||
<span class="score-label">综合得分</span>
|
||||
</div>
|
||||
<div class="overall-info">
|
||||
<div class="info-title">🏆 财务Bot · 2026年7月</div>
|
||||
<div class="info-desc">基于11项KPI的五档评分引擎,满分5.0</div>
|
||||
<div class="info-status">
|
||||
<el-tag v-if="overallScore != null" :type="overallStatus === 'success' ? 'success' : overallStatus === 'warning' ? 'warning' : 'danger'" size="small">
|
||||
{{ overallScore >= 4 ? '表现优秀' : overallScore >= 3 ? '需要关注' : '亟需改善' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 三区:核心产出 / 质量监控 / 用户反馈 -->
|
||||
<div class="kpi-groups">
|
||||
<!-- 核心产出 -->
|
||||
<div class="group-section">
|
||||
<div class="group-title">
|
||||
<el-icon :size="20"><DataBoard /></el-icon>
|
||||
<span>核心产出 KPI(月度)</span>
|
||||
<el-tag size="small" type="primary">{{ coreKpis.length }}</el-tag>
|
||||
</div>
|
||||
<div class="kpi-card-grid">
|
||||
<div v-for="k in coreKpis" :key="k.id" class="kpi-card" :class="'status-' + k.status">
|
||||
<div class="card-head">
|
||||
<span class="card-name">{{ k.kpi_name }}</span>
|
||||
<el-tag size="small" :type="k.status === 'success' ? 'success' : k.status === 'warning' ? 'warning' : 'danger'" effect="plain">
|
||||
{{ k.score != null ? k.score + '分' : '--' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="card-values">
|
||||
<div class="cv-row">
|
||||
<span class="cv-label">目标</span>
|
||||
<span class="cv-val target">{{ formatVal(k.target_value, k.unit) }}</span>
|
||||
</div>
|
||||
<div class="cv-row">
|
||||
<span class="cv-label">当前</span>
|
||||
<span class="cv-val actual" :class="k.status">{{ k.current_value != null ? formatVal(k.current_value, k.unit) : '待统计' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 进度条 -->
|
||||
<div class="progress-bar-wrap" v-if="k.target_value && k.current_value != null">
|
||||
<div class="progress-fill" :class="k.status"
|
||||
:style="{ width: calcProgress(k.current_value, k.target_value, k.kpi_code) + '%' }">
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-weight">
|
||||
<span>权重 {{ k.weight }}%</span>
|
||||
<span class="formula-hint" :title="k.formula">📐</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 质量监控 -->
|
||||
<div class="group-section">
|
||||
<div class="group-title">
|
||||
<el-icon :size="20"><WarningFilled /></el-icon>
|
||||
<span>质量监控 KPI(月度)</span>
|
||||
<el-tag size="small" type="primary">{{ qualityKpis.length }}</el-tag>
|
||||
</div>
|
||||
<div class="kpi-card-grid">
|
||||
<div v-for="k in qualityKpis" :key="k.id" class="kpi-card" :class="'status-' + k.status">
|
||||
<div class="card-head">
|
||||
<span class="card-name">{{ k.kpi_name }}</span>
|
||||
<el-tag size="small" :type="k.status === 'success' ? 'success' : k.status === 'warning' ? 'warning' : 'danger'" effect="plain">
|
||||
{{ k.score != null ? k.score + '分' : '--' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="card-values">
|
||||
<div class="cv-row">
|
||||
<span class="cv-label">目标</span>
|
||||
<span class="cv-val target">{{ formatVal(k.target_value, k.unit) }}</span>
|
||||
</div>
|
||||
<div class="cv-row">
|
||||
<span class="cv-label">当前</span>
|
||||
<span class="cv-val actual" :class="k.status">{{ k.current_value != null ? formatVal(k.current_value, k.unit) : '待统计' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-bar-wrap" v-if="k.target_value && k.current_value != null">
|
||||
<div class="progress-fill" :class="k.status"
|
||||
:style="{ width: calcProgress(k.current_value, k.target_value, k.kpi_code) + '%' }">
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-weight">
|
||||
<span>权重 {{ k.weight }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户反馈 -->
|
||||
<div class="group-section">
|
||||
<div class="group-title">
|
||||
<el-icon :size="20"><User /></el-icon>
|
||||
<span>用户反馈 KPI(季度)</span>
|
||||
<el-tag size="small" type="primary">{{ feedbackKpis.length }}</el-tag>
|
||||
</div>
|
||||
<div class="kpi-card-grid">
|
||||
<div v-for="k in feedbackKpis" :key="k.id" class="kpi-card" :class="'status-' + k.status">
|
||||
<div class="card-head">
|
||||
<span class="card-name">{{ k.kpi_name }}</span>
|
||||
<el-tag size="small" :type="k.status === 'success' ? 'success' : k.status === 'warning' ? 'warning' : 'danger'" effect="plain">
|
||||
{{ k.score != null ? k.score + '分' : '--' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="card-values">
|
||||
<div class="cv-row">
|
||||
<span class="cv-label">目标</span>
|
||||
<span class="cv-val target">{{ formatVal(k.target_value, k.unit) }}</span>
|
||||
</div>
|
||||
<div class="cv-row">
|
||||
<span class="cv-label">当前</span>
|
||||
<span class="cv-val actual" :class="k.status">{{ k.current_value != null ? formatVal(k.current_value, k.unit) : '待统计' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-bar-wrap" v-if="k.target_value && k.current_value != null">
|
||||
<div class="progress-fill" :class="k.status"
|
||||
:style="{ width: calcProgress(k.current_value, k.target_value, k.kpi_code) + '%' }">
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-weight">
|
||||
<span>权重 {{ k.weight }}%</span>
|
||||
<el-tag size="small" type="info" effect="plain">季度</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预警/待改进 -->
|
||||
<div class="bottom-summary">
|
||||
<el-alert
|
||||
:title="alertsInfo"
|
||||
:type="hasAlerts ? 'warning' : 'success'"
|
||||
:description="hasAlerts ? '以下KPI当前值为空或未达标:' + alertKpiNames : '所有KPI暂无预警'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { botKpiApi } from '../api/index'
|
||||
import { DataBoard, WarningFilled, User } from '@element-plus/icons-vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const source = 'finance-bot'
|
||||
const kpis = ref<any[]>([])
|
||||
const groups = ref<any>({})
|
||||
|
||||
const overallScore = ref<number | null>(null)
|
||||
const overallStatus = ref('info')
|
||||
|
||||
// 分区计算
|
||||
const coreKpis = computed(() => groups.value?.core_output?.kpis || [])
|
||||
const qualityKpis = computed(() => groups.value?.quality?.kpis || [])
|
||||
const feedbackKpis = computed(() => groups.value?.user_feedback?.kpis || [])
|
||||
|
||||
// 预警信息
|
||||
const hasAlerts = computed(() => {
|
||||
return kpis.value.some(k => k.current_value == null || (k.score != null && k.score < 3))
|
||||
})
|
||||
const alertKpiNames = computed(() => {
|
||||
return kpis.value
|
||||
.filter(k => k.current_value == null || (k.score != null && k.score < 3))
|
||||
.map(k => k.kpi_name)
|
||||
.join('、')
|
||||
})
|
||||
const alertsInfo = computed(() => {
|
||||
const cnt = kpis.value.filter(k => k.current_value == null || (k.score != null && k.score < 3)).length
|
||||
return cnt > 0 ? `🚨 ${cnt} 个KPI需要关注` : '✅ 一切正常'
|
||||
})
|
||||
|
||||
function formatVal(val: any, unit: string) {
|
||||
if (val == null) return '-'
|
||||
if (unit === '%') return val + '%'
|
||||
if (unit === '分钟') return val + '分钟'
|
||||
if (unit === '分') return val.toFixed(1) + '分'
|
||||
if (unit === '次') return val + '次/季'
|
||||
return val + (unit || '')
|
||||
}
|
||||
|
||||
function calcProgress(current: number, target: number, code: string) {
|
||||
if (!target || target === 0) return 0
|
||||
const rate = current / target * 100
|
||||
// 反向指标:响应时效
|
||||
if (code === 'FB_RESPONSE_TIME') {
|
||||
return Math.min(100, (target / current) * 100)
|
||||
}
|
||||
return Math.min(100, rate)
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await botKpiApi.list({ source })
|
||||
kpis.value = res.kpis || []
|
||||
groups.value = res.groups || {}
|
||||
overallScore.value = res.overall?.score ?? null
|
||||
overallStatus.value = res.overall?.status || 'info'
|
||||
} catch (e: any) {
|
||||
console.error('加载Bot KPI失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bot-kpi-page { padding: 0; max-width: 1200px; margin: 0 auto; }
|
||||
.bot-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.header-left h3 { margin: 0; font-size: 20px; color: #303133; }
|
||||
.overall-card {
|
||||
background: linear-gradient(135deg, #304156 0%, #43627f 100%);
|
||||
border-radius: 12px; padding: 24px; margin-bottom: 24px;
|
||||
color: #fff;
|
||||
}
|
||||
.overall-score { display: flex; align-items: center; gap: 24px; }
|
||||
.score-ring {
|
||||
width: 100px; height: 100px; border-radius: 50%;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
background: rgba(255,255,255,0.15); border: 3px solid rgba(255,255,255,0.3);
|
||||
}
|
||||
.score-ring.success { border-color: #67c23a; }
|
||||
.score-ring.warning { border-color: #e6a23c; }
|
||||
.score-ring.danger { border-color: #f56c6c; }
|
||||
.score-num { font-size: 32px; font-weight: bold; line-height: 1; }
|
||||
.score-label { font-size: 12px; opacity: 0.8; }
|
||||
.overall-info { flex: 1; }
|
||||
.info-title { font-size: 18px; font-weight: bold; margin-bottom: 4px; }
|
||||
.info-desc { font-size: 13px; opacity: 0.7; margin-bottom: 8px; }
|
||||
.group-section { margin-bottom: 28px; }
|
||||
.group-title {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 16px; font-weight: 600; color: #303133;
|
||||
margin-bottom: 14px; padding-bottom: 8px;
|
||||
border-bottom: 2px solid #e6e6e6;
|
||||
}
|
||||
.kpi-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
|
||||
.kpi-card {
|
||||
background: #fff; border-radius: 10px; padding: 16px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
|
||||
border-left: 4px solid #e6e6e6;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.kpi-card:hover { box-shadow: 0 2px 12px rgba(0,0,0,0.1); transform: translateY(-1px); }
|
||||
.kpi-card.status-success { border-left-color: #67c23a; }
|
||||
.kpi-card.status-warning { border-left-color: #e6a23c; }
|
||||
.kpi-card.status-danger { border-left-color: #f56c6c; }
|
||||
.kpi-card.status-info { border-left-color: #909399; }
|
||||
.card-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.card-name { font-size: 14px; font-weight: 600; color: #303133; }
|
||||
.card-values { margin-bottom: 10px; }
|
||||
.cv-row { display: flex; justify-content: space-between; align-items: center; padding: 3px 0; font-size: 13px; }
|
||||
.cv-label { color: #909399; }
|
||||
.cv-val.target { font-weight: 600; color: #409EFF; }
|
||||
.cv-val.actual { font-weight: 600; color: #303133; }
|
||||
.cv-val.actual.success { color: #67c23a; }
|
||||
.cv-val.actual.warning { color: #e6a23c; }
|
||||
.cv-val.actual.danger { color: #f56c6c; }
|
||||
.progress-bar-wrap {
|
||||
height: 6px; background: #ebeef5; border-radius: 3px;
|
||||
margin: 8px 0; overflow: hidden;
|
||||
}
|
||||
.progress-fill { height: 100%; border-radius: 3px; transition: width 0.5s; }
|
||||
.progress-fill.success { background: #67c23a; }
|
||||
.progress-fill.warning { background: #e6a23c; }
|
||||
.progress-fill.danger { background: #f56c6c; }
|
||||
.progress-fill.info { background: #909399; }
|
||||
.card-weight { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: #909399; }
|
||||
.formula-hint { cursor: help; }
|
||||
.bottom-summary { margin-top: 20px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user