Files
cma-management/frontend/src/views/BotKpiDashboard.vue
T

313 lines
13 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="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">
<BotSourceTag :updated-at="latestBotUpdate?.time || ''" :source="latestBotUpdate?.source || ''" :batch="latestBotUpdate?.batch || ''" style="margin-right:10px;" />
<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 BotSourceTag from '../components/BotSourceTag.vue'
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需要关注` : '✅ 一切正常'
})
// ── C区标注:Bot KPI看板数据最近一次更新(取各KPI最新值回填时间中的最大值,无则不显示) ──
const latestBotUpdate = computed(() => {
const rows = kpis.value.filter((k: any) => k.value_updated_at)
if (!rows.length) return null
const row = rows.slice().sort((a: any, b: any) => String(b.value_updated_at).localeCompare(String(a.value_updated_at)))[0]
return {
time: row.value_updated_at || '',
source: row.value_source_batch || 'finance-bot',
batch: row.value_source_batch || '',
}
})
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>