367 lines
15 KiB
Vue
367 lines
15 KiB
Vue
<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"> </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>
|