feat: P0/P1/P2全部功能 — 四层泳道/视角切换/KPI看板/预警/差异反打/预算/知识面板/回顾会/情景预测/Excel导入/角色权限

This commit is contained in:
Hermes CI Fix
2026-07-12 17:46:08 +08:00
parent cdf00efd69
commit ee25d5fa1d
8871 changed files with 1778433 additions and 0 deletions
+366
View File
@@ -0,0 +1,366 @@
<template>
<div class="alignment-page">
<div class="page-header">
<h3>战略执行看板</h3>
<div>
<el-button v-if="alignConfigured" @click="loadTree" :loading="loading" :disabled="loading" style="margin-right:8px;">
刷新数据
</el-button>
<el-button type="primary" @click="showSelector = true">
{{ alignConfigured ? '切换模式' : '配置对齐模式' }}
</el-button>
</div>
</div>
<!-- 模式选择器 -->
<div v-if="showSelector" class="align-setup">
<el-alert v-if="!alignConfigured" title="系统尚未配置KPI对齐模式" type="warning" :closable="false" show-icon style="margin-bottom:16px;" />
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
<el-card v-for="mode in alignModes" :key="mode.key"
:class="{ 'mode-card': true, 'selected-mode': selectedMode === mode.key }"
shadow="hover" @click="selectedMode = mode.key"
>
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-weight:600;">{{ mode.name }}</span>
<el-radio :model-value="selectedMode === mode.key" :label="mode.key">&nbsp;</el-radio>
</div>
</template>
<p style="font-size:13px;color:#666;line-height:1.6;">{{ mode.description }}</p>
<el-tag type="info" size="small">示例</el-tag>
<p style="font-size:12px;color:#999;margin-top:4px;background:#f5f7fa;padding:6px 8px;border-radius:4px;">{{ mode.example }}</p>
</el-card>
</div>
<div style="margin-top:16px;display:flex;gap:12px;">
<el-button type="primary" size="large" @click="saveAlignConfig" :loading="saving" :disabled="!selectedMode">
{{ alignConfigured ? '切换为「' + selectedModeName + '」' : '确定并启用「' + selectedModeName + '」' }}
</el-button>
<el-button v-if="alignConfigured" @click="showSelector = false; selectedMode = currentMode?.key || ''">取消</el-button>
</div>
</div>
<!-- 看板主体 -->
<div v-else>
<!-- 地图选择器 -->
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap;">
<span style="font-size:13px;color:#666;white-space:nowrap;">筛选地图</span>
<el-select v-model="selectedMapId" placeholder="全部KPI" clearable style="width:240px;" @change="onMapChange">
<el-option v-for="m in mapList" :key="m.id" :label="m.title" :value="m.id" />
</el-select>
<span v-if="selectedMapId" style="font-size:12px;color:#409eff;">仅显示该地图关联KPI的对齐关系</span>
<span v-else style="font-size:12px;color:#999;">显示全部KPI</span>
</div>
<!-- 状态栏 -->
<div style="display:flex;gap:16px;margin-bottom:16px;flex-wrap:wrap;">
<el-card shadow="never" style="flex:1;min-width:120px;">
<div style="font-size:12px;color:#999;">对齐模式</div>
<div style="font-size:16px;font-weight:600;margin-top:4px;">{{ currentMode?.name || '-' }}</div>
</el-card>
<el-card shadow="never" style="flex:1;min-width:120px;">
<div style="font-size:12px;color:#999;">KPI总数</div>
<div style="font-size:16px;font-weight:600;margin-top:4px;">{{ treeData?.total || 0 }}</div>
</el-card>
<el-card shadow="never" style="flex:1;min-width:120px;">
<div style="font-size:12px;color:#999;">有数据</div>
<div style="font-size:16px;font-weight:600;margin-top:4px;">{{ kpiWithData }}</div>
</el-card>
<el-card shadow="never" style="flex:1;min-width:120px;">
<div style="font-size:12px;color:#999;">红黄灯</div>
<div style="font-size:16px;font-weight:600;margin-top:4px;">
<span v-if="redCount > 0" style="color:#f56c6c;">{{ redCount }}</span>
<span v-if="redCount > 0 && yellowCount > 0" style="color:#999;margin:0 4px;">/</span>
<span v-if="yellowCount > 0" style="color:#e6a23c;">{{ yellowCount }}</span>
<span v-if="redCount === 0 && yellowCount === 0" style="color:#67c23a;">全部正常</span>
</div>
</el-card>
</div>
<!-- 对齐树 -->
<div style="border:1px solid #e4e7ed;border-radius:8px;padding:12px;background:#fff;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<span style="font-weight:500;">对齐关系{{ treeData?.mode_name || '' }}</span>
<span style="font-size:12px;color:#999;" v-if="treeData">
最后数据期间{{ latestPeriod }}
<el-button text size="small" @click="loadTree" :loading="loading" style="margin-left:8px;">刷新</el-button>
</span>
</div>
<el-tree v-if="treeData" :data="treeData.tree || []" :props="{ label: 'kpi_name', children: 'children' }" default-expand-all>
<template #default="{ data }">
<div class="tree-node-row">
<!-- 维度组 -->
<template v-if="data.is_dimension_group">
<span :style="{color: dimColor(data.dimension), fontWeight:600}">{{ data.kpi_name }}</span>
<span class="tree-meta">{{ data.child_count }}个KPI</span>
<span v-if="data.description" class="tree-desc"> {{ data.description }}</span>
</template>
<!-- 类别组 -->
<template v-else-if="data.is_category_group">
<el-tag size="small" type="success">{{ data.category_label }}</el-tag>
<span v-if="data.feeds" class="tree-drive"> 驱动 {{ feedLabels(data.feeds) }}</span>
<span v-else class="tree-drive" style="color:#67c23a;">最终结果</span>
<span class="tree-meta">{{ data.child_count }}</span>
</template>
<!-- KPI节点 -->
<template v-else>
<span class="tree-level-dot" :class="'level-' + (data.alert_level || 'none')"></span>
<span class="tree-code">{{ data.kpi_code }}</span>
<span class="tree-name">{{ data.kpi_name }}</span>
<span v-if="data.alignment_type === 'vertical_split'" class="tree-tag tag-amber">承接 {{ data.parent_code }}</span>
<span v-if="data.drives" class="tree-tag tag-gray"> {{ dimLabel(data.drives) }}</span>
<!-- 目标值 -->
<span class="tree-val" v-if="data.target_value != null">
目标<strong>{{ fmtNum(data.target_value) }}</strong>{{ data.unit }}
</span>
<!-- 实际值 -->
<span class="tree-val" v-if="data.actual_value != null" :class="statusClass(data)">
实绩<strong>{{ fmtNum(data.actual_value) }}</strong>{{ data.unit }}
</span>
<span v-else class="tree-val tree-val-na">实绩-</span>
<!-- 偏差率 -->
<span v-if="data.actual_value != null && data.target_value != null && data.target_value > 0"
class="tree-deviation" :class="devClass(data)">
{{ calcDev(data) }}
</span>
</template>
</div>
</template>
</el-tree>
<div v-else-if="!loading" style="text-align:center;padding:40px;color:#999;">
暂无对齐数据请先配置对齐模式
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
const route = useRoute()
const router = useRouter()
const alignModes = ref<any[]>([])
const alignConfigured = ref(false)
const currentMode = ref<any>(null)
const selectedMode = ref('')
const saving = ref(false)
const loading = ref(false)
const treeData = ref<any>(null)
const showSelector = ref(false)
// 地图选择
const mapList = ref<any[]>([])
const selectedMapId = ref<number | null>(null)
async function loadMapList() {
try {
const token = localStorage.getItem('cma_token')
const r = await fetch('/api/cma/maps', {
headers: { 'Authorization': `'Bearer ' + token` },
}).then(r => r.json())
mapList.value = r.data || []
} catch {}
}
function onMapChange(val: number | null) {
selectedMapId.value = val
// 更新URL(可选)
if (val) {
router.replace(`/alignment?map_id=${val}`)
} else {
router.replace('/alignment')
}
loadTree()
}
const selectedModeName = computed(() => {
const m = alignModes.value.find(m => m.key === selectedMode.value)
return m ? m.name : ''
})
// 统计
const kpiWithData = computed(() => {
if (!treeData.value) return 0
return countKpiField(treeData.value.tree || [], 'actual_value')
})
const redCount = computed(() => {
if (!treeData.value) return 0
return countAlert(treeData.value.tree || [], 'red')
})
const yellowCount = computed(() => {
if (!treeData.value) return 0
return countAlert(treeData.value.tree || [], 'yellow')
})
const latestPeriod = computed(() => {
if (!treeData.value) return '-'
return findLatestPeriod(treeData.value.tree || [])
})
function countKpiField(nodes: any[], field: string): number {
let count = 0
for (const n of nodes) {
if (n.children) count += countKpiField(n.children, field)
else if (n[field] != null) count++
}
return count
}
function countAlert(nodes: any[], level: string): number {
let count = 0
for (const n of nodes) {
if (n.children) count += countAlert(n.children, level)
else if (n.alert_level === level) count++
}
return count
}
function findLatestPeriod(nodes: any[]): string {
let latest = ''
for (const n of nodes) {
if (n.children) {
const sub = findLatestPeriod(n.children)
if (sub > latest) latest = sub
} else if (n.period && n.period > latest) {
latest = n.period
}
}
return latest
}
const dimColor = (dim: string) => {
const colors: Record<string, string> = { finance: '#409eff', customer: '#67c23a', process: '#e6a23c', learning: '#f56c6c' }
return colors[dim] || '#999'
}
const dimLabel = (dim: string) => {
const labels: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长', internal_process: '内部流程' }
return labels[dim] || dim
}
const feedLabels = (feeds: string[] | null) => {
if (!feeds) return ''
const map: Record<string, string> = {
supply_chain: '供应链效率', delivery_quality: '交付质量',
customer_scale: '客户规模', customer_concentration: '客户集中度',
customer_satisfaction: '客户满意', revenue_growth: '收入增长',
profitability: '盈利水平', cost_control: '成本费用', asset_efficiency: '资产效率',
}
return feeds.map(f => map[f] || f).join('、')
}
const fmtNum = (v: number) => {
if (v == null) return '-'
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
if (Number.isInteger(v)) return v.toLocaleString()
return v.toFixed(1)
}
const calcDev = (d: any) => {
if (d.actual_value == null || d.target_value == null || d.target_value === 0) return ''
const dev = ((d.actual_value - d.target_value) / d.target_value * 100)
const sign = dev > 0 ? '+' : ''
return `${sign}${dev.toFixed(1)}%`
}
const statusClass = (d: any) => {
if (d.alert_level === 'red') return 'val-red'
if (d.alert_level === 'yellow') return 'val-yellow'
if (d.alert_level === 'green') return 'val-green'
return ''
}
const devClass = (d: any) => {
if (d.alert_level === 'red') return 'dev-red'
if (d.alert_level === 'yellow') return 'dev-yellow'
return 'dev-green'
}
async function loadConfig() {
try {
const r = await fetch('/api/cma/alignment/config').then(r => r.json())
alignModes.value = r.modes || []
alignConfigured.value = r.configured || false
if (r.configured && r.mode) {
currentMode.value = alignModes.value.find(m => m.key === r.mode.mode) || null
selectedMode.value = r.mode.mode
}
showSelector.value = !r.configured
if (r.configured) loadTree()
} catch {}
}
async function saveAlignConfig() {
if (!selectedMode.value) return
saving.value = true
try {
const token = localStorage.getItem('cma_token')
await fetch('/api/cma/alignment/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `'Bearer ' + token` },
body: JSON.stringify({ mode: selectedMode.value }),
}).then(r => r.json())
ElMessage.success('对齐模式已设置')
currentMode.value = alignModes.value.find(m => m.key === selectedMode.value) || null
alignConfigured.value = true
showSelector.value = false
loadTree()
} catch { ElMessage.error('设置失败') }
saving.value = false
}
async function loadTree() {
loading.value = true
try {
const token = localStorage.getItem('cma_token')
let url = '/api/cma/alignment/tree'
if (selectedMapId.value) url += `?map_id=${selectedMapId.value}`
const r = await fetch(url, {
headers: { 'Authorization': `'Bearer ' + token` },
}).then(r => r.json())
treeData.value = r
} catch (e: any) {
ElMessage.error(e.message || '加载失败')
}
loading.value = false
}
onMounted(() => {
loadConfig()
loadMapList()
// 如果URL有map_id参数,自动选中对应地图
if (route.query.map_id) {
const mid = Number(route.query.map_id)
if (mid) {
selectedMapId.value = mid
}
}
})
</script>
<style scoped>
.alignment-page { padding: 16px; height: 100%; overflow-y: auto; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.page-header h3 { margin: 0; font-size: 18px; font-weight: 600; }
.mode-card { cursor: pointer; transition: all 0.2s; border: 2px solid transparent; }
.mode-card:hover { border-color: #409eff; }
.selected-mode { border-color: #409eff; background: #f0f7ff; }
.tree-node-row { display: flex; align-items: center; gap: 6px; font-size: 13px; padding: 3px 0; flex-wrap: wrap; }
.tree-code { font-family: monospace; font-size: 11px; color: #909399; }
.tree-name { color: #303133; }
.tree-meta { font-size: 11px; color: #999; }
.tree-desc { font-size: 11px; color: #909399; }
.tree-drive { font-size: 11px; color: #e6a23c; }
.tree-tag { font-size: 11px; padding: 1px 6px; border-radius: 3px; }
.tag-amber { background: #fdf6ec; color: #e6a23c; }
.tag-gray { background: #f4f4f5; color: #909399; }
.tree-level-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.level-red { background: #f56c6c; }
.level-yellow { background: #e6a23c; }
.level-green { background: #67c23a; }
.level-none { background: #dcdfe6; }
.tree-val { font-size: 12px; color: #606266; margin-left: 4px; }
.tree-val-na { color: #ccc; }
.val-red { color: #f56c6c; }
.val-yellow { color: #e6a23c; }
.val-green { color: #67c23a; }
.tree-deviation { font-size: 11px; font-weight: 500; padding: 1px 5px; border-radius: 3px; }
.dev-red { background: #fef0f0; color: #f56c6c; }
.dev-yellow { background: #fdf6ec; color: #e6a23c; }
.dev-green { background: #f0f9eb; color: #67c23a; }
</style>