chore: 清理误提交的.bak备份文件(git历史可恢复,源文件均在)

This commit is contained in:
Hermes CI Fix
2026-09-04 00:09:57 +08:00
parent 9eab9bb8a1
commit 88ec021a58
7 changed files with 0 additions and 2011 deletions
-840
View File
@@ -1,840 +0,0 @@
"""驾驶舱 API v2 — 支持时间区间"""
from fastapi import APIRouter, Depends, Query, Request, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func, or_
from datetime import datetime, timedelta
from typing import Optional
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KPIAlert, User
from app.utils.cache import get as cache_get, set as cache_set
import json
import logging
logger = logging.getLogger("cma.dashboard")
router = APIRouter(prefix="/api/cma/dashboard", tags=["驾驶舱"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
def parse_period(period_type: str, start_date: str = None, end_date: str = None):
"""解析时间区间"""
today = datetime.now()
if period_type == "month":
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end = today
elif period_type == "quarter":
q = (today.month - 1) // 3
start = today.replace(month=q*3+1, day=1, hour=0, minute=0, second=0, microsecond=0)
end = today
elif period_type == "year":
start = today.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
end = today
elif period_type == "custom" and start_date and end_date:
start = datetime.strptime(start_date, "%Y-%m-%d")
end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
else:
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end = today
return start, end
def period_prefix(period_type: str):
"""生成SQL期间前缀匹配"""
if period_type == "month":
return datetime.now().strftime("%Y-%m")
elif period_type == "quarter":
now = datetime.now()
q = (now.month - 1) // 3
months = [f"{now.year}-{m:02d}" for m in range(q*3+1, q*3+4)]
return months
elif period_type == "year":
return str(datetime.now().year)
return None
@router.get("/summary")
def get_dashboard_summary(role: str = Query("ceo"), period: str = Query("month"), db: Session = Depends(get_db)):
cache_key = f"summary:{role}:{period}"
cached = cache_get("dashboard", cache_key)
if cached:
return cached
kpi_total = db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active").scalar()
alert_count = db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending").scalar()
dims = db.query(KPIDefinition.dimension, func.count(KPIDefinition.id)).filter(
KPIDefinition.status == "active").group_by(KPIDefinition.dimension).all()
# 读取最近一次同步状态(从日志文件最后一行)
sync_status = {"last_sync": None, "status": "unknown", "detail": ""}
try:
with open("/var/log/cma-daily-sync.log", "r") as f:
lines = f.readlines()
# 从最后往前找包含 "完成" 或 "失败" 的行
for line in reversed(lines[-50:]):
if "全部完成" in line:
sync_status["status"] = "success"
sync_status["last_sync"] = line.strip()
break
elif "失败" in line or "ERROR" in line:
sync_status["status"] = "failed"
sync_status["last_sync"] = line.strip()
break
else:
# 没找到完成/失败标记,取最后一行
sync_status["last_sync"] = lines[-1].strip() if lines else None
except Exception as e:
sync_status["detail"] = str(e)
result = {
"kpi_total": kpi_total or 0, "alert_count": alert_count or 0,
"dimension_stats": [{"dimension": d[0], "count": d[1]} for d in dims],
"sync_status": sync_status,
}
cache_set("dashboard", cache_key, result, ttl_seconds=30)
return result
@router.get("/kpis")
def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"),
start_date: str = Query(None), end_date: str = Query(None),
db: Session = Depends(get_db)):
start, end = parse_period(period, start_date, end_date)
period_str = start.strftime("%Y-%m")
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
result = []
for k in kpis:
base_query = db.query(KPIValue).filter(KPIValue.kpi_id == k.id)
if period == "month":
latest = base_query.filter(KPIValue.period == period_str).order_by(KPIValue.id.desc()).first()
elif period == "quarter":
months = period_prefix("quarter")
values = base_query.filter(KPIValue.period.in_(months)).all()
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{months[0]}~{months[-1]}"})() if latest_val else None
elif period == "year":
values = base_query.filter(KPIValue.period.like(f"{period_str[:4]}%")).all()
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
latest = type('obj', (object,), {"actual_value": latest_val, "period": period_str[:4]})() if latest_val else None
elif period == "custom" and start_date and end_date:
periods = []
d = start
while d <= end:
periods.append(d.strftime("%Y-%m"))
d += timedelta(days=32)
d = d.replace(day=1)
values = base_query.filter(KPIValue.period.in_(set(periods))).all()
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{start_date}~{end_date}"})() if latest_val else None
else:
latest = base_query.order_by(KPIValue.period.desc()).first()
alert = db.query(KPIAlert).filter(
KPIAlert.kpi_id == k.id,
KPIAlert.status == "pending",
).order_by(KPIAlert.id.desc()).first()
result.append({
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"dimension": k.dimension, "unit": k.unit, "target_value": k.target_value,
"actual_value": latest.actual_value if latest else None,
"period": latest.period if latest else None,
"alert_level": alert.alert_level if alert else "none",
"alert_message": alert.alert_message if alert else None,
"frequency": k.frequency,
"responsible_dept": k.responsible_dept,
})
return {"data": result, "period": period, "range": {"start": start.strftime("%Y-%m-%d"), "end": end.strftime("%Y-%m-%d")}}
@router.get("/my-kpis")
def get_my_kpis(
current_user: User = Depends(require_auth),
period: str = Query("month"),
db: Session = Depends(get_db),
):
"""获取当前用户负责的KPI
- business角色:只看自己负责的KPI
- 其他角色:看所有有预警的KPI
"""
role = current_user.role
username = current_user.username
name = current_user.name
period_str = datetime.now().strftime("%Y-%m")
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
result = []
for k in kpis:
# business角色筛选
if role == "business":
responsible = (k.responsible_user or "").strip()
if responsible and responsible != username and responsible != name:
continue
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.period == period_str,
).order_by(KPIValue.id.desc()).first()
alert = db.query(KPIAlert).filter(
KPIAlert.kpi_id == k.id,
KPIAlert.status == "pending",
).order_by(KPIAlert.id.desc()).first()
trend_values = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
).order_by(KPIValue.period.desc()).limit(6).all()
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
result.append({
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"dimension": k.dimension, "unit": k.unit,
"target_value": k.target_value,
"actual_value": latest.actual_value if latest else None,
"period": latest.period if latest else period_str,
"alert_level": alert.alert_level if alert else "none",
"alert_message": alert.alert_message if alert else None,
"alert_id": alert.id if alert else None,
"frequency": k.frequency,
"responsible_dept": k.responsible_dept,
"responsible_user": k.responsible_user,
"trend": trend,
"threshold_green": k.threshold_green,
"threshold_yellow": k.threshold_yellow,
"threshold_red": k.threshold_red,
})
return {"data": result, "user_role": role, "user_name": name, "period": period_str}
@router.get("/finance-analysis")
def get_finance_analysis(
current_user: User = Depends(require_auth),
period: str = Query("month"),
db: Session = Depends(get_db),
):
"""财务工作台分析数据"""
period_str = datetime.now().strftime("%Y-%m")
finance_kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.dimension == "finance",
).all()
kpi_data = []
for k in finance_kpis:
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.period == period_str,
).order_by(KPIValue.id.desc()).first()
trend_values = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
).order_by(KPIValue.period.desc()).limit(6).all()
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
alert = db.query(KPIAlert).filter(
KPIAlert.kpi_id == k.id,
KPIAlert.status == "pending",
).order_by(KPIAlert.id.desc()).first()
kpi_data.append({
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"unit": k.unit, "target_value": k.target_value,
"actual_value": latest.actual_value if latest else None,
"threshold_green": k.threshold_green,
"threshold_yellow": k.threshold_yellow,
"threshold_red": k.threshold_red,
"trend": trend,
"alert_level": alert.alert_level if alert else "none",
"frequency": k.frequency,
})
total_sales = next((k for k in kpi_data if k["kpi_code"] == "SALES_TOTAL"), None)
gross_profit = next((k for k in kpi_data if k["kpi_code"] == "SALES_PROFIT_RATE"), None)
cost_control = next((k for k in kpi_data if k["kpi_code"] == "COST_CONTROL_RATE"), None)
receivable = next((k for k in kpi_data if k["kpi_code"] == "RECEIVABLE_TURNOVER"), None)
return {
"period": period_str,
"kpis": kpi_data,
"summary": {
"total_sales": total_sales["actual_value"] if total_sales else None,
"gross_profit_rate": gross_profit["actual_value"] if gross_profit else None,
"cost_control_rate": cost_control["actual_value"] if cost_control else None,
"receivable_turnover": receivable["actual_value"] if receivable else None,
}
}
@router.get("/predict")
def predict_kpis(db: Session = Depends(get_db)):
"""基于历史趋势预测下月KPI值(简单线性回归)"""
from datetime import datetime, timedelta
period_str = datetime.now().strftime("%Y-%m")
next_month = int(period_str[5:7]) + 1
next_year = int(period_str[:4])
if next_month > 12:
next_month = 1
next_year += 1
next_period = f"{next_year}-{next_month:02d}"
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
predictions = []
for k in kpis:
values = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
).order_by(KPIValue.period.asc()).all()
# 需要至少3个数据点才能做预测
if len(values) < 3:
continue
# 简单线性回归: y = a + bx
points = [(i, v.actual_value) for i, v in enumerate(values) if v.actual_value is not None]
if len(points) < 3:
continue
n = len(points)
sum_x = sum(p[0] for p in points)
sum_y = sum(p[1] for p in points)
sum_xy = sum(p[0] * p[1] for p in points)
sum_xx = sum(p[0] ** 2 for p in points)
# 斜率 b = (n*sum_xy - sum_x*sum_y) / (n*sum_xx - sum_x*sum_x)
denom = n * sum_xx - sum_x * sum_x
if denom == 0:
continue
b = (n * sum_xy - sum_x * sum_y) / denom
a = (sum_y - b * sum_x) / n
# 预测下个月(x = n,因为最后一个索引是 n-1)
predicted_value = a + b * n
# 检查预测值是否触发阈值
alert_level = "none"
if k.threshold_red:
try:
op = k.threshold_red[:2] if len(k.threshold_red) > 1 and k.threshold_red[1] in "=<>" else k.threshold_red[0]
val_str = k.threshold_red.replace(op, "").strip()
val = float(val_str)
if (op in (">=", ">") and predicted_value >= val) or (op in ("<=", "<") and predicted_value <= val):
alert_level = "red"
except (ValueError, IndexError):
pass
if alert_level == "none" and k.threshold_yellow:
try:
op = k.threshold_yellow[:2] if len(k.threshold_yellow) > 1 and k.threshold_yellow[1] in "=<>" else k.threshold_yellow[0]
val_str = k.threshold_yellow.replace(op, "").strip()
val = float(val_str)
if (op in (">=", ">") and predicted_value >= val) or (op in ("<=", "<") and predicted_value <= val):
alert_level = "yellow"
except (ValueError, IndexError):
pass
predictions.append({
"kpi_id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
"target_value": k.target_value,
"last_value": points[-1][1] if points else None,
"predicted_value": round(predicted_value, 2),
"predicted_period": next_period,
"alert_level": alert_level,
"trend": "up" if b > 0 else ("down" if b < 0 else "stable"),
"confidence": "high" if len(points) >= 6 else ("medium" if len(points) >= 4 else "low"),
"data_points": len(points),
})
return {
"current_period": period_str,
"next_period": next_period,
"predictions": predictions,
"kpi_count": len(kpis),
"predictable_count": len(predictions),
}
# ── 个人工作台 ──────────────────────────────
@router.get("/my-dashboard")
def my_dashboard(
current_user: User = Depends(require_auth),
db: Session = Depends(get_db),
):
"""个人工作台:返回我的KPI、改善行动、待办提醒"""
username = current_user.username
name = current_user.name
role = current_user.role
# 角色 → 维度映射(从已发布战略地图中按角色筛选对应维度的KPI)
ROLE_DIMENSIONS = {
"ceo": ["finance", "customer", "process", "learning"], # CEO看全部维度
"finance": ["finance"], # 财务看财务维度
"business": ["customer", "process"], # 业务看客户+流程维度
"it": ["process", "learning"], # IT看流程+学习成长
}
role_dims = ROLE_DIMENSIONS.get(role, ["finance", "customer"])
# 获取所有已发布战略地图的KPI code集合(dimensions中引用的)
from app.models import StrategicMap
published_maps = db.query(StrategicMap).filter(StrategicMap.status == "published").all()
map_kpi_codes = set()
for sm in published_maps:
dims = sm.dimensions
if isinstance(dims, str):
try:
dims = json.loads(dims)
except Exception:
continue
for dim in dims:
for obj in dim.get("objectives", []):
for code in obj.get("kpis", []):
map_kpi_codes.add(code)
# 1. 按角色维度筛选(从已发布地图的KPI中取符合角色维度的)
map_kpis = []
if map_kpi_codes:
map_kpis = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code.in_(map_kpi_codes),
KPIDefinition.dimension.in_(role_dims),
KPIDefinition.status == "active",
).all()
# 2. 补充负责的KPI(responsible_user匹配)
assigned_kpis = db.query(KPIDefinition).filter(
or_(
KPIDefinition.responsible_user == username,
KPIDefinition.responsible_user == name,
),
KPIDefinition.status == "active",
).all()
assigned_ids = {k.id for k in assigned_kpis}
# 去重合并
all_kpis = map_kpis + [k for k in assigned_kpis if k.id not in {mk.id for mk in map_kpis}]
kpi_list = []
for k in all_kpis:
latest_v = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id
).order_by(KPIValue.calculated_at.desc()).first()
actual = latest_v.actual_value if latest_v else None
target = k.target_value
level = "gray"
if actual is not None and target:
ratio = actual / target
level = "green" if ratio >= 0.9 else ("yellow" if ratio >= 0.7 else "red")
kpi_list.append({
"id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
"dimension": k.dimension,
"category": k.category,
"target_value": target,
"actual_value": actual,
"unit": k.unit,
"level": level,
"period": latest_v.period if latest_v else None,
})
# 2. 我的改善行动(assignee匹配)
from app.models import ActionPlan
my_plans = db.query(ActionPlan).filter(
or_(
ActionPlan.assignee == username,
ActionPlan.assignee == name,
)
).order_by(ActionPlan.updated_at.desc()).all()
plan_list = []
for p in my_plans:
overdue = False
if p.due_date and p.status not in ("completed", "cancelled"):
overdue = p.due_date < datetime.now()
kpi_name = ""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
if kpi:
kpi_name = kpi.kpi_name
plan_list.append({
"id": p.id,
"kpi_id": p.kpi_id,
"kpi_name": kpi_name,
"title": p.title,
"assignee": p.assignee,
"priority": p.priority,
"status": p.status,
"progress": p.progress or 0,
"due_date": p.due_date.isoformat() if p.due_date else None,
"overdue": overdue,
"created_at": p.created_at.isoformat() if p.created_at else None,
})
# 3. 待办提醒
reminders = []
# 逾期行动
for p in plan_list:
if p["overdue"]:
reminders.append({
"type": "overdue_plan",
"severity": "danger",
"message": f"你负责的「{p['title']}」已逾期",
"related_id": p["id"],
"related_type": "action_plan",
})
# 红色预警KPI
for k in kpi_list:
if k["level"] == "red":
reminders.append({
"type": "red_kpi",
"severity": "danger",
"message": f"你负责的KPI「{k['kpi_name']}」处于红色预警",
"related_id": k["id"],
"related_type": "kpi",
})
# 黄色预警KPI
for k in kpi_list:
if k["level"] == "yellow":
reminders.append({
"type": "yellow_kpi",
"severity": "warning",
"message": f"你负责的KPI「{k['kpi_name']}」处于黄色预警",
"related_id": k["id"],
"related_type": "kpi",
})
return {
"kpis": kpi_list,
"action_plans": plan_list,
"reminders": reminders,
}
@router.get("/erp-trends")
def get_erp_trends(
current_user: User = Depends(require_auth),
months: int = Query(12, ge=3, le=36),
db: Session = Depends(get_db),
):
"""获取ERP关键指标趋势数据(驾驶舱趋势分析用)"""
codes = [
"F_REVENUE",
"F_PROFIT_RATE",
"F_NET_PROFIT_RATE",
"F_COST_RATIO",
"F_CASH_FLOW",
"F_AR_TURNOVER",
"F_ROE",
"F_ASSET_TURNOVER",
"F_DEBT_RATIO",
"C_CUSTOMER_COUNT",
"C_CUSTOMER_SATISFACTION",
"C_CUSTOMER_CONCENTRATION",
"P_DELIVERY_ON_TIME",
"P_DEFECT_RATE",
"P_SUPPLY_CYCLE",
"L_TRAINING_HOURS",
"L_EMPLOYEE_TURNOVER",
"L_INNOVATION_COUNT",
"L_TECH_COVERAGE",
]
result = {}
for code in codes:
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
if not kpi:
continue
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
).order_by(KPIValue.period.desc()).limit(months).all()
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(values)]
if trend:
vals = [v["value"] for v in trend if v["value"] is not None]
latest = vals[-1] if vals else 0
first = vals[0] if vals else 0
if latest > first * 1.05:
trend_dir = "up"
elif latest < first * 0.95:
trend_dir = "down"
else:
trend_dir = "stable"
mom_val = vals[-2] if len(vals) >= 2 else None
yoy_val = vals[-12] if len(vals) >= 12 else (vals[0] if len(vals) >= 1 else None)
result[code] = {
"name": kpi.kpi_name,
"unit": kpi.unit or "",
"target": kpi.target_value,
"trend": trend,
"trend_dir": trend_dir,
"latest": latest,
"mom": mom_val,
"mom_rate": round((latest - mom_val) / abs(mom_val) * 100, 1) if mom_val and mom_val != 0 else None,
"yoy": yoy_val,
"yoy_rate": round((latest - yoy_val) / abs(yoy_val) * 100, 1) if yoy_val and yoy_val != 0 else None,
}
return {"data": result}
@router.get("/dupont")
async def dupont_analysis(
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
):
"""杜邦分析 — ROE分解
ROE = 净利率 × 资产周转率 × 权益乘数
"""
cache_key = f"dupont:{current_user.role}"
cached = cache_get("dashboard", cache_key)
if cached:
return cached
# 获取底层数据KPI
def get_kpi_value(code: str) -> tuple:
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
if not kpi:
return None, None, None
latest = db.query(KPIValue).filter(KPIValue.kpi_id == kpi.id).order_by(KPIValue.period.desc()).first()
prev = db.query(KPIValue).filter(KPIValue.kpi_id == kpi.id).order_by(KPIValue.period.desc()).offset(1).first()
val = latest.actual_value if latest else None
pval = prev.actual_value if prev else None
return val, pval, kpi.unit
# 营收、利润、总资产、净资产
revenue, prev_revenue, _ = get_kpi_value("F_REVENUE")
# 用营收×净利润率估算净利润(数据库没有净利润绝对值)
profit_net = None
prev_profit_net = None
if revenue:
net_profit_rate, prev_npr, _ = get_kpi_value("F_NET_PROFIT_RATE")
if net_profit_rate:
profit_net = revenue * (net_profit_rate / 100)
if prev_revenue and prev_npr:
prev_profit_net = prev_revenue * (prev_npr / 100)
# 如果还是算不出来,用毛利率做替代估算
if profit_net is None and revenue:
gross_profit, _, _ = get_kpi_value("F_PROFIT_RATE")
profit_net = revenue * (gross_profit / 100) * 0.7 if gross_profit else None # 粗略估算净利润=毛利*0.7
asset_total, prev_asset, _ = get_kpi_value("F_ASSET_TOTAL")
equity_total, prev_equity, _ = get_kpi_value("F_EQUITY_TOTAL")
# 计算杜邦因子
result = {"roe": None, "factors": {}, "raw_data": {}, "history": {}}
if revenue and profit_net and asset_total and equity_total and all(v > 0 for v in [revenue, asset_total, equity_total]):
net_profit_margin = round(profit_net / revenue, 4) # 净利率
asset_turnover = round(revenue / asset_total, 4) # 资产周转率
equity_multiplier = round(asset_total / equity_total, 4) # 权益乘数
roe = round(net_profit_margin * asset_turnover * equity_multiplier * 100, 2)
result["roe"] = roe
result["factors"] = {
"net_profit_margin": {"value": net_profit_margin, "label": "净利率", "desc": f"净利润/{'营收' if revenue else '-'} = {net_profit_margin*100:.2f}%"},
"asset_turnover": {"value": asset_turnover, "label": "资产周转率", "desc": f"营收/总资产 = {asset_turnover:.4f}次"},
"equity_multiplier": {"value": equity_multiplier, "label": "权益乘数", "desc": f"总资产/净资产 = {equity_multiplier:.4f}"},
}
result["raw_data"] = {
"revenue": revenue,
"profit_net": profit_net,
"asset_total": asset_total,
"equity_total": equity_total,
}
# 环比计算
if prev_revenue and prev_profit_net and prev_asset and prev_equity and all(v > 0 for v in [prev_revenue, prev_asset, prev_equity]):
prev_npm = round(prev_profit_net / prev_revenue, 4)
prev_at = round(prev_revenue / prev_asset, 4)
prev_em = round(prev_asset / prev_equity, 4)
prev_roe = round(prev_npm * prev_at * prev_em * 100, 2)
result["history"]["prev"] = {
"roe": prev_roe,
"net_profit_margin": prev_npm,
"asset_turnover": prev_at,
"equity_multiplier": prev_em,
}
# 同比变化
change = round(roe - prev_roe, 2)
npm_change = round((net_profit_margin - prev_npm) * 10000, 2) # 转成BP
at_change = round(asset_turnover - prev_at, 4)
em_change = round(equity_multiplier - prev_em, 4)
result["history"]["change"] = {
"roe": change,
"roe_label": f"{'+' if change > 0 else ''}{change}%",
"net_profit_margin_bp": npm_change,
"asset_turnover": at_change,
"equity_multiplier": em_change,
}
result["history"]["trend"] = "up" if change > 0 else ("down" if change < 0 else "stable")
# 补上原始数据(即使计算不全也返回给前端展示)
if not result.get("raw_data"):
result["raw_data"] = {
"revenue": revenue,
"profit_net": profit_net,
"asset_total": asset_total,
"equity_total": equity_total,
}
cache_set("dashboard", cache_key, result, ttl_seconds=300)
return result
def _get_kpi_trend(kpi_id: int, db: Session) -> dict:
"""计算KPI的环比和同比趋势"""
from datetime import datetime
now = datetime.now()
cur_period = now.strftime("%Y-%m")
# 上月
if now.month == 1:
prev_month = f"{now.year-1}-12"
else:
prev_month = f"{now.year}-{now.month-1:02d}"
# 去年同期
last_year = f"{now.year-1}-{now.month:02d}"
cur_val = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.period == cur_period
).order_by(KPIValue.id.desc()).first()
prev_val = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.period == prev_month
).order_by(KPIValue.id.desc()).first()
yoy_val = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.period == last_year
).order_by(KPIValue.id.desc()).first()
def calc_rate(curr, prev):
if curr and prev and prev.actual_value and prev.actual_value != 0:
return round((curr.actual_value - prev.actual_value) / prev.actual_value * 100, 2)
return None
return {
"current_value": cur_val.actual_value if cur_val else None,
"current_period": cur_period,
"mom_value": prev_val.actual_value if prev_val else None,
"mom_rate": calc_rate(cur_val, prev_val),
"yoy_value": yoy_val.actual_value if yoy_val else None,
"yoy_rate": None if not yoy_val else calc_rate(cur_val, yoy_val),
}
@router.get("/kpis/enhanced")
def get_kpis_enhanced(role: str = Query("ceo"), period: str = Query("month"),
start_date: str = None, end_date: str = None,
db: Session = Depends(get_db)):
"""增强版KPI列表(带趋势)"""
result = get_dashboard_kpis(role=role, period=period, start_date=start_date, end_date=end_date, db=db)
if "data" in result and result["data"]:
for kpi in result["data"]:
if kpi.get("id"):
trend = _get_kpi_trend(kpi["id"], db)
kpi["trend"] = trend
return result
@router.get("/trend-analysis")
def get_trend_analysis(kpi_ids: str = Query(""), period: str = Query("month"),
db: Session = Depends(get_db)):
"""多KPI趋势对比(折线图数据)"""
ids = [int(x) for x in kpi_ids.split(",") if x.strip().isdigit()]
if not ids:
return {"data": []}
result = []
for kpi_id in ids:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
continue
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id
).order_by(KPIValue.period).all()
series = []
for v in values:
if v.actual_value is not None:
series.append({
"period": v.period,
"value": v.actual_value,
})
result.append({
"kpi_id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"unit": kpi.unit,
"target": kpi.target_value,
"data": series,
})
return {"data": result}
@router.get("/alert-stats")
def get_alert_stats(period: str = Query("month"), db: Session = Depends(get_db)):
"""预警统计(按等级和维度)"""
from sqlalchemy import func
# 按等级统计
by_level = db.query(
KPIAlert.alert_level,
func.count(KPIAlert.id)
).group_by(KPIAlert.alert_level).all()
level_stats = {row[0]: row[1] for row in by_level}
# 按维度统计
by_dim = db.query(
KPIDefinition.dimension,
func.count(KPIAlert.id)
).join(KPIAlert, KPIDefinition.id == KPIAlert.kpi_id
).group_by(KPIDefinition.dimension).all()
dim_stats = {row[0]: row[1] for row in by_dim}
return {
"by_level": level_stats,
"by_dimension": dim_stats,
"total": sum(level_stats.values()) if level_stats else 0,
}
@router.get("/export")
def export_kpi_data(kpi_ids: str = "", db: Session = Depends(get_db)):
"""导出KPI数据为CSV格式"""
from fastapi.responses import PlainTextResponse
ids = [int(x) for x in kpi_ids.split(",") if x.strip().isdigit()]
query = db.query(KPIValue).join(KPIDefinition, KPIValue.kpi_id == KPIDefinition.id)
if ids:
query = query.filter(KPIValue.kpi_id.in_(ids))
rows = query.order_by(KPIDefinition.kpi_code, KPIValue.period).all()
csv_lines = ["KPI编码,KPI名称,期间,实际值,目标值,来源,状态"]
for r in rows:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == r.kpi_id).first()
csv_lines.append(f"{kpi.kpi_code},{kpi.kpi_name},{r.period},{r.actual_value},{kpi.target_value},{r.source_type},{r.data_status}")
return PlainTextResponse("\n".join(csv_lines), media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=kpi_export.csv"})
-31
View File
@@ -1,31 +0,0 @@
#!/usr/bin/env bash
# CMA预警检查 — 通过wrapper脚本避免systemd引号冲突
set -euo pipefail
cd /root/cma-management/backend
VENV="/root/cma-management/backend/venv"
# 1. KPI阈值检查
"$VENV/bin/python3" -c "
from app.database import get_db_session
from app.api.alert_rules import check_all_kpi_alerts
db = next(get_db_session())
try:
count = check_all_kpi_alerts(db)
print(f'KPI预警检查完成: 新增{count}条')
finally:
db.close()
" 2>&1
# 2. 差异预警推送检查
"$VENV/bin/python3" -c "
from app.database import get_db_session
from app.api.deviation_push import check_all_push
db = next(get_db_session())
try:
count = check_all_push(db)
print(f'差异预警检查完成: 新增{count}条')
finally:
db.close()
" 2>&1
@@ -1,143 +0,0 @@
<template>
<div class="kp-panel" :class="{ 'kp-collapsed': collapsed }">
<div class="kp-header" @click="toggleCollapse">
<span class="kp-icon">{{ icon }}</span>
<span class="kp-title">📖 CMA知识点</span>
<span class="kp-toggle">{{ collapsed ? '展开' : '收起' }}</span>
</div>
<div v-if="!collapsed" class="kp-body">
<div v-if="loading" style="padding:12px;text-align:center;color:#999;">加载中...</div>
<div v-else-if="articles.length === 0" style="padding:12px;text-align:center;color:#ccc;">暂无关联知识</div>
<template v-else>
<div v-for="(article, idx) in articles" :key="article.id" class="kp-article" :class="{ 'kp-first': idx === 0 }">
<div class="kp-article-head" @click="toggleArticle(idx)">
<span class="kp-article-icon">{{ article.icon || '📖' }}</span>
<span class="kp-article-title">{{ article.title }}</span>
<span class="kp-article-arrow">{{ expandedArticle === idx ? '▼' : '▶' }}</span>
</div>
<div v-if="expandedArticle === idx" class="kp-article-body" v-html="renderedContent(article.content)"></div>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { knowledgeArticleApi } from '../api/index'
import { marked } from 'marked'
const props = defineProps<{
relatedPage: string
icon?: string
defaultCollapsed?: boolean
}>()
const collapsed = ref(props.defaultCollapsed !== false)
const loading = ref(false)
const articles = ref<any[]>([])
const expandedArticle = ref<number | null>(0)
// 首次使用tooltip引导
const hasSeenTooltip = ref(localStorage.getItem('cma_kb_tooltip_seen') === '1')
onMounted(() => {
if (!hasSeenTooltip.value) {
collapsed.value = false
localStorage.setItem('cma_kb_tooltip_seen', '1')
ElMessage.info('💡 右侧面板展示了关联的CMA知识点,可收起不干扰操作')
}
loadArticles()
})
watch(() => props.relatedPage, () => {
loadArticles()
})
function toggleCollapse() {
collapsed.value = !collapsed.value
}
function toggleArticle(idx: number) {
expandedArticle.value = expandedArticle.value === idx ? null : idx
}
function renderedContent(content: string) {
if (!content) return ''
try {
return marked(content)
} catch {
return content
}
}
async function loadArticles() {
if (!props.relatedPage) return
loading.value = true
try {
const r: any = await knowledgeArticleApi.list({ related_page: props.relatedPage })
articles.value = r.data || []
} catch {
articles.value = []
}
loading.value = false
}
</script>
<style scoped>
.kp-panel {
border: 1px solid #e8e8e8;
border-radius: 8px;
background: #fff;
overflow: hidden;
transition: all 0.3s;
font-size: 13px;
}
.kp-panel.kp-collapsed {
max-height: 36px;
}
.kp-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
cursor: pointer;
background: linear-gradient(135deg, #f5f7fa 0%, #eef1f5 100%);
user-select: none;
}
.kp-icon { font-size: 16px; }
.kp-title { flex: 1; font-weight: 600; font-size: 13px; color: #555; }
.kp-toggle { font-size: 11px; color: #999; }
.kp-body { max-height: 400px; overflow-y: auto; }
.kp-article { border-top: 1px solid #f0f0f0; }
.kp-article.kp-first { border-top: none; }
.kp-article-head {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.2s;
}
.kp-article-head:hover { background: #f5f8ff; }
.kp-article-icon { font-size: 14px; }
.kp-article-title { flex: 1; font-size: 13px; color: #409eff; }
.kp-article-arrow { font-size: 10px; color: #999; }
.kp-article-body {
padding: 4px 12px 12px;
line-height: 1.7;
color: #555;
font-size: 12px;
}
.kp-article-body :deep(h4) { margin: 8px 0 4px; font-size: 13px; color: #333; }
.kp-article-body :deep(h5) { margin: 6px 0 3px; font-size: 12px; color: #666; }
.kp-article-body :deep(ul),
.kp-article-body :deep(ol) { padding-left: 16px; }
.kp-article-body :deep(li) { margin-bottom: 2px; }
.kp-article-body :deep(code) { background: #f5f5f5; padding: 1px 4px; border-radius: 3px; font-size: 11px; }
.kp-article-body :deep(table) { width: 100%; border-collapse: collapse; margin: 8px 0; }
.kp-article-body :deep(th),
.kp-article-body :deep(td) { border: 1px solid #e0e0e0; padding: 4px 6px; text-align: left; font-size: 11px; }
.kp-article-body :deep(th) { background: #f5f5f5; font-weight: 600; }
</style>
@@ -1,221 +0,0 @@
<template>
<div v-if="visible" class="mc-dialog-overlay" @click.self="$emit('close')">
<div class="mc-dialog-box">
<div class="mc-dialog-header">
<span>{{ isEditing ? '编辑目标' : '添加目标' }}</span>
<button class="mc-dialog-close" @click="$emit('close')">×</button>
</div>
<div class="mc-dialog-body">
<div class="mc-form-item">
<label>目标名称 <span class="mc-required">*</span></label>
<input v-model="localForm.name" class="mc-input" placeholder="请输入目标名称" ref="nameInputRef" />
</div>
<div class="mc-form-item">
<label>描述</label>
<textarea v-model="localForm.description" class="mc-input mc-textarea" rows="3" placeholder="目标描述(可选)"></textarea>
</div>
<div class="mc-form-item">
<label>图标</label>
<div class="mc-icon-picker">
<button
v-for="(ico, key) in iconOptions" :key="key"
class="mc-icon-btn"
:class="{ active: localForm.icon === key }"
@click="localForm.icon = key"
:title="ico"
>{{ iconEmoji(key) }}</button>
</div>
</div>
<div class="mc-form-item">
<label>关联KPI <span class="mc-hint">(最多3个)</span></label>
<div class="mc-kpi-select-wrap">
<div class="mc-kpi-tags" v-if="localForm.kpis.length > 0">
<span class="mc-kpi-tag" v-for="(code, i) in localForm.kpis" :key="code">
{{ getKpiName(code) }}
<button class="mc-tag-remove" @click="localForm.kpis.splice(i, 1)">×</button>
</span>
</div>
<div class="mc-kpi-add-row" v-if="localForm.kpis.length < 3 && allKpis.length > 0">
<select class="mc-select mc-select-sm" @change="addKpi($event)" style="flex:1;">
<option value="">+ 选择KPI({{ dimLabel }})</option>
<option v-for="k in availableKpis" :key="k.id" :value="k.kpi_code">{{ k.kpi_name }}</option>
<option value="__new__">+ 新建KPI...</option>
</select>
</div>
<div v-if="allKpis.length === 0" style="color:#999;font-size:12px;">
暂无该维度KPI,
<el-button text size="small" type="primary" @click="onNewKpi">点击新建</el-button>
</div>
</div>
</div>
</div>
<div class="mc-dialog-footer">
<button class="mc-btn" @click="$emit('close')">取消</button>
<button class="mc-btn mc-btn-primary" @click="onSave">保存</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps<{
visible: boolean
form: { name: string; description: string; icon: string; kpis: string[] }
dimKey: string
dimLabel: string
isEditing: boolean
allKpis: any[]
kpiNameMap: Record<string, string>
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'save', form: { name: string; description: string; icon: string; kpis: string[] }): void
(e: 'new-kpi'): void
}>()
const nameInputRef = ref<HTMLElement | null>(null)
const localForm = reactive({ name: '', description: '', icon: 'target', kpis: [] as string[] })
watch(() => props.visible, (v) => {
if (v) {
localForm.name = props.form.name
localForm.description = props.form.description
localForm.icon = props.form.icon || 'target'
localForm.kpis = [...(props.form.kpis || [])]
nextTick(() => nameInputRef.value?.focus())
}
})
const iconOptions: Record<string, string> = {
target: '目标', star: '星级', rocket: '火箭', chart: '图表', team: '团队',
light: '灯泡', shield: '盾牌', gear: '齿轮', handshake: '握手', medal: '奖牌',
}
function iconEmoji(key: string): string {
const map: Record<string, string> = {
target: '🎯', star: '⭐', rocket: '🚀', chart: '📊', team: '👥',
light: '💡', shield: '🛡️', gear: '⚙️', handshake: '🤝', medal: '🏅',
}
return map[key] || '📌'
}
const availableKpis = computed(() => {
return props.allKpis.filter((k: any) => {
if (k.dimension !== props.dimKey) return false
if (localForm.kpis.includes(k.kpi_code)) return false
return true
})
})
function getKpiName(code: string): string {
return props.kpiNameMap[code] || code
}
function addKpi(e: any) {
const val = e.target.value
if (val === '__new__') {
e.target.value = ''
emit('new-kpi')
return
}
if (val && !localForm.kpis.includes(val) && localForm.kpis.length < 3) {
localForm.kpis.push(val)
}
e.target.value = ''
}
function onNewKpi() {
emit('new-kpi')
}
function onSave() {
if (!localForm.name?.trim()) {
ElMessage.warning('请输入目标名称')
nextTick(() => nameInputRef.value?.focus())
return
}
emit('save', {
name: localForm.name.trim(),
description: localForm.description?.trim() || '',
icon: localForm.icon || 'target',
kpis: [...localForm.kpis],
})
}
</script>
<style scoped>
.mc-dialog-overlay {
position: fixed; top:0; left:0; right:0; bottom:0;
background:rgba(0,0,0,0.5); z-index:9999;
display:flex; align-items:center; justify-content:center;
}
.mc-dialog-box {
background:#fff; border-radius:12px; width:520px;
box-shadow:0 8px 32px rgba(0,0,0,0.18); overflow:hidden;
}
.mc-dialog-header {
display:flex; justify-content:space-between; align-items:center;
padding:16px 20px; border-bottom:1px solid #f0f0f0;
font-weight:600; font-size:16px;
}
.mc-dialog-close {
background:none; border:none; font-size:22px; color:#999;
cursor:pointer; padding:0 4px; line-height:1;
}
.mc-dialog-close:hover { color:#333; }
.mc-dialog-body { padding:20px; }
.mc-dialog-footer {
display:flex; justify-content:flex-end; gap:10px;
padding:12px 20px; border-top:1px solid #f0f0f0;
}
.mc-form-item { margin-bottom:16px; }
.mc-form-item label {
display:block; font-size:13px; color:#666; margin-bottom:6px; font-weight:500;
}
.mc-required { color:#f56c6c; margin-left:2px; }
.mc-hint { font-weight:400; color:#999; font-size:12px; }
.mc-input {
width:100%; padding:8px 12px; border:1px solid #dcdfe6;
border-radius:6px; font-size:14px; outline:none; box-sizing:border-box;
}
.mc-input:focus { border-color:#409eff; }
.mc-textarea { resize:vertical; min-height:60px; font-family:inherit; }
.mc-select {
width:100%; padding:8px 12px; border:1px solid #dcdfe6;
border-radius:6px; font-size:14px; outline:none; background:#fff; box-sizing:border-box;
}
.mc-select:focus { border-color:#409eff; }
.mc-select-sm { font-size:13px; padding:6px 10px; }
.mc-btn {
padding:8px 20px; border:1px solid #dcdfe6; border-radius:6px;
background:#fff; color:#333; font-size:14px; cursor:pointer;
}
.mc-btn-primary { background:#409eff; color:#fff; border-color:#409eff; }
.mc-btn:hover { opacity:0.85; }
.mc-icon-picker {
display:flex; flex-wrap:wrap; gap:6px;
}
.mc-icon-btn {
width:40px; height:40px; display:flex; align-items:center; justify-content:center;
font-size:20px; border:2px solid #e8e8e8; border-radius:8px;
background:#fff; cursor:pointer; transition:all .15s;
}
.mc-icon-btn:hover { border-color:#409eff; transform:scale(1.1); }
.mc-icon-btn.active { border-color:#409eff; background:#ecf5ff; box-shadow:0 0 0 2px rgba(64,158,255,0.2); }
.mc-kpi-select-wrap { display:flex; flex-direction:column; gap:6px; }
.mc-kpi-tags { display:flex; flex-wrap:wrap; gap:6px; }
.mc-kpi-tag {
display:inline-flex; align-items:center; gap:4px;
padding:4px 10px; background:#ecf5ff; color:#409eff;
border-radius:4px; font-size:12px;
}
.mc-tag-remove {
background:none; border:none; color:#a0cfff; cursor:pointer;
font-size:14px; padding:0; line-height:1;
}
.mc-tag-remove:hover { color:#f56c6c; }
</style>
@@ -1,201 +0,0 @@
<template>
<div class="strategy-layer" :class="`layer-${layerKey}`" :style="{ borderColor: color }">
<div class="layer-header" :style="{ background: color }">
<span class="layer-icon">{{ icon }}</span>
<span class="layer-name">{{ label }}</span>
<span class="layer-objective-count">{{ objectives.length }} 个目标</span>
<el-dropdown trigger="click" @command="onLayerAction">
<el-button class="layer-more-btn" size="small" text>
<el-icon><MoreFilled /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-item command="rename">重命名</el-dropdown-item>
<el-dropdown-item command="color">更换颜色</el-dropdown-item>
<el-dropdown-item command="icon">更换图标</el-dropdown-item>
</template>
</el-dropdown>
</div>
<div class="layer-body" ref="bodyRef">
<MapNode
v-for="(obj, oi) in objectives" :key="`${layerKey}-${oi}`"
:ref="el => setNodeRef(`${layerKey}-${oi}`, el)"
:objective="obj"
:node-key="`${layerKey}-${oi}`"
:index="oi"
:level="getNodeLevel(`${layerKey}-${oi}`)"
:progress-data="getNodeProgress(`${layerKey}-${oi}`)"
:draggable="true"
:is-linking-source="linkingFromKey === `${layerKey}-${oi}`"
:is-linking-target="!!linkingFromKey && linkingFromKey !== `${layerKey}-${oi}`"
:is-drag-connect-target="dragConnectTarget === `${layerKey}-${oi}`"
:is-drag-over="dragOverNodeKey === `${layerKey}-${oi}`"
:icon-map="iconMap"
:kpi-name-map="kpiNameMap"
:all-kpis="allKpis"
@drag-start="(p: any) => onNodeDragStart(layerKey, oi, p.event)"
@drag-over="onNodeDragOver(layerKey, oi)"
@drag-leave="onNodeDragLeave"
@drop="onNodeDrop(layerKey, oi)"
@click="onNodeClick(layerKey, oi, obj)"
@edit="$emit('edit-objective', { dimKey: layerKey, idx: oi, obj })"
@delete="$emit('delete-objective', { dimKey: layerKey, idx: oi })"
@link-click="$emit('start-link', { key: `${layerKey}-${oi}`, obj })"
@link-drag-start="(e: MouseEvent) => $emit('link-drag-start', e, layerKey, oi, obj)"
@kpi-click="(code: string) => $emit('kpi-click', code)"
@plan-click="$emit('show-plan-list', { dimKey: layerKey, idx: oi, obj })"
/>
<el-button text type="primary" size="small" style="margin-top:8px;width:100%;" @click="$emit('add-objective', layerKey)">
+ 添加目标
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { MoreFilled } from '@element-plus/icons-vue'
import MapNode from './MapNode.vue'
const props = defineProps<{
layerKey: string
label: string
icon: string
color: string
objectives: any[]
linkingFromKey?: string | null
dragConnectTarget?: string | null
dragOverNodeKey?: string | null
iconMap?: Record<string, string>
kpiNameMap?: Record<string, string>
allKpis?: any[]
getNodeLevel: (key: string) => string
getNodeProgress: (key: string) => any
}>()
const emit = defineEmits<{
(e: 'add-objective', dimKey: string): void
(e: 'edit-objective', payload: { dimKey: string; idx: number; obj: any }): void
(e: 'delete-objective', payload: { dimKey: string; idx: number }): void
(e: 'start-link', payload: { key: string; obj: any }): void
(e: 'link-drag-start', event: MouseEvent, dimKey: string, oi: number, obj: any): void
(e: 'kpi-click', code: string): void
(e: 'show-plan-list', payload: { dimKey: string; idx: number; obj: any }): void
(e: 'node-click', dimKey: string, oi: number, obj: any): void
(e: 'drag-start', dimKey: string, oi: number, event: DragEvent): void
(e: 'drag-over', dimKey: string, oi: number): void
(e: 'drag-leave'): void
(e: 'drop', dimKey: string, oi: number): void
(e: 'layer-action', cmd: string): void
(e: 'update:label', val: string): void
(e: 'update:color', val: string): void
(e: 'update:icon', val: string): void
}>()
const bodyRef = ref<HTMLElement | null>(null)
// Node DOM refs for line calculation (reactive so parent can aggregate)
const nodeRefs = reactive<Record<string, any>>({})
function setNodeRef(key: string, el: any) {
if (el) nodeRefs[key] = el
}
// Expose nodeRefs to parent for connection line calculation
defineExpose({ nodeRefs, bodyRef })
function onLayerAction(cmd: string) {
emit('layer-action', cmd)
}
function onNodeDragStart(dimKey: string, oi: number, event: DragEvent) {
emit('drag-start', dimKey, oi, event)
}
function onNodeDragOver(dimKey: string, oi: number) {
emit('drag-over', dimKey, oi)
}
function onNodeDragLeave() {
emit('drag-leave')
}
function onNodeDrop(dimKey: string, oi: number) {
emit('drop', dimKey, oi)
}
function onNodeClick(dimKey: string, oi: number, obj: any) {
emit('node-click', dimKey, oi, obj)
}
</script>
<style scoped>
.strategy-layer {
border: 2px solid #e8e8e8;
border-radius: 10px;
display: flex;
flex-direction: column;
background: #fafafa;
min-height: 120px;
transition: border-color 0.2s;
overflow: hidden;
width: 100%;
}
.layer-header {
padding: 10px 14px;
color: #fff;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
flex-shrink: 0;
user-select: none;
}
.layer-icon {
font-size: 18px;
}
.layer-name {
flex: 1;
}
.layer-objective-count {
font-size: 11px;
opacity: 0.8;
font-weight: 400;
}
.layer-more-btn {
color: rgba(255,255,255,0.7) !important;
padding: 2px !important;
}
.layer-more-btn:hover {
color: #fff !important;
}
.layer-body {
padding: 10px 14px;
flex: 1;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
align-content: flex-start;
overflow-y: auto;
}
/* Each MapNode in a layer gets flex basis */
.layer-body > .map-node-wrapper {
flex: 0 0 auto;
}
/* Adjust for the direct MapNode child */
.layer-body :deep(.map-node) {
margin-bottom: 0;
flex: 1 1 220px;
max-width: 280px;
}
/* Add objective button full width row */
.layer-body > .el-button {
width: 100% !important;
flex-shrink: 0;
}
</style>
@@ -1,288 +0,0 @@
<template>
<svg class="connection-svg" ref="svgRef">
<defs>
<!-- 跨层箭头(↓方向) -->
<marker
id="arrow-down"
markerWidth="10"
markerHeight="7"
refX="5"
refY="7"
orient="auto"
>
<polygon points="0,0 10,3.5 0,7" fill="#909399" />
</marker>
<!-- 同层箭头(→方向) -->
<marker
id="arrow-right"
markerWidth="10"
markerHeight="7"
refX="10"
refY="3.5"
orient="auto"
>
<polygon points="0,0 10,3.5 0,7" fill="#409eff" />
</marker>
<!-- 高亮箭头 -->
<marker
id="arrow-active"
markerWidth="10"
markerHeight="7"
refX="10"
refY="3.5"
orient="auto"
>
<polygon points="0,0 10,3.5 0,7" fill="#f56c6c" />
</marker>
</defs>
<!-- 跨层固定箭头:每层底部 → 下层顶部 -->
<line
v-for="(line, idx) in crossLayerLines"
:key="'cross-' + idx"
:x1="line.x1"
:y1="line.y1"
:x2="line.x2"
:y2="line.y2"
stroke="#909399"
stroke-width="2"
stroke-dasharray="6,3"
marker-end="url(#arrow-down)"
class="cross-layer-line"
/>
<!-- 同层用户手动连线箭头(→方向) -->
<g v-for="(conn, idx) in layerConnections" :key="'conn-' + idx">
<path
:d="conn.path"
:class="[
'conn-line',
{ 'conn-selected': selectedIdx === idx },
]"
:stroke="selectedIdx === idx ? '#f56c6c' : '#409eff'"
:stroke-width="selectedIdx === idx ? 3 : 2"
fill="none"
marker-end="url(#arrow-right)"
@click.stop="$emit('select-connection', idx)"
@mouseenter="hoverIdx = idx"
@mouseleave="hoverIdx = null"
/>
<!-- hover提示 -->
<rect
v-if="hoverIdx === idx"
:x="conn.mx - 50"
:y="conn.my - 10"
width="100"
height="20"
rx="4"
fill="rgba(0,0,0,0.65)"
class="conn-hover-bg"
/>
<text
v-if="hoverIdx === idx"
:x="conn.mx"
:y="conn.my + 4"
text-anchor="middle"
fill="#fff"
font-size="11"
class="conn-hover-text"
>
点击选中
</text>
<!-- 删除按钮(选中时) -->
<g v-if="selectedIdx === idx">
<rect
:x="conn.mx - 20"
:y="conn.my - 28"
width="88"
height="24"
rx="12"
fill="#f56c6c"
class="del-btn-bg"
@click.stop="$emit('delete-connection', idx)"
/>
<text
:x="conn.mx + 24"
:y="conn.my - 12"
text-anchor="middle"
fill="#fff"
font-size="12"
font-weight="bold"
class="del-btn-text"
@click.stop="$emit('delete-connection', idx)"
>
✕ 删除连线
</text>
</g>
</g>
<!-- 绘制中的临时线 -->
<line
v-if="tempLine"
:x1="tempLine.x1"
:y1="tempLine.y1"
:x2="tempLine.x2"
:y2="tempLine.y2"
stroke="#e6a23c"
stroke-width="2"
stroke-dasharray="6,3"
marker-end="url(#arrow-down)"
/>
</svg>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
const props = defineProps({
/** 各层DOM元素引用,格式:{ financial: HTMLElement, customer: HTMLElement, ... } */
layerRefs: { type: Object, required: true },
/** 节点DOM元素引用,格式:{ 'financial-0': HTMLElement, ... } */
nodeRefs: { type: Object, default: () => ({}) },
/** 层顺序列表 */
layerKeys: { type: Array, default: () => ['financial', 'customer', 'process', 'learning'] },
/** 同层连线数据 [{from: 'financial-0', to: 'financial-1'}] */
connections: { type: Array, default: () => [] },
/** 临时连线 */
tempLine: { type: Object, default: null },
/** 容器尺寸变化触发重算 */
containerKey: { type: Number, default: 0 },
})
const emit = defineEmits(['select-connection', 'delete-connection'])
const svgRef = ref(null)
const hoverIdx = ref(null)
const selectedIdx = ref(null)
const layerConnections = ref([])
const crossLayerLines = ref([])
/**
* 计算跨层固定箭头
* 从上层底部中心 → 下层顶部中心
*/
function calcCrossLayerLines() {
const lines = []
const keys = props.layerKeys
for (let i = 0; i < keys.length - 1; i++) {
const fromEl = props.layerRefs[keys[i]]
const toEl = props.layerRefs[keys[i + 1]]
if (!fromEl || !toEl) continue
const fr = fromEl.getBoundingClientRect()
const tr = toEl.getBoundingClientRect()
const svg = svgRef.value
if (!svg) continue
const svgRect = svg.getBoundingClientRect()
lines.push({
x1: fr.left + fr.width / 2 - svgRect.left,
y1: fr.bottom - svgRect.top,
x2: tr.left + tr.width / 2 - svgRect.left,
y2: tr.top - svgRect.top,
})
}
crossLayerLines.value = lines
}
/**
* 计算同层用户连线
* 使用贝塞尔曲线从源节点右侧 → 目标节点左侧
*/
function calcLayerConnections() {
const result = []
const svg = svgRef.value
if (!svg) return
const svgRect = svg.getBoundingClientRect()
for (const conn of props.connections) {
const fromEl = props.nodeRefs[conn.from]
const toEl = props.nodeRefs[conn.to]
if (!fromEl || !toEl) continue
const fr = fromEl.getBoundingClientRect()
const tr = toEl.getBoundingClientRect()
// 源节点右侧中点 → 目标节点左侧中点
const x1 = fr.right - svgRect.left
const y1 = fr.top + fr.height / 2 - svgRect.top
const x2 = tr.left - svgRect.left
const y2 = tr.top + tr.height / 2 - svgRect.top
// 贝塞尔曲线控制点
const dx = Math.abs(x2 - x1) * 0.5
const cp1x = x1 + dx
const cp1y = y1
const cp2x = x2 - dx
const cp2y = y2
const path = `M ${x1} ${y1} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${x2} ${y2}`
result.push({
path,
mx: (x1 + x2) / 2,
my: (y1 + y2) / 2,
from: conn.from,
to: conn.to,
})
}
layerConnections.value = result
}
function recalcAll() {
calcCrossLayerLines()
calcLayerConnections()
}
// 暴露给父组件调用
defineExpose({ recalcAll })
onMounted(() => {
nextTick(recalcAll)
})
</script>
<style scoped>
.connection-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 5;
pointer-events: none;
overflow: visible;
}
.cross-layer-line {
pointer-events: none;
}
.conn-line {
cursor: pointer;
transition: stroke 0.15s, stroke-width 0.15s;
pointer-events: stroke;
}
.conn-line:hover {
stroke: #e6a23c !important;
stroke-width: 4 !important;
}
.conn-selected {
stroke: #f56c6c !important;
stroke-width: 3;
}
.del-btn-bg {
cursor: pointer;
pointer-events: all;
}
.del-btn-bg:hover {
fill: #e74c3c !important;
}
.del-btn-text {
cursor: pointer;
pointer-events: all;
user-select: none;
}
.conn-hover-bg,
.conn-hover-text {
pointer-events: none;
}
</style>
@@ -1,287 +0,0 @@
<template>
<div>
<h3>预测模拟</h3>
<el-tabs v-model="activeTab" style="margin-top:16px;">
<!-- Tab 1: CVP本量利分析 -->
<el-tab-pane label="本量利分析" name="cvp">
<el-row :gutter="16">
<el-col :span="10">
<el-card>
<template #header>输入参数</template>
<el-form :model="cvpForm" label-width="140px">
<el-form-item label="产品单价"><el-input-number v-model="cvpForm.unit_price" :min="0" :precision="2" style="width:200px;" /></el-form-item>
<el-form-item label="单位变动成本"><el-input-number v-model="cvpForm.unit_variable_cost" :min="0" :precision="2" style="width:200px;" /></el-form-item>
<el-form-item label="固定成本"><el-input-number v-model="cvpForm.fixed_cost" :min="0" :precision="2" style="width:200px;" /></el-form-item>
<el-form-item label="实际销量(可选)"><el-input-number v-model="cvpForm.actual_volume" :min="0" style="width:200px;" /></el-form-item>
<el-form-item label="目标利润(可选)"><el-input-number v-model="cvpForm.target_profit" :min="0" :precision="2" style="width:200px;" /></el-form-item>
<el-form-item>
<el-button type="primary" @click="doCvp" :loading="cvpLoading">计算</el-button>
<el-button @click="cvpForm = { unit_price: 100, unit_variable_cost: 60, fixed_cost: 50000, actual_volume: 2000, target_profit: 20000 }">填充示例</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
<el-col :span="14">
<el-card v-if="cvpResult.bep_units">
<template #header>分析结果</template>
<el-row :gutter="12">
<el-col :span="8"><div class="result-card"><div class="r-label">保本销量</div><div class="r-value">{{ cvpResult.bep_units }} 件</div></div></el-col>
<el-col :span="8"><div class="result-card"><div class="r-label">保本销售额</div><div class="r-value">{{ formatMoney(cvpResult.bep_revenue) }}</div></div></el-col>
<el-col :span="8"><div class="result-card"><div class="r-label">边际贡献率</div><div class="r-value blue">{{ cvpResult.contribution_ratio }}%</div></div></el-col>
</el-row>
<el-row :gutter="12" style="margin-top:12px;">
<el-col :span="8" v-if="cvpResult.safety_margin_units !== undefined"><div class="result-card"><div class="r-label">安全边际(量)</div><div class="r-value green">{{ cvpResult.safety_margin_units }} 件</div></div></el-col>
<el-col :span="8" v-if="cvpResult.safety_margin_ratio !== undefined"><div class="result-card"><div class="r-label">安全边际率</div><div class="r-value" :class="safetyColor(cvpResult.safety_margin_ratio)">{{ cvpResult.safety_margin_ratio }}%</div></div></el-col>
<el-col :span="8" v-if="cvpResult.actual_profit !== undefined"><div class="result-card"><div class="r-label">当前利润</div><div class="r-value orange">{{ formatMoney(cvpResult.actual_profit) }}</div></div></el-col>
</el-row>
<el-row :gutter="12" style="margin-top:12px;" v-if="cvpResult.target_units">
<el-col :span="12"><div class="result-card"><div class="r-label">目标利润所需销量</div><div class="r-value purple">{{ cvpResult.target_units }} 件</div></div></el-col>
<el-col :span="12"><div class="result-card"><div class="r-label">目标利润所需收入</div><div class="r-value purple">{{ formatMoney(cvpResult.target_revenue) }}</div></div></el-col>
</el-row>
</el-card>
<el-empty v-else-if="!cvpLoading" description="输入参数后点击「计算」" style="padding:40px 0;" />
</el-col>
</el-row>
</el-tab-pane>
<!-- Tab 2: 投资决策 -->
<el-tab-pane label="投资决策" name="investment">
<el-row :gutter="16">
<el-col :span="10">
<el-card>
<template #header>输入参数</template>
<el-form :model="invForm" label-width="140px">
<el-form-item label="初始投资"><el-input-number v-model="invForm.initial_investment" :min="0" :precision="2" style="width:200px;" /></el-form-item>
<el-form-item label="折现率(%)"><el-input-number v-model="invForm.discount_rate" :min="0" :max="100" :precision="1" style="width:200px;" /></el-form-item>
<el-form-item label="现金流(每年)">
<div v-for="(cf, idx) in invForm.cash_flows" :key="idx" style="display:flex;gap:8px;margin-bottom:4px;">
<span style="line-height:32px;width:80px;">第{{ idx+1 }}年</span>
<el-input-number v-model="invForm.cash_flows[idx]" :min="0" :precision="2" style="width:150px;" />
<el-button v-if="invForm.cash_flows.length > 1" size="small" type="danger" @click="invForm.cash_flows.splice(idx, 1)">×</el-button>
</div>
<el-button size="small" @click="invForm.cash_flows.push(0)">+ 添加年份</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="doInvestment" :loading="invLoading">计算</el-button>
<el-button @click="fillInvExample">填充示例</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
<el-col :span="14">
<el-card v-if="invResult.npv_analysis">
<template #header>分析结果</template>
<el-row :gutter="12">
<el-col :span="8"><div class="result-card"><div class="r-label">净现值 (NPV)</div><div class="r-value" :class="invResult.npv_analysis.npv > 0 ? 'green' : 'red'">{{ formatMoney(invResult.npv_analysis.npv) }}</div></div></el-col>
<el-col :span="8"><div class="result-card"><div class="r-label">内部收益率 (IRR)</div><div class="r-value blue">{{ invResult.irr_analysis.irr }}%</div></div></el-col>
<el-col :span="8"><div class="result-card"><div class="r-label">盈利指数 (PI)</div><div class="r-value purple">{{ invResult.npv_analysis.profitability_index }}</div></div></el-col>
</el-row>
<el-row :gutter="12" style="margin-top:12px;">
<el-col :span="8"><div class="result-card"><div class="r-label">静态回收期</div><div class="r-value">{{ invResult.irr_analysis.payback_period || '--' }} 年</div></div></el-col>
<el-col :span="8"><div class="result-card"><div class="r-label">动态回收期</div><div class="r-value">{{ invResult.irr_analysis.discounted_payback_period || '>N期' }} 年</div></div></el-col>
<el-col :span="8"><div class="result-card"><div class="r-label">可行性</div><div class="r-value" :class="invResult.npv_analysis.is_viable ? 'green' : 'red'">{{ invResult.npv_analysis.is_viable ? '✅ 可行' : '❌ 不可行' }}</div></div></el-col>
</el-row>
</el-card>
<el-empty v-else-if="!invLoading" description="输入参数后点击「计算」" style="padding:40px 0;" />
</el-col>
</el-row>
</el-tab-pane>
<!-- Tab 3: 敏感性分析 -->
<el-tab-pane label="敏感性分析" name="sensitivity">
<el-row :gutter="16">
<el-col :span="10">
<el-card>
<template #header>输入参数</template>
<el-form :model="sensForm" label-width="140px">
<el-form-item label="基准收入"><el-input-number v-model="sensForm.base_revenue" :min="0" :precision="2" style="width:200px;" /></el-form-item>
<el-form-item label="基准成本"><el-input-number v-model="sensForm.base_cost" :min="0" :precision="2" style="width:200px;" /></el-form-item>
<el-form-item label="变动步长(%)"><el-input-number v-model="sensForm.step" :min="1" :max="50" style="width:200px;" /></el-form-item>
<el-form-item>
<el-button type="primary" @click="doSensitivity" :loading="sensLoading">计算</el-button>
<el-button @click="sensForm = { base_revenue: 1000000, base_cost: 700000, step: 5 }">填充示例</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
<el-col :span="14">
<el-card v-if="sensResult.base_revenue">
<template #header>
敏感性分析结果 — 基准利润:{{ formatMoney(sensResult.base_profit) }}
</template>
<el-table :data="sensResult.factors" border stripe size="small" style="width:100%;" max-height="400">
<el-table-column prop="change_pct" label="变动%" width="70" align="center">
<template #default="{ row }"><span :style="{ color: row.change_pct > 0 ? '#f56c6c' : '#67c23a', fontWeight:600 }">{{ row.change_pct > 0 ? '+' : '' }}{{ row.change_pct }}%</span></template>
</el-table-column>
<el-table-column prop="revenue_change_profit" label="收入变动→利润" width="130" align="right"><template #default="{ row }">{{ formatMoney(row.revenue_change_profit) }}</template></el-table-column>
<el-table-column prop="revenue_sensitivity" label="收入敏感度" width="100" align="right"><template #default="{ row }"><span :style="{ color: row.revenue_sensitivity < 0 ? '#f56c6c' : '#67c23a' }">{{ row.revenue_sensitivity > 0 ? '+' : '' }}{{ row.revenue_sensitivity }}%</span></template></el-table-column>
<el-table-column prop="cost_change_profit" label="成本变动→利润" width="130" align="right"><template #default="{ row }">{{ formatMoney(row.cost_change_profit) }}</template></el-table-column>
<el-table-column prop="cost_sensitivity" label="成本敏感度" width="100" align="right"><template #default="{ row }"><span :style="{ color: row.cost_sensitivity < 0 ? '#f56c6c' : '#67c23a' }">{{ row.cost_sensitivity > 0 ? '+' : '' }}{{ row.cost_sensitivity }}%</span></template></el-table-column>
</el-table>
</el-card>
<el-empty v-else-if="!sensLoading" description="输入参数后点击「计算」" style="padding:40px 0;" />
</el-col>
</el-row>
</el-tab-pane>
<!-- Tab 4: 情景模拟 -->
<el-tab-pane label="情景模拟" name="scenario">
<el-row :gutter="16">
<el-col :span="10">
<el-card>
<template #header>输入三个情景</template>
<el-form label-width="120px">
<div style="margin-bottom:12px;">
<el-tag type="success" size="small">乐观情景</el-tag>
<div style="display:flex;gap:8px;margin-top:4px;">
<el-input-number v-model="scenarioForm.optimistic.revenue" placeholder="收入" :min="0" :precision="2" style="width:150px;" />
<el-input-number v-model="scenarioForm.optimistic.cost" placeholder="成本" :min="0" :precision="2" style="width:150px;" />
</div>
</div>
<div style="margin-bottom:12px;">
<el-tag type="warning" size="small">中性情景</el-tag>
<div style="display:flex;gap:8px;margin-top:4px;">
<el-input-number v-model="scenarioForm.base.revenue" :min="0" :precision="2" style="width:150px;" />
<el-input-number v-model="scenarioForm.base.cost" :min="0" :precision="2" style="width:150px;" />
</div>
</div>
<div style="margin-bottom:12px;">
<el-tag type="danger" size="small">悲观情景</el-tag>
<div style="display:flex;gap:8px;margin-top:4px;">
<el-input-number v-model="scenarioForm.pessimistic.revenue" :min="0" :precision="2" style="width:150px;" />
<el-input-number v-model="scenarioForm.pessimistic.cost" :min="0" :precision="2" style="width:150px;" />
</div>
</div>
<el-button type="primary" @click="doScenario" :loading="scenarioLoading">模拟</el-button>
<el-button @click="fillScenarioExample">填充示例</el-button>
</el-form>
</el-card>
</el-col>
<el-col :span="14">
<el-card v-if="scenarioResult.scenarios">
<template #header>
模拟结果
<span style="margin-left:12px;font-size:12px;color:#999;">
期望利润:{{ formatMoney(scenarioResult.expected_profit) }}
| 标准差:{{ formatMoney(scenarioResult.std_deviation) }}
</span>
</template>
<el-table :data="scenarioResult.scenarios" border stripe size="small" style="width:100%;">
<el-table-column prop="scenario" label="情景" width="80">
<template #default="{ row }">
<el-tag :type="row.scenario === '乐观' ? 'success' : row.scenario === '中性' ? 'warning' : 'danger'" size="small">{{ row.scenario }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="revenue" label="收入" width="100" align="right"><template #default="{ row }">{{ formatMoney(row.revenue) }}</template></el-table-column>
<el-table-column prop="cost" label="成本" width="100" align="right"><template #default="{ row }">{{ formatMoney(row.cost) }}</template></el-table-column>
<el-table-column prop="profit" label="利润" width="100" align="right">
<template #default="{ row }"><span :style="{ color: row.profit >= 0 ? '#67c23a' : '#f56c6c', fontWeight:600 }">{{ formatMoney(row.profit) }}</span></template>
</el-table-column>
<el-table-column prop="profit_margin" label="利润率" width="80" align="right"><template #default="{ row }">{{ row.profit_margin }}%</template></el-table-column>
<el-table-column prop="deviation_from_base" label="偏差" width="100" align="right">
<template #default="{ row }"><span :style="{ color: row.deviation_from_base > 0 ? '#67c23a' : '#f56c6c' }">{{ row.deviation_from_base > 0 ? '+' : '' }}{{ formatMoney(row.deviation_from_base) }}</span></template>
</el-table-column>
<el-table-column prop="deviation_pct" label="偏差%" width="80" align="right"><template #default="{ row }">{{ row.deviation_pct }}%</template></el-table-column>
</el-table>
</el-card>
<el-empty v-else-if="!scenarioLoading" description="输入三个情景后点击「模拟」" style="padding:40px 0;" />
</el-col>
</el-row>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { predictApi } from '../api/index'
const activeTab = ref('cvp')
function formatMoney(v: any) {
if (v === null || v === undefined) return '--'
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function safetyColor(r: number) { return r > 25 ? 'green' : r > 10 ? 'orange' : 'red' }
// ── CVP ──
const cvpLoading = ref(false)
const cvpForm = ref({ unit_price: 100, unit_variable_cost: 60, fixed_cost: 50000, actual_volume: 2000, target_profit: 20000 })
const cvpResult = ref<any>({})
async function doCvp() {
cvpLoading.value = true
try {
cvpResult.value = await predictApi.cvp(cvpForm.value) as any
} catch { ElMessage.error('CVP计算失败') }
cvpLoading.value = false
}
// ── 投资决策 ──
const invLoading = ref(false)
const invForm = ref({ initial_investment: 100000, discount_rate: 10, cash_flows: [30000, 40000, 50000, 40000] })
const invResult = ref<any>({})
function fillInvExample() {
invForm.value = { initial_investment: 100000, discount_rate: 10, cash_flows: [30000, 40000, 50000, 40000] }
}
async function doInvestment() {
invLoading.value = true
try {
invResult.value = await predictApi.investment(invForm.value) as any
} catch { ElMessage.error('投资决策计算失败') }
invLoading.value = false
}
// ── 敏感性 ──
const sensLoading = ref(false)
const sensForm = ref({ base_revenue: 1000000, base_cost: 700000, step: 5 })
const sensResult = ref<any>({})
async function doSensitivity() {
sensLoading.value = true
try {
sensResult.value = await predictApi.sensitivity(sensForm.value) as any
} catch { ElMessage.error('敏感性分析失败') }
sensLoading.value = false
}
// ── 情景 ──
const scenarioLoading = ref(false)
const scenarioForm = ref({
optimistic: { revenue: 130, cost: 90 },
base: { revenue: 100, cost: 100 },
pessimistic: { revenue: 80, cost: 110 },
})
const scenarioResult = ref<any>({})
function fillScenarioExample() {
scenarioForm.value = {
optimistic: { revenue: 130, cost: 90 },
base: { revenue: 100, cost: 100 },
pessimistic: { revenue: 80, cost: 110 },
}
}
async function doScenario() {
scenarioLoading.value = true
try {
scenarioResult.value = await predictApi.scenario(scenarioForm.value) as any
} catch { ElMessage.error('情景模拟失败') }
scenarioLoading.value = false
}
</script>
<style scoped>
.result-card { background: #f5f7fa; border-radius: 8px; padding: 16px; text-align: center; }
.r-label { font-size: 12px; color: #909399; margin-bottom: 6px; }
.r-value { font-size: 22px; font-weight: bold; color: #303133; }
.r-value.green { color: #67c23a; }
.r-value.blue { color: #409eff; }
.r-value.orange { color: #e6a23c; }
.r-value.red { color: #f56c6c; }
.r-value.purple { color: #8b5cf6; }
</style>