Files
cma-management/frontend/src/views/MapReview.vue
T
Hermes CI Fix 957dacd248 fix: 恢复5个被CI覆盖的前端文件+成本数据+预警定时器
- 从.bak恢复: BudgetManagement(72%代码丢失)/KPIList/DeviationDashboard/MapReview/NodeEditDialog
- 注册4个缺失后端API模块到main.py
- 新增3个前端路由(管理报表/战略执行看板/杜邦分析)
- 修复改善行动路由指向ActionPlanLibrary(原指向AlertList)
- deploy.sh增加git pull步骤
- 运行成本种子数据: 29标准成本+31实际成本+10ABC+25分配
- 补充非财务KPI预算: 72条(客户/流程/学习维度)
- 配置预警cron: 每30分钟自动检查
2026-07-13 09:52:06 +08:00

352 lines
15 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="review-page">
<!-- 顶部 -->
<div class="review-header">
<div class="header-left">
<el-button text @click="$router.push('/maps')"> 返回战略地图</el-button>
<el-button text type="primary" @click="goCanvas" v-if="selectedMap">🗺 打开画布</el-button>
<h3>{{ mapTitle }} · 战略回顾</h3>
</div>
<div class="header-right">
<el-select v-model="selectedMap" placeholder="选择地图" style="width:200px;margin-right:8px;" @change="loadReview">
<el-option v-for="m in maps" :key="m.id" :label="m.title" :value="m.id" />
</el-select>
<el-radio-group v-model="period" size="small">
<el-radio-button value="month">本月</el-radio-button>
<el-radio-button value="quarter">本季</el-radio-button>
<el-radio-button value="year">本年</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 加载中 -->
<div v-if="loading" class="loading-wrap">
<el-skeleton :rows="6" animated />
</div>
<template v-if="!loading && summary">
<!-- 总体得分卡片 -->
<div class="summary-cards">
<div class="s-card overall">
<div class="s-card-top">
<span class="score">{{ summary.health_score }}%</span>
<span class="score-label">整体健康度</span>
</div>
<div class="s-card-btm">
<span class="stat-item green">🟢 {{ summary.green }}个达成</span>
<span class="stat-item yellow">🟡 {{ summary.yellow }}个预警</span>
<span class="stat-item red">🔴 {{ summary.red }}个异常</span>
</div>
</div>
</div>
<!-- 四维度矩阵 -->
<div class="dim-matrix">
<div v-for="dim in dimensions" :key="dim.key" class="dim-col" :style="{ borderLeft: '4px solid ' + dim.color }">
<div class="dim-head">
<span class="dim-icon">{{ dim.icon }}</span>
<span class="dim-name">{{ dim.name }}</span>
</div>
<div class="dim-body">
<div v-for="obj in dim.objectives" :key="obj.name"
class="obj-row"
:class="'obj-' + obj.level"
@click="showObjKpis(obj)">
<span class="obj-badge">{{ badgeIcon(obj.level) }}</span>
<div class="obj-info">
<span class="obj-name">{{ obj.name }}</span>
<span v-if="obj.has_data" class="obj-ratio">
<template v-for="k in obj.kpis.slice(0,2)" :key="k.kpi_code">
{{ fmtValue(k.actual_value, k.kpi_code) }}/{{ k.target_value ?? '-' }}
</template>
</span>
</div>
</div>
</div>
</div>
</div>
<!-- 🔴 需重点关注 -->
<div v-if="focusItems.length > 0" class="focus-section">
<h4 class="focus-title">🔴 需重点关注</h4>
<div v-for="item in focusItems" :key="item.name" class="focus-card" :class="'focus-' + item.level" @click="showObjKpis(item)">
<div class="focus-head">
<span class="focus-level">{{ item.level === 'red' ? '紧急' : '预警' }}</span>
<span class="focus-name">{{ item.name }}</span>
</div>
<div class="focus-kpis">
<div v-for="k in item.kpis" :key="k.kpi_code" class="fkpi-row">
<span class="fkpi-name">{{ k.kpi_name }}</span>
<span class="fkpi-val" :style="{ color: k.level === 'red' ? '#f56c6c' : '#e6a23c' }">
{{ fmtValue(k.actual_value, k.kpi_code) }} / {{ k.target_value ?? '-' }}
</span>
</div>
</div>
<!-- 关联的改善行动 -->
<div v-if="getPlansByKpi(item.kpis)" class="focus-plans">
<div v-for="p in getPlansByKpi(item.kpis)" :key="p.id" class="fplan-row" :class="'plan-' + planStatus(p)">
<span class="fplan-status">{{ planIcon(p) }}</span>
<span class="fplan-title">{{ p.title }}</span>
<span class="fplan-assignee">{{ p.assignee }}</span>
<span v-if="p.due_date && p.status !== 'completed'" class="fplan-due">{{ dueText(p) }}</span>
</div>
</div>
</div>
</div>
<!-- 📋 改善行动进度 -->
<div v-if="actionPlans.length > 0" class="plans-section">
<h4 class="plans-title">📋 改善行动进度</h4>
<div class="plans-stats">
<el-tag>{{ pendingCount }} 待处理</el-tag>
<el-tag type="warning">{{ inProgressCount }} 进行中</el-tag>
<el-tag type="success">{{ doneCount }} 已完成</el-tag>
<el-tag type="danger">{{ overdueCount }} 逾期</el-tag>
</div>
<el-table :data="actionPlans" size="small" style="width:100%" @row-click="(r:any)=>$router.push('/action-plans?kpi='+r.kpi_id)">
<el-table-column prop="title" label="行动标题" min-width="200" />
<el-table-column prop="assignee" label="负责人" width="120" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="planTagType(row)" size="small">{{ planLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="进度" width="120">
<template #default="{ row }">
<el-progress :percentage="row.progress || 0" :status="row.status==='completed'?'success':undefined" :stroke-width="12" />
</template>
</el-table-column>
<el-table-column label="截止" width="120">
<template #default="{ row }">
<span :style="{ color: isOverdue(row) ? '#f56c6c' : '#666' }">{{ row.due_date ? row.due_date.slice(0,10) : '-' }}</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
<!-- KPI详情弹出 -->
<el-dialog v-model="showKpiDialog" :title="selectedObjName" width="600">
<div v-for="k in selectedKpis" :key="k.kpi_code" class="kpi-detail-row" :class="'kpi-' + k.level">
<div class="kpi-detail-head">
<span class="kpi-detail-badge">{{ badgeIcon(k.level) }}</span>
<span class="kpi-detail-name">{{ k.kpi_name }} ({{ k.kpi_code }})</span>
</div>
<div class="kpi-detail-body">
<div class="kpi-detail-item">
<span class="label">实际值</span>
<span class="val">{{ fmtValue(k.actual_value, k.kpi_code) }}</span>
</div>
<div class="kpi-detail-item">
<span class="label">目标值</span>
<span class="val">{{ k.target_value ?? '-' }} {{ k.unit }}</span>
</div>
<div class="kpi-detail-item" v-if="k.actual_value != null && k.target_value">
<span class="label">达成率</span>
<span class="val" :style="{ color: k.level === 'red' ? '#f56c6c' : 'inherit' }">
{{ (k.actual_value / k.target_value * 100).toFixed(1) }}%
</span>
</div>
<el-button size="small" @click="$router.push('/kpis/'+k.kpi_id)" style="margin-top:8px;">查看KPI详情 →</el-button>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue"
import { useRoute, useRouter } from "vue-router"
import { ElMessage } from "element-plus"
import api from "../api/index"
const route = useRoute()
const $router = useRouter()
// ── 状态 ──
const maps = ref<any[]>([])
const selectedMap = ref<number | null>(null)
const mapTitle = ref("")
const loading = ref(false)
const period = ref("month")
const summary = ref<any>(null)
const dimensions = ref<any[]>([])
const focusItems = ref<any[]>([])
const actionPlans = ref<any[]>([])
const showKpiDialog = ref(false)
const selectedObjName = ref("")
const selectedKpis = ref<any[]>([])
// ── 计算属性 ──
const pendingCount = () => actionPlans.value.filter((p:any) => p.status === 'pending').length
const inProgressCount = () => actionPlans.value.filter((p:any) => p.status === 'in_progress').length
const doneCount = () => actionPlans.value.filter((p:any) => p.status === 'completed').length
const overdueCount = () => actionPlans.value.filter((p:any) => p.status !== 'completed' && isOverdue(p)).length
// ── 加载 ──
async function loadReview() {
if (!selectedMap.value) return
loading.value = true
try {
const r: any = await api.get(`/maps/${selectedMap.value}/review`)
mapTitle.value = r.title
summary.value = r.summary
dimensions.value = r.dimensions || []
focusItems.value = r.focus_items || []
actionPlans.value = r.action_plans || []
} catch (e: any) {
ElMessage.error("加载战略回顾数据失败")
} finally {
loading.value = false
}
}
// ── 工具函数 ──
function badgeIcon(level: string) {
return level === 'green' ? '🟢' : level === 'yellow' ? '🟡' : level === 'red' ? '🔴' : '⚪'
}
function fmtValue(val: any, code: string) {
if (val == null) return '-'
if (typeof val === 'number') {
if (Math.abs(val) >= 10000) return (val / 10000).toFixed(1) + '万'
return val.toLocaleString()
}
return val
}
function showObjKpis(obj: any) {
selectedObjName.value = obj.name
selectedKpis.value = obj.kpis || []
showKpiDialog.value = true
}
function getPlansByKpi(kpis: any[]) {
if (!kpis || !actionPlans.value.length) return null
const kpiIds = kpis.map((k:any) => k.kpi_id)
return actionPlans.value.filter((p:any) => kpiIds.includes(p.kpi_id))
}
function planStatus(p: any) {
if (p.status === 'completed') return 'done'
if (isOverdue(p)) return 'overdue'
if (p.status === 'in_progress') return 'active'
return 'pending'
}
function planIcon(p: any) {
if (p.status === 'completed') return '✅'
if (isOverdue(p)) return '🔴'
if (p.status === 'in_progress') return '🔄'
return '⏳'
}
function planLabel(p: any) {
const labels: Record<string, string> = { pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' }
return labels[p.status] || p.status
}
function planTagType(p: any) {
if (p.status === 'completed') return 'success'
if (isOverdue(p)) return 'danger'
if (p.status === 'in_progress') return 'warning'
return 'info'
}
function isOverdue(p: any) {
if (p.status === 'completed' || !p.due_date) return false
return new Date(p.due_date) < new Date()
}
function dueText(p: any) {
if (!p.due_date) return ''
const due = new Date(p.due_date)
const now = new Date()
const diff = Math.ceil((due.getTime() - now.getTime()) / 86400000)
if (diff < 0) return `逾期${Math.abs(diff)}天`
if (diff === 0) return '今日截止'
return `剩余${diff}天`
}
function goCanvas() {
if (selectedMap.value) {
$router.push('/maps/canvas/' + selectedMap.value)
}
}
onMounted(async () => {
const r: any = await api.get('/maps')
maps.value = r.data || []
const id = route.params.id ? Number(route.params.id) : (maps.value[0]?.id || null)
if (id) {
selectedMap.value = id
await loadReview()
}
})
</script>
<style scoped>
.review-page { padding: 20px; height: calc(100vh - 60px); overflow-y: auto; }
.review-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-shrink: 0; }
.review-header .header-left { display: flex; align-items: center; gap: 12px; }
.review-header h3 { margin: 0; font-size: 18px; }
.review-header .header-right { display: flex; align-items: center; }
.loading-wrap { padding: 40px; }
.summary-cards { display: flex; gap: 16px; margin-bottom: 20px; }
.s-card { padding: 20px; border-radius: 12px; flex: 1; }
.s-card.overall { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; }
.s-card-top { display: flex; align-items: baseline; gap: 8px; margin-bottom: 12px; }
.score { font-size: 36px; font-weight: 700; }
.score-label { font-size: 14px; opacity: 0.8; }
.s-card-btm { display: flex; gap: 16px; font-size: 13px; }
.stat-item { opacity: 0.9; }
.dim-matrix { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 24px; }
.dim-col { background: #fff; border-radius: 8px; padding: 12px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
.dim-head { font-size: 14px; font-weight: 600; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.dim-body { display: flex; flex-direction: column; gap: 6px; }
.obj-row { display: flex; align-items: center; gap: 6px; padding: 8px; border-radius: 6px; cursor: pointer; transition: background 0.2s; font-size: 13px; }
.obj-row:hover { background: #f5f7fa; }
.obj-red { background: #fef0f0; }
.obj-yellow { background: #fdf6ec; }
.obj-green { background: #f0f9eb; }
.obj-gray { background: #fafafa; }
.obj-badge { font-size: 12px; }
.obj-info { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.obj-name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.obj-ratio { font-size: 11px; color: #999; }
.focus-section { margin-bottom: 24px; }
.focus-title { font-size: 16px; margin: 0 0 12px 0; }
.focus-card { background: #fff; border-radius: 8px; padding: 12px; margin-bottom: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); cursor: pointer; }
.focus-red { border-left: 4px solid #f56c6c; }
.focus-yellow { border-left: 4px solid #e6a23c; }
.focus-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.focus-level { font-size: 11px; padding: 2px 6px; border-radius: 4px; font-weight: 600; }
.focus-red .focus-level { background: #fef0f0; color: #f56c6c; }
.focus-yellow .focus-level { background: #fdf6ec; color: #e6a23c; }
.focus-name { font-weight: 600; font-size: 14px; }
.focus-kpis { padding-left: 20px; margin-bottom: 6px; }
.fkpi-row { display: flex; justify-content: space-between; font-size: 13px; padding: 4px 0; }
.fkpi-name { color: #666; }
.fkpi-val { font-weight: 500; }
.focus-plans { padding-left: 20px; margin-top: 4px; border-top: 1px solid #f0f0f0; padding-top: 6px; }
.fplan-row { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 4px 0; }
.fplan-status { flex-shrink: 0; }
.fplan-title { flex: 1; }
.fplan-assignee { color: #999; }
.fplan-due { color: #f56c6c; font-size: 11px; }
.plans-section { margin-bottom: 24px; }
.plans-title { font-size: 16px; margin: 0 0 12px 0; }
.plans-stats { display: flex; gap: 8px; margin-bottom: 12px; }
.kpi-detail-row { border: 1px solid #eee; border-radius: 8px; padding: 12px; margin-bottom: 8px; }
.kpi-red { border-left: 4px solid #f56c6c; }
.kpi-yellow { border-left: 4px solid #e6a23c; }
.kpi-green { border-left: 4px solid #67c23a; }
.kpi-gray { border-left: 4px solid #ccc; }
.kpi-detail-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.kpi-detail-name { font-weight: 600; font-size: 14px; }
.kpi-detail-body { padding-left: 24px; }
.kpi-detail-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; }
.kpi-detail-item .label { color: #999; width: 60px; }
.kpi-detail-item .val { font-weight: 500; }
</style>