feat: CMA P1+P2 Round2 — 风险矩阵+COSO+预算方法

This commit is contained in:
Hermes CI Fix
2026-07-16 17:09:32 +08:00
parent 82919b1616
commit 9eded3e4fe
7 changed files with 750 additions and 5 deletions
+160
View File
@@ -143,3 +143,163 @@ def delete_plan(plan_id: int, db: Session = Depends(get_db)):
db.delete(plan)
db.commit()
return {"message": "已删除"}
# ──────────────────────────────────────────────
# 功能5: COSO内控自检表 (CMA P1 - COSO五要素)
# ──────────────────────────────────────────────
COSO_CHECKLIST_DATA = {
"hanke": {
"entity_name": "陕西酣客(白酒经销)",
"total_score": 46,
"max_score": 100,
"risk_level": "high", # high / medium / low
"risk_label": "高风险",
"elements": [
{
"id": "control_environment",
"name": "控制环境",
"name_en": "Control Environment",
"score": 60,
"max_score": 100,
"status": "medium",
"items": [
{"id": "ce_01", "text": "管理层重视内控", "passed": True, "detail": "✅ 任总亲自跟"},
{"id": "ce_02", "text": "职责分离", "passed": True, "detail": "✅ 业务≠财务"},
{"id": "ce_03", "text": "授权审批制度", "passed": False, "detail": "❌ 渠补无标准审批流程"},
{"id": "ce_04", "text": "人事政策", "passed": False, "detail": "❌ 无定期轮岗"},
],
},
{
"id": "risk_assessment",
"name": "风险评估",
"name_en": "Risk Assessment",
"score": 40,
"max_score": 100,
"status": "low",
"items": [
{"id": "ra_01", "text": "风险识别机制", "passed": False, "detail": "❌ 没有系统风险清单"},
{"id": "ra_02", "text": "风险应对预案", "passed": False, "detail": "❌ 现金断流无预案"},
],
},
{
"id": "control_activities",
"name": "控制活动",
"name_en": "Control Activities",
"score": 30,
"max_score": 100,
"status": "low",
"items": [
{"id": "ca_01", "text": "渠补审批流程", "passed": False, "detail": "❌ 口头谈,无记录"},
{"id": "ca_02", "text": "费用审批流程", "passed": False, "detail": "❌ 超预算无拦截"},
{"id": "ca_03", "text": "实物返利入账流程", "passed": False, "detail": "❌ 纯P&L不进系统"},
],
},
{
"id": "information_communication",
"name": "信息与沟通",
"name_en": "Information & Communication",
"score": 70,
"max_score": 100,
"status": "medium",
"items": [
{"id": "ic_01", "text": "财务报告及时性", "passed": True, "detail": "✅ 月度出表"},
{"id": "ic_02", "text": "系统数据互通", "passed": False, "detail": "❌ 进销存≠财务账"},
],
},
{
"id": "monitoring",
"name": "监控",
"name_en": "Monitoring",
"score": 30,
"max_score": 100,
"status": "low",
"items": [
{"id": "mo_01", "text": "定期内审", "passed": False, "detail": "❌ 无"},
{"id": "mo_02", "text": "异常追踪机制", "passed": False, "detail": "❌ 发现异常无跟踪"},
],
},
],
},
"bohai": {
"entity_name": "陕西博海科技(IT服务)",
"total_score": 55,
"max_score": 100,
"risk_level": "medium",
"risk_label": "中风险",
"elements": [
{
"id": "control_environment",
"name": "控制环境",
"name_en": "Control Environment",
"score": 70,
"max_score": 100,
"status": "medium",
"items": [
{"id": "ce_01", "text": "管理层重视内控", "passed": True, "detail": "✅ 老板直接管"},
{"id": "ce_02", "text": "职责分离", "passed": True, "detail": "✅ 业务≠财务≠技术"},
{"id": "ce_03", "text": "授权审批制度", "passed": False, "detail": "❌ 部分项目无预算审批"},
],
},
{
"id": "risk_assessment",
"name": "风险评估",
"name_en": "Risk Assessment",
"score": 50,
"max_score": 100,
"status": "low",
"items": [
{"id": "ra_01", "text": "风险识别机制", "passed": False, "detail": "❌ 无正式风险清单"},
{"id": "ra_02", "text": "风险应对预案", "passed": True, "detail": "✅ 重点项目有预案"},
],
},
{
"id": "control_activities",
"name": "控制活动",
"name_en": "Control Activities",
"score": 50,
"max_score": 100,
"status": "low",
"items": [
{"id": "ca_01", "text": "采购审批流程", "passed": True, "detail": "✅ 有标准流程"},
{"id": "ca_02", "text": "项目交付流程", "passed": False, "detail": "❌ 验收流程不完善"},
],
},
{
"id": "information_communication",
"name": "信息与沟通",
"name_en": "Information & Communication",
"score": 60,
"max_score": 100,
"status": "medium",
"items": [
{"id": "ic_01", "text": "财务报告及时性", "passed": True, "detail": "✅ 月度出表"},
{"id": "ic_02", "text": "项目沟通机制", "passed": False, "detail": "❌ 跨部门信息滞后"},
],
},
{
"id": "monitoring",
"name": "监控",
"name_en": "Monitoring",
"score": 40,
"max_score": 100,
"status": "low",
"items": [
{"id": "mo_01", "text": "定期内审", "passed": False, "detail": "❌ 无"},
{"id": "mo_02", "text": "异常追踪机制", "passed": True, "detail": "✅ 项目延期有跟踪"},
],
},
],
},
}
@router.get("/coso-checklist")
def get_coso_checklist(entity: str = "hanke"):
"""COSO内控自检表 - CMA P1 COSO五要素"""
data = COSO_CHECKLIST_DATA.get(entity)
if not data:
data = COSO_CHECKLIST_DATA["hanke"]
data["entity_name"] = f"未知实体({entity}),默认返回酣客数据"
return data
+216
View File
@@ -4,6 +4,9 @@ from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import KPIAlert, OperationLog
import logging
logger = logging.getLogger("cma.alerts")
router = APIRouter(prefix="/api/cma/alerts", tags=["预警"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
@@ -28,3 +31,216 @@ def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
from datetime import datetime; alert.resolved_at = datetime.now()
db.commit()
return {"message": "已处理", "assignee": alert.assignee}
# ──────────────────────────────────────────────
# 功能4: 风险矩阵热力图 (CMA P2 - ERM框架、风险识别四象限)
# ──────────────────────────────────────────────
RISK_MATRIX_DATA = {
"hanke": {
"entity_name": "陕西酣客(白酒经销)",
"quadrants": [
{
"impact": "high",
"probability": "high",
"label": "高影响×高概率",
"risks": [
{
"id": "risk_001",
"name": "流动性风险",
"impact_label": "",
"probability_label": "",
"detail": "现金2.2万 vs 短债350万 → 断流风险",
"impact_value": 90,
"probability_value": 85,
"type": "red",
"measures": ["催收大额应收", "协商短期借款续贷"],
"responsible": "任富海",
"deadline": "7月底",
},
{
"id": "risk_002",
"name": "合规风险",
"impact_label": "",
"probability_label": "",
"detail": "欠税426万 · 折旧违规",
"impact_value": 95,
"probability_value": 80,
"type": "red",
"measures": ["补缴欠税计划", "重新梳理折旧政策"],
"responsible": "任富海",
"deadline": "8月底",
},
],
},
{
"impact": "high",
"probability": "medium",
"label": "高影响×中概率",
"risks": [
{
"id": "risk_003",
"name": "政策风险",
"impact_label": "",
"probability_label": "",
"detail": "白酒消费税调整可能导致成本上升15-20%",
"impact_value": 85,
"probability_value": 50,
"type": "orange",
"measures": ["关注政策动向", "预留税务缓冲资金"],
"responsible": "财务部",
"deadline": "持续关注",
},
],
},
{
"impact": "medium",
"probability": "high",
"label": "中影响×高概率",
"risks": [
{
"id": "risk_004",
"name": "运营风险",
"impact_label": "",
"probability_label": "",
"detail": "Model C 成本模型未落地,成本核算偏差",
"impact_value": 60,
"probability_value": 80,
"type": "yellow",
"measures": ["推动Model C落地", "建立成本标准化流程"],
"responsible": "财务部",
"deadline": "8月中",
},
],
},
{
"impact": "medium",
"probability": "medium",
"label": "中影响×中概率",
"risks": [
{
"id": "risk_005",
"name": "战略风险",
"impact_label": "",
"probability_label": "",
"detail": "酒类零交易,新业务方向不确定",
"impact_value": 55,
"probability_value": 55,
"type": "yellow",
"measures": ["制定新业务评估框架", "定期战略复盘"],
"responsible": "管理层",
"deadline": "9月底",
},
],
},
{
"impact": "low",
"probability": "low",
"label": "低影响×低概率",
"risks": [
{
"id": "risk_006",
"name": "市场风险",
"impact_label": "",
"probability_label": "",
"detail": "行业需求波动,但酣客已基本退出市场",
"impact_value": 25,
"probability_value": 20,
"type": "green",
"measures": ["定期监控行业数据"],
"responsible": "业务部",
"deadline": "每季度",
},
{
"id": "risk_007",
"name": "人员风险",
"impact_label": "",
"probability_label": "",
"detail": "核心团队稳定,短期内无流失风险",
"impact_value": 20,
"probability_value": 15,
"type": "green",
"measures": ["保持团队激励", "关键岗位备份"],
"responsible": "人事部",
"deadline": "持续",
},
],
},
],
},
"bohai": {
"entity_name": "陕西博海科技(IT服务)",
"quadrants": [
{
"impact": "high",
"probability": "medium",
"label": "高影响×中概率",
"risks": [
{
"id": "risk_b_001",
"name": "现金流风险",
"impact_label": "",
"probability_label": "",
"detail": "应收账款账期延长,现金流紧张",
"impact_value": 85,
"probability_value": 55,
"type": "orange",
"measures": ["加快应收催收", "建立信用管理制度"],
"responsible": "任富海",
"deadline": "7月底",
},
],
},
{
"impact": "medium",
"probability": "high",
"label": "中影响×高概率",
"risks": [
{
"id": "risk_b_002",
"name": "项目交付风险",
"impact_label": "",
"probability_label": "",
"detail": "多个项目并行,交付压力大",
"impact_value": 65,
"probability_value": 75,
"type": "yellow",
"measures": ["优化项目排期", "增加外包资源"],
"responsible": "项目部",
"deadline": "持续",
},
],
},
{
"impact": "low",
"probability": "medium",
"label": "低影响×中概率",
"risks": [
{
"id": "risk_b_003",
"name": "技术迭代风险",
"impact_label": "",
"probability_label": "",
"detail": "新技术跟踪不及时,可能落后",
"impact_value": 30,
"probability_value": 45,
"type": "green",
"measures": ["定期技术培训", "技术栈评估"],
"responsible": "技术部",
"deadline": "每季度",
},
],
},
],
},
}
@router.get("/risk-matrix")
def get_risk_matrix(entity: str = Query("hanke", description="hanke/bohai")):
"""风险矩阵热力图数据 - CMA P2 ERM四象限"""
data = RISK_MATRIX_DATA.get(entity)
if not data:
data = RISK_MATRIX_DATA["hanke"]
data["entity_name"] = f"未知实体({entity}),默认返回酣客数据"
return data
+100
View File
@@ -334,3 +334,103 @@ def get_deviation_report(
"summary": summary,
"items": items,
}
# ──────────────────────────────────────────────
# 功能6: 预算方法三选一向导 (CMA P1 - 增量/零基/弹性)
# ──────────────────────────────────────────────
@router.post("/method-comparison")
def budget_method_comparison(data: dict):
"""
预算方法三选一对比计算
接收: { entity: "hanke", last_month_budget: 91, current_revenue: 122, ... }
返回三种方法的计算结果
"""
entity = data.get("entity", "hanke")
last_month_budget = data.get("last_month_budget", 91) # 上月预算(万)
current_revenue = data.get("current_revenue", 122) # 当前收入(万)
fixed_costs = data.get("fixed_costs", {
"rent": 15, # 房租(万)
"labor": 40, # 人工(万)
"entertainment": 16, # 招待费(万)
"misc": 12, # 杂项(万)
})
variable_cost_rate = data.get("variable_cost_rate", 0.4862) # 变动成本率
# 1. 增量预算: 基于上月统一调整
increment_rate = data.get("increment_rate", 0.05) # 5%增幅
incremental_result = round(last_month_budget * (1 + increment_rate), 1)
incremental_detail = f"上月{last_month_budget}× (1+{increment_rate*100:.0f}%) = {incremental_result}"
# 2. 零基预算: 每项从零论证
zbb_entertainment = round(fixed_costs.get("entertainment", 16) / 2, 1) # 砍半
zbb_misc = round(fixed_costs.get("misc", 12) * 0.7, 1) # 压缩30%
zbb_total = round(
fixed_costs.get("rent", 15)
+ fixed_costs.get("labor", 40)
+ zbb_entertainment
+ zbb_misc,
1,
)
zbb_savings = round(last_month_budget - zbb_total, 1)
zbb_detail = (
f"房租{fixed_costs.get('rent', 15)}万(固定)+人工{fixed_costs.get('labor', 40)}万(砍不掉)"
f"+招待{zbb_entertainment}万(砍半)+杂项{zbb_misc}万(压缩)"
f"={zbb_total}万 ← 省{zbb_savings}"
)
# 3. 弹性预算: 根据收入水平动态调整
flexible_fixed = round(fixed_costs.get("rent", 15) + fixed_costs.get("labor", 40) * 0.5, 1)
flexible_variable = round(current_revenue * variable_cost_rate * 0.4, 1)
flexible_total = round(flexible_fixed + flexible_variable, 1)
flexible_variance = round(last_month_budget - flexible_total, 1)
flex_detail = (
f"收入{current_revenue}万 → 对应费用预算 = {flexible_total}"
f"(固定部分{flexible_fixed}万+变动部分{flexible_variable}万)"
f",实际{last_month_budget}万 → 差异{flexible_variance}万 → {'效率问题' if flexible_variance > 0 else '节省'}"
)
# 推荐方法
recommended = "zero_based"
return {
"entity": entity,
"entity_name": "陕西酣客(白酒经销)" if entity == "hanke" else "陕西博海科技(IT服务)",
"methods": [
{
"id": "incremental",
"name": "增量预算",
"name_en": "Incremental Budgeting",
"result_value": incremental_result,
"detail": incremental_detail,
"pros": "简单快速",
"cons": "浪费持续",
"is_recommended": False,
},
{
"id": "zero_based",
"name": "零基预算",
"name_en": "Zero-Based Budgeting (ZBB)",
"result_value": zbb_total,
"savings": zbb_savings,
"detail": zbb_detail,
"pros": "最合理",
"cons": "耗时",
"is_recommended": True,
},
{
"id": "flexible",
"name": "弹性预算",
"name_en": "Flexible Budgeting",
"result_value": flexible_total,
"variance": flexible_variance,
"detail": flex_detail,
"pros": "动态响应",
"cons": "需要详细分类",
"is_recommended": False,
},
],
"recommended": recommended,
"recommended_name": "零基预算",
}
+3
View File
@@ -84,6 +84,7 @@ export const dataApi = {
export const alertApi = {
list: (params?: any) => api.get('/alerts', { params }),
resolve: (id: number, data: any) => api.post(`/alerts/${id}/resolve`, data),
riskMatrix: (params?: any) => api.get('/alerts/risk-matrix', { params }),
}
export const alertRulesApi = {
@@ -120,6 +121,7 @@ export const actionPlanApi = {
create: (data: any) => api.post('/action-plans', data),
update: (id: number, data: any) => api.put(`/action-plans/${id}`, data),
delete: (id: number) => api.delete(`/action-plans/${id}`),
cosoChecklist: (params?: any) => api.get('/action-plans/coso-checklist', { params }),
}
export const budgetApi = {
@@ -129,6 +131,7 @@ export const budgetApi = {
delete: (id: number) => api.delete(`/budget/plans/${id}`),
autoDecompose: (data: any) => api.post('/budget/auto-decompose', data),
deviationReport: (params?: any) => api.get('/budget/deviation-report', { params }),
methodComparison: (data: any) => api.post('/budget/method-comparison', data),
}
export const costApi = {
+94 -4
View File
@@ -1,14 +1,21 @@
<template>
<div style="padding:0 16px;">
<!-- 顶部标题+操作 -->
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h3 style="margin:0;">行动方案库</h3>
<div style="display:flex;gap:8px;align-items:center;">
<!-- 顶部tabs -->
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<div>
<el-radio-group v-model="activeTab" size="small">
<el-radio-button value="plans">行动方案</el-radio-button>
<el-radio-button value="coso">COSO内控自检</el-radio-button>
</el-radio-group>
</div>
<div v-if="activeTab === 'plans'" style="display:flex;gap:8px;align-items:center;">
<el-input v-model="searchKeyword" placeholder="搜索标题/KPI名称/编码" clearable style="width:240px;" @clear="loadData" @keyup.enter="loadData" />
<el-button type="primary" @click="openCreate">+ 新建行动方案</el-button>
</div>
</div>
<!-- ===== 行动方案列表 ===== -->
<template v-if="activeTab === 'plans'">
<!-- 状态统计卡片 -->
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;">
<div v-for="card in statCards" :key="card.key"
@@ -249,7 +256,66 @@
<el-button @click="showDetail = false">关闭</el-button>
</template>
</MyDialog>
</template>
</div>
<!-- ===== COSO内控自检 ===== -->
<template v-if="activeTab === 'coso'">
<div>
<div style="margin-bottom:12px;display:flex;gap:8px;align-items:center;">
<span style="font-weight:500;">COSO内控自检表 CMA P1内部控制五要素</span>
<el-select v-model="cosoEntity" size="small" style="width:160px;" @change="loadCosoChecklist">
<el-option label="陕西酣客(白酒经销)" value="hanke" />
<el-option label="陕西博海科技(IT服务)" value="bohai" />
</el-select>
<el-button size="small" @click="loadCosoChecklist" :loading="cosoLoading">刷新</el-button>
<el-button v-if="cosoData" type="primary" size="small" style="margin-left:12px;">生成改善行动</el-button>
</div>
<div v-if="cosoData">
<!-- 总体评分 -->
<div style="margin-bottom:16px;padding:16px 20px;background:#f5f7fa;border-radius:8px;display:flex;align-items:center;gap:20px;">
<div>
<div style="font-size:12px;color:#909399;">总体内控评分</div>
<div style="font-size:36px;font-weight:700;margin-top:2px;"
:style="{ color: cosoData.total_score < 50 ? '#f56c6c' : cosoData.total_score < 70 ? '#e6a23c' : '#67c23a' }">
{{ cosoData.total_score }}/{{ cosoData.max_score }}
</div>
</div>
<div>
<el-tag :type="cosoData.risk_level === 'high' ? 'danger' : cosoData.risk_level === 'medium' ? 'warning' : 'success'" size="large" effect="dark" style="font-size:16px;padding:6px 16px;">
{{ cosoData.risk_label }}
</el-tag>
</div>
<div style="flex:1;display:flex;gap:12px;flex-wrap:wrap;">
<div v-for="el in cosoData.elements" :key="el.id" style="text-align:center;min-width:80px;">
<div style="font-size:20px;font-weight:600;" :style="{ color: el.score >= 60 ? '#67c23a' : el.score >= 40 ? '#e6a23c' : '#f56c6c' }">{{ el.score }}</div>
<div style="font-size:11px;color:#999;">{{ el.name }}</div>
</div>
</div>
</div>
<!-- 五要素折叠面板 -->
<div v-for="el in cosoData.elements" :key="el.id" style="margin-bottom:8px;border:1px solid #ebeef5;border-radius:8px;overflow:hidden;">
<div class="coso-element-header" @click="toggleCosoElement(el.id)" style="cursor:pointer;display:flex;align-items:center;gap:12px;padding:12px 16px;background:#fafafa;border-left:4px solid"
:style="{ borderLeftColor: el.score >= 60 ? '#67c23a' : el.score >= 40 ? '#e6a23c' : '#f56c6c' }">
<span style="flex:1;font-weight:500;">{{ el.name }}{{ el.name_en }}</span>
<el-progress :percentage="el.score" :stroke-width="14" :status="el.score >= 60 ? 'success' : el.score >= 40 ? 'warning' : 'exception'" style="width:160px;" />
<span :class="{ 'is-open': cosoExpanded[el.id] }" style="color:#999;transition:transform .2s;"></span>
</div>
<div v-show="cosoExpanded[el.id]" style="padding:8px 16px 12px;">
<div v-for="item in el.items" :key="item.id" style="display:flex;align-items:center;gap:10px;padding:6px 0;border-bottom:1px solid #f5f5f5;">
<span :style="{color: item.passed ? '#67c23a' : '#f56c6c', fontSize: '16px', fontWeight: 'bold'}">{{ item.passed ? '✅' : '❌' }}</span>
<span style="flex:1;font-size:14px;">{{ item.text }}</span>
<span style="font-size:12px;color:#666;">{{ item.detail }}</span>
</div>
</div>
</div>
</div>
<div v-else-if="!cosoLoading" style="padding:60px 0;text-align:center;color:#999;">暂无COSO自检数据</div>
</div>
</template>
</template>
<script setup lang="ts">
@@ -260,6 +326,7 @@ import { actionPlanApi, kpiApi, userApi } from '../api/index'
import MyDialog from '../components/MyDialog.vue'
const route = useRoute()
const activeTab = ref((route.query?.tab as string) || 'plans')
//
const list = ref<any[]>([])
@@ -496,6 +563,29 @@ function statusLabel(status: string) {
return map[status] || status
}
// COSO
const cosoEntity = ref('hanke')
const cosoData = ref<any>(null)
const cosoLoading = ref(false)
const cosoExpanded = ref<Record<string, boolean>>({})
function toggleCosoElement(id: string) {
cosoExpanded.value[id] = !cosoExpanded.value[id]
}
async function loadCosoChecklist() {
cosoLoading.value = true
try {
const r: any = await actionPlanApi.cosoChecklist({ entity: cosoEntity.value })
cosoData.value = r
//
if (r?.elements?.length > 0) {
cosoExpanded.value[r.elements[0].id] = true
}
} catch { ElMessage.error('加载COSO自检数据失败') }
cosoLoading.value = false
}
//
onMounted(() => {
loadStats()
+101 -1
View File
@@ -13,6 +13,7 @@
<div style="margin:12px 0;display:flex;gap:12px;align-items:center;">
<el-radio-group v-model="activeTab" size="small">
<el-radio-button value="alerts">预警列表</el-radio-button>
<el-radio-button value="riskMatrix">风险矩阵</el-radio-button>
<el-radio-button value="rules">预警规则配置</el-radio-button>
<el-radio-button value="plans">改善行动</el-radio-button>
</el-radio-group>
@@ -69,6 +70,68 @@
</el-table>
</template>
<!-- ===== 风险矩阵热力图 ===== -->
<template v-if="activeTab === 'riskMatrix'">
<div style="margin-bottom:12px;display:flex;gap:8px;align-items:center;">
<span style="font-weight:500;">风险矩阵 CMA P2 ERM框架</span>
<el-select v-model="riskEntity" size="small" style="width:160px;" @change="loadRiskMatrix">
<el-option label="陕西酣客(白酒经销)" value="hanke" />
<el-option label="陕西博海科技(IT服务)" value="bohai" />
</el-select>
<el-button size="small" @click="loadRiskMatrix" :loading="riskLoading">刷新</el-button>
</div>
<div v-if="riskData" style="display:flex;gap:20px;flex-wrap:wrap;">
<!-- 四象限矩阵图 -->
<div style="flex:1;min-width:500px;">
<div style="font-size:12px;color:#909399;margin-bottom:4px;text-align:right;">概率 </div>
<div style="display:grid;grid-template-columns:50px 1fr 1fr 1fr;gap:4px;">
<div style="font-size:11px;color:#909399;display:flex;align-items:center;justify-content:center;">影响 </div>
<!-- Probability labels -->
<div style="font-size:11px;color:#909399;text-align:center;"></div>
<div style="font-size:11px;color:#909399;text-align:center;"></div>
<div style="font-size:11px;color:#909399;text-align:center;"></div>
<!-- 高影响 row -->
<div style="font-size:11px;color:#909399;display:flex;align-items:center;justify-content:center;"></div>
<risk-cell v-for="q in highImpactQuads" :key="q.label" :quadrant="q" @click="selectRisk(q)" />
<!-- 中影响 row -->
<div style="font-size:11px;color:#909399;display:flex;align-items:center;justify-content:center;"></div>
<risk-cell v-for="q in medImpactQuads" :key="q.label" :quadrant="q" @click="selectRisk(q)" />
<!-- 低影响 row -->
<div style="font-size:11px;color:#909399;display:flex;align-items:center;justify-content:center;"></div>
<risk-cell v-for="q in lowImpactQuads" :key="q.label" :quadrant="q" @click="selectRisk(q)" />
</div>
</div>
<!-- 风险详情面板 -->
<div style="width:360px;" v-if="selectedRisk">
<div style="font-weight:600;margin-bottom:8px;font-size:15px;">
<span :style="{color: riskTypeColor(selectedRisk.type)}"></span>
{{ selectedRisk.name }}
</div>
<el-card shadow="never" style="margin-bottom:8px;">
<div style="margin-bottom:6px;"><span style="color:#909399;font-size:12px;">影响</span>{{ selectedRisk.impact_label }}{{ selectedRisk.impact_value }}</div>
<div style="margin-bottom:6px;"><span style="color:#909399;font-size:12px;">概率</span>{{ selectedRisk.probability_label }}{{ selectedRisk.probability_value }}</div>
<div style="margin-bottom:6px;"><span style="color:#909399;font-size:12px;">详情</span>{{ selectedRisk.detail }}</div>
<div style="margin-bottom:6px;"><span style="color:#909399;font-size:12px;">应对措施</span>
<ul style="margin:4px 0 0 16px;padding:0;">
<li v-for="m in selectedRisk.measures" :key="m" style="font-size:13px;">{{ m }}</li>
</ul>
</div>
<div style="margin-bottom:6px;"><span style="color:#909399;font-size:12px;">责任人</span>{{ selectedRisk.responsible }}</div>
<div><span style="color:#909399;font-size:12px;">截止日</span>{{ selectedRisk.deadline }}</div>
</el-card>
</div>
<div v-else style="width:360px;display:flex;align-items:center;justify-content:center;color:#ccc;font-size:14px;">
点击风险卡片查看详情
</div>
</div>
<div v-else-if="!riskLoading" style="padding:40px 0;text-align:center;color:#999;">暂无风险数据</div>
</template>
<!-- ===== 预警规则配置 ===== -->
<template v-if="activeTab === 'rules'">
<div style="margin-bottom:12px;display:flex;gap:8px;">
@@ -265,10 +328,11 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { alertApi, userApi, actionPlanApi, kpiApi, alertRulesApi } from '../api/index'
import RiskCell from '../components/charts/RiskCell.vue'
const route = useRoute()
const activeTab = ref((route.meta?.tab as string) || 'alerts')
@@ -567,6 +631,35 @@ async function loadKpis() {
try { const r: any = await kpiApi.list(); kpiOptions.value = r.data || [] } catch (e) {}
}
//
const riskEntity = ref('hanke')
const riskData = ref<any>(null)
const riskLoading = ref(false)
const selectedRisk = ref<any>(null)
const highImpactQuads = computed(() => riskData.value?.quadrants?.filter((q: any) => q.impact === 'high') || [])
const medImpactQuads = computed(() => riskData.value?.quadrants?.filter((q: any) => q.impact === 'medium') || [])
const lowImpactQuads = computed(() => riskData.value?.quadrants?.filter((q: any) => q.impact === 'low') || [])
function riskTypeColor(type: string): string {
return { red: '#f56c6c', orange: '#e6a23c', yellow: '#409eff', green: '#67c23a' }[type] || '#999'
}
function selectRisk(q: any) {
// If quad has only one risk, select it directly; otherwise pick first
selectedRisk.value = q.risks?.[0] || null
}
async function loadRiskMatrix() {
riskLoading.value = true
selectedRisk.value = null
try {
const r: any = await alertApi.riskMatrix({ entity: riskEntity.value })
riskData.value = r
} catch { ElMessage.error('加载风险矩阵失败') }
riskLoading.value = false
}
onMounted(() => {
loadAlerts()
loadRules()
@@ -579,4 +672,11 @@ onMounted(() => {
.page-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
.page-header h3 { margin:0; }
.header-actions { display:flex; gap:8px; }
.risk-cell { min-height:80px; border-radius:6px; padding:6px; background:#fafafa; border:1px solid #e8e8e8; }
.risk-item { padding:4px 6px; margin-bottom:3px; border-radius:4px; font-size:12px; line-height:1.4; }
.risk-item:last-child { margin-bottom:0; }
.risk-dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:4px; }
.risk-name { font-weight:500; }
.risk-value { display:block; font-size:11px; color:#666; margin-top:1px; padding-left:12px; }
.risk-empty { color:#ddd; text-align:center; line-height:80px; font-size:13px; }
</style>
+76
View File
@@ -176,6 +176,52 @@
<el-table-column label="预警" width="80"><template #default="{ row }"><el-tag v-if="row.alert_level === 'red'" size="small" type="danger">严重</el-tag><el-tag v-else-if="row.alert_level === 'yellow'" size="small" type="warning">关注</el-tag><el-tag v-else size="small" type="success">正常</el-tag></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="编制方法" name="method">
<div>
<p style="font-size:14px;color:#606266;margin-bottom:16px;">选择预算编制方法 CMA P1 预算编制增量预算 vs 零基预算 vs 弹性预算</p>
<!-- 方法选择卡片 -->
<div style="display:flex;gap:16px;margin-bottom:20px;flex-wrap:wrap;">
<div v-for="m in budgetMethods" :key="m.id"
:class="['method-card', { selected: selectedMethod === m.id, recommended: m.is_recommended }]"
@click="selectedMethod = m.id; loadMethodDetail(m.id)"
style="flex:1;min-width:220px;border:2px solid #e8e8e8;border-radius:10px;padding:16px;cursor:pointer;transition:all .2s;position:relative;">
<div v-if="m.is_recommended" style="position:absolute;top:-10px;right:12px;background:#e6a23c;color:#fff;font-size:11px;padding:2px 10px;border-radius:8px;">推荐</div>
<div style="font-size:12px;color:#999;">{{ m.name_en }}</div>
<div style="font-size:18px;font-weight:600;margin:6px 0;">{{ m.name }}</div>
<div style="font-size:13px;color:#606266;margin-bottom:8px;">{{ m.pros }} · {{ m.cons }}</div>
<div v-if="m.result_value != null" style="font-size:24px;font-weight:700;color:#409eff;">{{ formatNumber(m.result_value) }}<span style="font-size:14px;font-weight:400;color:#999;"> </span></div>
<div v-if="m.savings" style="font-size:12px;color:#67c23a;">节省 {{ formatNumber(m.savings) }} </div>
<div v-if="m.variance" style="font-size:12px;" :style="{ color: m.variance > 0 ? '#f56c6c' : '#67c23a' }">差异 {{ m.variance > 0 ? '+' : '' }}{{ formatNumber(m.variance) }} </div>
</div>
</div>
<!-- 方法详情 -->
<el-card v-if="selectedMethodDetail" shadow="never" style="margin-bottom:12px;">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span><strong>{{ selectedMethodDetail.name }}</strong> <span style="font-size:12px;color:#999;">{{ selectedMethodDetail.name_en }}</span></span>
<el-tag v-if="selectedMethodDetail.is_recommended" type="warning" effect="dark">推荐</el-tag>
</div>
</template>
<p style="margin:0 0 8px;color:#606266;">{{ selectedMethodDetail.detail }}</p>
<div style="display:flex;gap:20px;flex-wrap:wrap;">
<div><span style="color:#909399;">优点</span>{{ selectedMethodDetail.pros }}</div>
<div><span style="color:#909399;">缺点</span>{{ selectedMethodDetail.cons }}</div>
</div>
<div v-if="selectedMethodDetail.result_value != null" style="margin-top:12px;padding:10px 14px;background:#f0f9ff;border-radius:6px;display:flex;align-items:center;gap:12px;">
<span style="color:#909399;">预算结果</span>
<span style="font-size:22px;font-weight:700;color:#409eff;">{{ formatNumber(selectedMethodDetail.result_value) }} </span>
</div>
</el-card>
<div style="display:flex;gap:8px;margin-top:16px;">
<el-button v-if="selectedMethod" type="primary" @click="confirmMethod">确认选择{{ selectedMethodName }}</el-button>
<el-button @click="refreshMethodComparison">刷新计算</el-button>
</div>
</div>
</el-tab-pane>
</el-tabs>
<MyDialog v-model="showAddBudget" title="新增预算" :width="500">
@@ -449,11 +495,41 @@ async function doDecompose() {
decomposing.value = false
}
//
const selectedMethod = ref('zero_based')
const budgetMethods = ref<any[]>([])
const selectedMethodDetail = ref<any>(null)
const selectedMethodName = computed(() => {
return budgetMethods.value.find(m => m.id === selectedMethod.value)?.name || ''
})
function loadMethodDetail(id: string) {
selectedMethodDetail.value = budgetMethods.value.find(m => m.id === id) || null
}
async function refreshMethodComparison() {
try {
const r: any = await budgetApi.methodComparison({ entity: 'hanke' })
budgetMethods.value = r.methods || []
selectedMethod.value = r.recommended || 'zero_based'
loadMethodDetail(selectedMethod.value)
} catch { ElMessage.error('加载预算方法对比失败') }
}
function confirmMethod() {
const m = selectedMethodDetail.value
if (m) {
ElMessage.success(`已选择「${m.name}」,预算结果:${m.result_value}`)
}
}
watch(activeTab, (tab) => {
if (tab === 'decompose') loadBudget()
else if (tab === 'strategy') loadStrategyBudget()
else if (tab === 'versions') loadVersions()
else if (tab === 'execution') loadExecutionReport()
else if (tab === 'method') refreshMethodComparison()
})
onMounted(async () => {
await loadPublishedMaps()