Files
cma-management/frontend/src/views/KPIAlignment.vue
T

367 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 class="section-gap" />
<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>