feat: CMA P1+P2 Round2 — 风险矩阵+COSO+预算方法
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user