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,172 @@
|
|||||||
|
"""Bot KPI管理 API — 管理各Agent的KPI自评体系"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.auth_middleware import require_auth
|
||||||
|
from app.models import KPIDefinition, KPIValue
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/cma/bot-kpis", tags=["Bot KPI管理"],
|
||||||
|
dependencies=[Depends(require_auth)],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 反向指标编码(值越低越好)
|
||||||
|
REVERSE_BOT_INDICATORS = ['FB_RESPONSE_TIME']
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_bot_kpi_score(current_value, target_value, is_reverse=False):
|
||||||
|
"""五档评分引擎(复用KPI体系)- 1~5分"""
|
||||||
|
if current_value is None or target_value is None or target_value == 0:
|
||||||
|
return None, "info"
|
||||||
|
ratio = current_value / target_value
|
||||||
|
if is_reverse:
|
||||||
|
if ratio <= 0.5:
|
||||||
|
return 5, "success"
|
||||||
|
elif ratio <= 0.8:
|
||||||
|
return 4, "success"
|
||||||
|
elif ratio <= 1.0:
|
||||||
|
return 3, "warning"
|
||||||
|
elif ratio <= 1.2:
|
||||||
|
return 2, "danger"
|
||||||
|
else:
|
||||||
|
return 1, "danger"
|
||||||
|
else:
|
||||||
|
if ratio >= 1.2:
|
||||||
|
return 5, "success"
|
||||||
|
elif ratio >= 1.0:
|
||||||
|
return 4, "success"
|
||||||
|
elif ratio >= 0.8:
|
||||||
|
return 3, "warning"
|
||||||
|
elif ratio >= 0.5:
|
||||||
|
return 2, "danger"
|
||||||
|
else:
|
||||||
|
return 1, "danger"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_bot_kpis(
|
||||||
|
source: str = Query("finance-bot", description="Bot标识"),
|
||||||
|
period: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""获取某Bot的所有KPI(含评分)"""
|
||||||
|
kpis = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.bot_source == source,
|
||||||
|
KPIDefinition.status == "active",
|
||||||
|
).order_by(KPIDefinition.kpi_code).all()
|
||||||
|
|
||||||
|
if not kpis:
|
||||||
|
return {
|
||||||
|
"source": source,
|
||||||
|
"kpis": [],
|
||||||
|
"groups": {},
|
||||||
|
"overall": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
from app.api.kpis import REVERSE_INDICATORS as _ri
|
||||||
|
|
||||||
|
result_kpis = []
|
||||||
|
for k in kpis:
|
||||||
|
# 取最新实际值
|
||||||
|
val_query = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == k.id,
|
||||||
|
KPIValue.actual_value.isnot(None),
|
||||||
|
)
|
||||||
|
if period:
|
||||||
|
val_query = val_query.filter(KPIValue.period == period)
|
||||||
|
latest_val = val_query.order_by(KPIValue.period.desc()).first()
|
||||||
|
|
||||||
|
current_val = latest_val.actual_value if latest_val else None
|
||||||
|
is_reverse = k.kpi_code in REVERSE_BOT_INDICATORS
|
||||||
|
score, status = _calc_bot_kpi_score(current_val, k.target_value, is_reverse=is_reverse)
|
||||||
|
|
||||||
|
result_kpis.append({
|
||||||
|
"id": k.id,
|
||||||
|
"kpi_code": k.kpi_code,
|
||||||
|
"kpi_name": k.kpi_name,
|
||||||
|
"category": k.category or "core_output",
|
||||||
|
"formula": k.formula,
|
||||||
|
"target_value": k.target_value,
|
||||||
|
"current_value": current_val,
|
||||||
|
"unit": k.unit,
|
||||||
|
"weight": 15, # 权重在PRD中定义,但未存表,前端使用
|
||||||
|
"score": score,
|
||||||
|
"status": status,
|
||||||
|
"period": latest_val.period if latest_val else None,
|
||||||
|
"frequency": k.frequency or "monthly",
|
||||||
|
})
|
||||||
|
|
||||||
|
# 按group分组: core_output / quality / user_feedback
|
||||||
|
groups = {
|
||||||
|
"core_output": {"label": "核心产出", "kpis": []},
|
||||||
|
"quality": {"label": "质量监控", "kpis": []},
|
||||||
|
"user_feedback": {"label": "用户反馈", "kpis": []},
|
||||||
|
}
|
||||||
|
# category映射: FB编码前缀区分
|
||||||
|
for kp in result_kpis:
|
||||||
|
code = kp["kpi_code"]
|
||||||
|
if code.startswith("FB_ANALYSIS") or code.startswith("FB_ACCURACY") or code.startswith("FB_ISSUE") or code.startswith("FB_ACTION") or code.startswith("FB_RESPONSE"):
|
||||||
|
groups["core_output"]["kpis"].append(kp)
|
||||||
|
elif code.startswith("FB_DATA") or code.startswith("FB_CONSISTENCY") or code.startswith("FB_CITATION"):
|
||||||
|
groups["quality"]["kpis"].append(kp)
|
||||||
|
else:
|
||||||
|
groups["user_feedback"]["kpis"].append(kp)
|
||||||
|
|
||||||
|
# 综合得分
|
||||||
|
scored_kpis = [k for k in result_kpis if k["score"] is not None]
|
||||||
|
if scored_kpis:
|
||||||
|
overall = round(sum(k["score"] * k["weight"] for k in scored_kpis) / sum(k["weight"] for k in scored_kpis), 2)
|
||||||
|
overall_status = "success" if overall >= 4 else ("warning" if overall >= 3 else "danger")
|
||||||
|
else:
|
||||||
|
overall = None
|
||||||
|
overall_status = "info"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"source": source,
|
||||||
|
"kpis": result_kpis,
|
||||||
|
"groups": groups,
|
||||||
|
"overall": {"score": overall, "status": overall_status},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{kpi_id}/value")
|
||||||
|
def update_bot_kpi_value(
|
||||||
|
kpi_id: int,
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""更新Bot KPI当前值"""
|
||||||
|
kpi = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.id == kpi_id,
|
||||||
|
KPIDefinition.bot_source.isnot(None),
|
||||||
|
).first()
|
||||||
|
if not kpi:
|
||||||
|
raise HTTPException(404, "Bot KPI不存在")
|
||||||
|
|
||||||
|
actual_value = data.get("actual_value")
|
||||||
|
if actual_value is None:
|
||||||
|
raise HTTPException(422, "actual_value 不能为空")
|
||||||
|
|
||||||
|
period = data.get("period", datetime.now().strftime("%Y-%m"))
|
||||||
|
|
||||||
|
existing = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == kpi_id,
|
||||||
|
KPIValue.period == period,
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
existing.actual_value = actual_value
|
||||||
|
existing.source_type = "manual"
|
||||||
|
else:
|
||||||
|
val = KPIValue(
|
||||||
|
kpi_id=kpi_id,
|
||||||
|
period=period,
|
||||||
|
actual_value=actual_value,
|
||||||
|
source_type="manual",
|
||||||
|
data_status="pending",
|
||||||
|
)
|
||||||
|
db.add(val)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": "更新成功", "kpi_id": kpi_id, "period": period, "actual_value": actual_value}
|
||||||
+2
-1
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from app.database import init_db
|
from app.database import init_db
|
||||||
from app.api import auth, kpis, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget
|
from app.api import auth, kpis, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis
|
||||||
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
|
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
|
||||||
from scripts.erp_sync import run_sync as run_erp_sync
|
from scripts.erp_sync import run_sync as run_erp_sync
|
||||||
from app.auth_middleware import require_auth
|
from app.auth_middleware import require_auth
|
||||||
@@ -70,6 +70,7 @@ app.include_router(okr.router)
|
|||||||
app.include_router(okr_templates.router)
|
app.include_router(okr_templates.router)
|
||||||
app.include_router(subjects.router)
|
app.include_router(subjects.router)
|
||||||
app.include_router(driver_budget.router)
|
app.include_router(driver_budget.router)
|
||||||
|
app.include_router(bot_kpis.router)
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def global_exception_handler(request: Request, exc: Exception):
|
async def global_exception_handler(request: Request, exc: Exception):
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ class KPIDefinition(Base):
|
|||||||
responsible_dept = Column(String(200), nullable=True, comment="负责部门")
|
responsible_dept = Column(String(200), nullable=True, comment="负责部门")
|
||||||
responsible_user = Column(String(100), nullable=True, comment="负责人")
|
responsible_user = Column(String(100), nullable=True, comment="负责人")
|
||||||
status = Column(String(20), default="active")
|
status = Column(String(20), default="active")
|
||||||
|
bot_source = Column(String(50), nullable=True, comment="Bot标识: finance-bot/ops-bot等")
|
||||||
epic = Column(String(50), default="Epic2", comment="所属Epic")
|
epic = Column(String(50), default="Epic2", comment="所属Epic")
|
||||||
created_by = Column(Integer, nullable=True)
|
created_by = Column(Integer, nullable=True)
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""
|
||||||
|
种子数据:财务Bot KPI(11个)
|
||||||
|
插入到 kpi_definitions 表,bot_source='finance-bot'
|
||||||
|
"""
|
||||||
|
import pymysql
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
DB_USER = os.getenv("CMA_DB_USER", "cma_user")
|
||||||
|
DB_PASS = os.getenv("CMA_DB_PASS", "cma_pass_2026")
|
||||||
|
DB_HOST = os.getenv("CMA_DB_HOST", "127.0.0.1")
|
||||||
|
DB_PORT = int(os.getenv("CMA_DB_PORT", "3306"))
|
||||||
|
DB_NAME = os.getenv("CMA_DB_NAME", "cma")
|
||||||
|
|
||||||
|
FINANCE_BOT_KPIS = [
|
||||||
|
# ── 核心产出(5个 · 月度考核)──
|
||||||
|
{
|
||||||
|
"kpi_code": "FB_ANALYSIS_COUNT",
|
||||||
|
"kpi_name": "分析报告产出数",
|
||||||
|
"formula": "月度生成的结构化分析报告数量",
|
||||||
|
"unit": "份",
|
||||||
|
"target_value": 20,
|
||||||
|
"frequency": "monthly",
|
||||||
|
"category": "core_output",
|
||||||
|
"weight": 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kpi_code": "FB_ACCURACY_RATE",
|
||||||
|
"kpi_name": "数据提取准确率",
|
||||||
|
"formula": "1−(数据错误次数/总分析次数)",
|
||||||
|
"unit": "%",
|
||||||
|
"target_value": 98,
|
||||||
|
"frequency": "monthly",
|
||||||
|
"category": "core_output",
|
||||||
|
"weight": 25,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kpi_code": "FB_ISSUE_FOUND",
|
||||||
|
"kpi_name": "问题发现数",
|
||||||
|
"formula": "月度发现的影响经营的问题数量",
|
||||||
|
"unit": "个",
|
||||||
|
"target_value": 5,
|
||||||
|
"frequency": "monthly",
|
||||||
|
"category": "core_output",
|
||||||
|
"weight": 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kpi_code": "FB_ACTION_RATE",
|
||||||
|
"kpi_name": "行动采纳率",
|
||||||
|
"formula": "被用户采纳的行动建议数/总建议数",
|
||||||
|
"unit": "%",
|
||||||
|
"target_value": 60,
|
||||||
|
"frequency": "monthly",
|
||||||
|
"category": "core_output",
|
||||||
|
"weight": 25,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kpi_code": "FB_RESPONSE_TIME",
|
||||||
|
"kpi_name": "响应时效",
|
||||||
|
"formula": "用户发文件到出分析结果的平均时间",
|
||||||
|
"unit": "分钟",
|
||||||
|
"target_value": 10,
|
||||||
|
"frequency": "monthly",
|
||||||
|
"category": "core_output",
|
||||||
|
"weight": 15,
|
||||||
|
},
|
||||||
|
# ── 质量监控(3个 · 月度考核)──
|
||||||
|
{
|
||||||
|
"kpi_code": "FB_DATA_GAP",
|
||||||
|
"kpi_name": "数据间隙发现率",
|
||||||
|
"formula": "发现的数据异常/缺失数 / 应发现数",
|
||||||
|
"unit": "%",
|
||||||
|
"target_value": 90,
|
||||||
|
"frequency": "monthly",
|
||||||
|
"category": "quality",
|
||||||
|
"weight": 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kpi_code": "FB_CONSISTENCY",
|
||||||
|
"kpi_name": "跨期一致性",
|
||||||
|
"formula": "同期指标口径是否一致",
|
||||||
|
"unit": "%",
|
||||||
|
"target_value": 100,
|
||||||
|
"frequency": "monthly",
|
||||||
|
"category": "quality",
|
||||||
|
"weight": 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kpi_code": "FB_CITATION",
|
||||||
|
"kpi_name": "结论可追溯率",
|
||||||
|
"formula": "每个结论有对应的数据来源",
|
||||||
|
"unit": "%",
|
||||||
|
"target_value": 100,
|
||||||
|
"frequency": "monthly",
|
||||||
|
"category": "quality",
|
||||||
|
"weight": 40,
|
||||||
|
},
|
||||||
|
# ── 用户反馈(3个 · 季度考核)──
|
||||||
|
{
|
||||||
|
"kpi_code": "FB_SATISFACTION",
|
||||||
|
"kpi_name": "用户满意度",
|
||||||
|
"formula": "用户对分析报告的评分(1-5分)",
|
||||||
|
"unit": "分",
|
||||||
|
"target_value": 4.0,
|
||||||
|
"frequency": "quarterly",
|
||||||
|
"category": "user_feedback",
|
||||||
|
"weight": 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kpi_code": "FB_REUSE_RATE",
|
||||||
|
"kpi_name": "复用率",
|
||||||
|
"formula": "用户连续使用天数/月总天数",
|
||||||
|
"unit": "%",
|
||||||
|
"target_value": 80,
|
||||||
|
"frequency": "quarterly",
|
||||||
|
"category": "user_feedback",
|
||||||
|
"weight": 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kpi_code": "FB_REFERRAL",
|
||||||
|
"kpi_name": "推荐率",
|
||||||
|
"formula": "用户主动向他人推荐次数",
|
||||||
|
"unit": "次",
|
||||||
|
"target_value": 1,
|
||||||
|
"frequency": "quarterly",
|
||||||
|
"category": "user_feedback",
|
||||||
|
"weight": 30,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host=DB_HOST, user=DB_USER, password=DB_PASS,
|
||||||
|
database=DB_NAME, charset="utf8mb4",
|
||||||
|
)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
inserted = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for kpi in FINANCE_BOT_KPIS:
|
||||||
|
code = kpi["kpi_code"]
|
||||||
|
# 检查是否已存在
|
||||||
|
cursor.execute("SELECT id FROM kpi_definitions WHERE kpi_code = %s", (code,))
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
if existing:
|
||||||
|
print(f" ⏭ {code} 已存在 (id={existing[0]})")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
sql = """
|
||||||
|
INSERT INTO kpi_definitions
|
||||||
|
(entity_id, kpi_code, kpi_name, dimension, formula, unit,
|
||||||
|
target_value, frequency, category, status, bot_source, data_source,
|
||||||
|
data_owner, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(%s, %s, %s, %s, %s, %s,
|
||||||
|
%s, %s, %s, 'active', 'finance-bot', 'Bot自计数',
|
||||||
|
'FinanceBot', %s, %s)
|
||||||
|
"""
|
||||||
|
cursor.execute(sql, (
|
||||||
|
1, code, kpi["kpi_name"], "process", kpi["formula"], kpi["unit"],
|
||||||
|
kpi["target_value"], kpi["frequency"], kpi["category"],
|
||||||
|
now, now,
|
||||||
|
))
|
||||||
|
new_id = cursor.lastrowid
|
||||||
|
print(f" ✅ {code} -> id={new_id}")
|
||||||
|
inserted += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
print(f"\n完成:新增 {inserted} 条,跳过 {skipped} 条(共 {len(FINANCE_BOT_KPIS)} 个KPI)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -279,4 +279,9 @@ export const ethicsQuizApi = {
|
|||||||
getQuestions: () => api.get('/knowledge/ethics-quiz'),
|
getQuestions: () => api.get('/knowledge/ethics-quiz'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const botKpiApi = {
|
||||||
|
list: (params?: any) => api.get('/bot-kpis', { params }),
|
||||||
|
updateValue: (id: number, data: any) => api.post(`/bot-kpis/${id}/value`, data),
|
||||||
|
}
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||||||
{ path: '/customer', label: '客户维度', icon: 'User', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
|
{ path: '/customer', label: '客户维度', icon: 'User', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
|
||||||
{ path: '/learning-dashboard', label: '学习成长看板', icon: 'Reading', roles: ['ceo', 'finance', 'it'], group: '🟡 C 监控与评价' },
|
{ path: '/learning-dashboard', label: '学习成长看板', icon: 'Reading', roles: ['ceo', 'finance', 'it'], group: '🟡 C 监控与评价' },
|
||||||
{ path: '/alerts', label: '预警中心', icon: 'WarningFilled', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
|
{ path: '/alerts', label: '预警中心', icon: 'WarningFilled', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
|
||||||
|
{ path: '/bot-kpis', label: 'Bot KPI', icon: 'TrendCharts', roles: ['ceo', 'finance', 'it'], group: '🟡 C 监控与评价' },
|
||||||
|
|
||||||
// ── GROUP 4: 复盘与改进(Act)──
|
// ── GROUP 4: 复盘与改进(Act)──
|
||||||
{ path: '/action-plans', label: '改善行动', icon: 'Edit', roles: ['ceo', 'finance', 'business', 'it'], group: '🔴 A 复盘与改进' },
|
{ path: '/action-plans', label: '改善行动', icon: 'Edit', roles: ['ceo', 'finance', 'business', 'it'], group: '🔴 A 复盘与改进' },
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const routes = [
|
|||||||
{ path: 'okr-templates', name: 'OKRTemplates', component: () => import('@/views/OKRTemplates.vue'), meta: { title: 'OKR模板库', roles: ['ceo', 'finance', 'it'] } },
|
{ path: 'okr-templates', name: 'OKRTemplates', component: () => import('@/views/OKRTemplates.vue'), meta: { title: 'OKR模板库', roles: ['ceo', 'finance', 'it'] } },
|
||||||
{ path: 'subjects', name: 'SubjectManage', component: () => import('@/views/SubjectManage.vue'), meta: { title: '科目打标', roles: ['ceo', 'finance', 'business', 'it'] } },
|
{ path: 'subjects', name: 'SubjectManage', component: () => import('@/views/SubjectManage.vue'), meta: { title: '科目打标', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||||
{ path: 'mpm-calculator', name: 'MpmCalculator', component: () => import('@/views/MpmCalculator.vue'), meta: { title: 'MPM计算器', roles: ['ceo', 'finance', 'business'] } },
|
{ path: 'mpm-calculator', name: 'MpmCalculator', component: () => import('@/views/MpmCalculator.vue'), meta: { title: 'MPM计算器', roles: ['ceo', 'finance', 'business'] } },
|
||||||
|
{ path: 'bot-kpis', name: 'BotKpis', component: () => import('@/views/BotKpiDashboard.vue'), meta: { title: 'Bot KPI看板', roles: ['ceo', 'finance', 'it'] } },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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