Files
cma-management/frontend/src/views/StrategyMap.vue
T
wecom-fullstack 0fc2c6109e
ci/woodpecker/push/main Pipeline was successful
style(page-title): 补漏——战略地图看板页标题弱化 + KPIListView 标题 emoji 清零
- StrategyMap.vue: 页内 h2 19px/600/深色(与路由标题「战略地图看板」基本同名)降为
  div.page-subtitle 14px/500/#64748B(保留地图名+版本+状态等增量信息,不再充当 H1)
- KPIListView.vue: 组件 h3 图标默认值 '📊' 置空(v-if 渲染),分类标题与明细卡头
  '📋' 改为 Lucide 内联 SVG(clipboard-list,18/16px,#64748B)
- 仅模板与样式:不动 MainLayout、不改接口/数据/路由 key、不碰 MapCanvas.vue
2026-10-19 04:34:35 +08:00

412 lines
16 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="strategy-map-page" ref="pageRef">
<!-- ── 页头:地图标题 + 版本 + 数据截止期 + 期间切换(只读页,无任何写入口)── -->
<div class="page-header">
<div class="page-subtitle">
<el-icon class="title-icon"><DataBoard /></el-icon>
<span>{{ board?.map?.title || '战略地图' }}</span>
<el-tag v-if="board?.map?.version" size="small" effect="plain">{{ board.map.version }}</el-tag>
<el-tag v-if="board?.map?.status" size="small" type="info" effect="plain">{{ board.map.status }}</el-tag>
</div>
<div class="header-right">
<span class="asof">
<el-icon><Clock /></el-icon>
数据截止期:<b>{{ board?.data_asof || '—' }}</b>
</span>
<el-radio-group v-model="period" size="small" :disabled="!periods.length" @change="load">
<el-radio-button v-for="p in periods" :key="p" :value="p">{{ p }}</el-radio-button>
</el-radio-group>
<el-button size="small" :loading="loading" @click="load">
<el-icon><Refresh /></el-icon>刷新
</el-button>
<el-button size="small" @click="doPrint">
<el-icon><Printer /></el-icon>打印 A3
</el-button>
</div>
</div>
<!-- ── 口径/统计条 ── -->
<div class="meta-bar">
<span class="axis-hint">泳道自下而上:学习 → 流程 → 客户 → 财务</span>
<span class="legend">
<span v-for="lg in legend" :key="lg.level" class="legend-item">
<i class="dot" :style="{ background: lg.color }"></i>{{ lg.label }} {{ lg.count }}
</span>
</span>
<span class="stat">
节点 <b>{{ stats.nodes }}</b> · 已回填实际值 <b>{{ stats.with_actual }}</b>/{{ stats.nodes }} ·
因果链 <b>{{ stats.edges }}</b>
</span>
</div>
<el-alert
v-if="missingTip"
class="caliber-note"
type="info"
:closable="false"
show-icon
:title="missingTip"
/>
<el-alert
v-if="errorMsg"
class="caliber-note"
type="error"
:closable="false"
show-icon
:title="errorMsg"
/>
<!-- ── 看板主体:泳道 + 因果箭头 ── -->
<div v-loading="loading" class="board-wrap" ref="boardRef">
<svg class="edge-layer" :width="boardSize.w" :height="boardSize.h">
<defs>
<marker id="sm-arrow" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#94A3B8" />
</marker>
<marker id="sm-arrow-neg" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#F56C6C" />
</marker>
</defs>
<path
v-for="e in edgePaths"
:key="e.id"
:d="e.d"
fill="none"
:stroke="e.negative ? '#F56C6C' : '#94A3B8'"
:stroke-width="e.width"
:stroke-dasharray="e.negative ? '6,4' : '0'"
:opacity="e.opacity"
:marker-end="e.negative ? 'url(#sm-arrow-neg)' : 'url(#sm-arrow)'"
>
<title>{{ e.title }}</title>
</path>
</svg>
<div
v-for="layer in displayLayers"
:key="layer.key"
class="swimlane"
:style="{ borderLeftColor: layer.color }"
>
<div class="lane-head">
<el-icon class="lane-icon" :style="{ color: layer.color }">
<component :is="layerIcon(layer.key)" />
</el-icon>
<span class="lane-name" :style="{ color: layer.color }">{{ layer.name }}</span>
<span class="lane-count">{{ layer.nodes.length }}</span>
</div>
<div class="lane-nodes">
<div
v-for="n in layer.nodes"
:key="n.id"
:ref="(el) => setNodeRef(n.id, el)"
class="node-card"
:class="'lvl-' + n.level"
:style="{ borderTopColor: n.color }"
>
<div class="card-head">
<span class="card-name" :title="n.name">{{ n.name }}</span>
<el-tag size="small" effect="plain" class="unit-tag">{{ n.unit || '—' }}</el-tag>
</div>
<div class="card-value">
<span class="cur" :class="'cur-' + n.level">
{{ n.current === null || n.current === undefined ? '待回填' : fmt(n.current) }}
</span>
<span class="arrow-mark">
<el-icon><Right /></el-icon>
</span>
<span class="tgt">{{ n.target === null || n.target === undefined ? '—' : fmt(n.target) }}</span>
<span class="pct" :style="{ color: n.color }">
{{ n.rate === null || n.rate === undefined ? (n.mode === 'status' ? '状态' : '—') : pct(n.rate) }}
</span>
</div>
<div class="bar">
<div class="bar-in" :style="{ width: barWidth(n), background: n.color }"></div>
</div>
<div class="card-foot">
<span class="foot-left">
<i class="dot" :style="{ background: n.color }"></i>{{ levelLabel(n.level) }}
<span v-if="n.mode === 'inverse'" class="tag-plain">逆</span>
<span v-if="n.mode === 'status'" class="tag-plain">状态</span>
</span>
<span class="foot-right" :title="ownerTip(n)">{{ n.owner || '—' }}</span>
</div>
<div v-if="noteOf(n)" class="card-note" :title="noteOf(n)">{{ noteOf(n) }}</div>
</div>
<div v-if="!layer.nodes.length" class="lane-empty">本层无节点</div>
</div>
</div>
<el-empty v-if="!loading && !stats.nodes" description="该地图暂无节点(map_objectives 为空)" />
</div>
<div class="print-foot">
口径:达成率 = 实际值 / 目标值(逆指标取倒数)· 绿 ≥100% / 黄 ≥80% / 红 &lt;80% / 灰 = 无实际值 ·
实际值取「实测区」(排除预估/预测行)最新期 · 数据来源:CMA 生产库(只读)
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import {
DataBoard, Clock, Refresh, Printer, Right,
Money, User, Setting, Reading, TrendCharts,
} from '@element-plus/icons-vue'
import { mapApi } from '../api/index'
// ── 状态 ──
const boardRef = ref<HTMLElement | null>(null)
const loading = ref(false)
const errorMsg = ref('')
const board = ref<any>(null)
const period = ref<string>('')
const periods = ref<string[]>([])
const boardSize = ref({ w: 0, h: 0 })
const edgePaths = ref<any[]>([])
const MAP_ID = 65 // 陕西酣客三年战略规划(e1);map 列表为空时回退到「最新非归档地图」
const displayLayers = computed<any[]>(() => {
const layers = (board.value?.layers || []).slice()
// 后端已按 学习→流程→客户→财务 返回(自下而上);页面自上而下渲染需反转
const known = ['learning', 'process', 'customer', 'finance']
layers.sort((a, b) => known.indexOf(b.key) - known.indexOf(a.key))
return layers
})
const stats = computed(() => board.value?.stats || {
nodes: 0, with_actual: 0, missing_actual: 0, edges: 0,
by_level: { success: 0, warning: 0, danger: 0, info: 0 },
})
const legend = computed(() => {
const bl = stats.value.by_level || {}
return [
{ level: 'success', label: '达成', color: '#16A34A', count: bl.success || 0 },
{ level: 'warning', label: '接近', color: '#EAB308', count: bl.warning || 0 },
{ level: 'danger', label: '未达成', color: '#DC2626', count: bl.danger || 0 },
{ level: 'info', label: '待回填', color: '#94A3B8', count: bl.info || 0 },
]
})
const missingTip = computed(() => {
const s = stats.value
if (!s.nodes || !s.missing_actual) return ''
return `本图 ${s.nodes} 个节点中 ${s.missing_actual} 个尚无 kpi_values 实际值,按口径置灰(无实际值 ⇒ 灰),`
+ '待数据侧回填后自动上色;另有状态类指标(单位=状态)按 1=达成 / 0 或未填报=未达成 判色,不参与达成率。'
})
// ── 取数(只读 GET)──
async function load() {
loading.value = true
errorMsg.value = ''
try {
const params: any = {}
if (period.value) params.period = period.value
const r: any = await mapApi.strategyBoard(MAP_ID, params)
board.value = r
if (!periods.value.length) periods.value = r.periods || []
if (r.period) period.value = r.period
} catch (e: any) {
const status = e?.response?.status
if (status === 404) {
board.value = null
await loadFallbackMap()
} else {
board.value = null
errorMsg.value = `战略地图数据加载失败${status ? '(HTTP ' + status + ')' : ''}:${e?.message || e}`
}
} finally {
loading.value = false
await nextTick()
drawEdges()
}
}
/** 未命中 map 65 时:取本账套第一张非归档地图(只读,不写库) */
async function loadFallbackMap() {
try {
const maps: any = await mapApi.list()
const list = (maps?.data || maps || []).filter((m: any) => m.status !== 'archived')
const target = list.find((m: any) => m.id === MAP_ID) || list[0]
if (!target) {
errorMsg.value = '本账套暂无战略地图'
return
}
const params: any = period.value ? { period: period.value } : {}
const r: any = await mapApi.strategyBoard(target.id, params)
board.value = r
periods.value = r.periods || []
if (r.period) period.value = r.period
} catch (e: any) {
errorMsg.value = `战略地图数据加载失败:${e?.message || e}`
}
}
// ── 节点 ref / 因果箭头 ──
const nodeEls = new Map<number, HTMLElement>()
function setNodeRef(id: number, el: any) {
if (el) nodeEls.set(id, el as HTMLElement)
else nodeEls.delete(id)
}
function anchor(el: HTMLElement, up: boolean) {
const r = el.getBoundingClientRect()
return { x: r.left + r.width / 2, y: up ? r.top : r.bottom }
}
function drawEdges() {
const wrap = boardRef.value
if (!wrap) return
const wrapRect = wrap.getBoundingClientRect()
const edges = board.value?.edges || []
boardSize.value = { w: wrap.clientWidth, h: wrap.clientHeight }
const paths: any[] = []
for (const e of edges) {
const s = nodeEls.get(e.source_node_id)
const t = nodeEls.get(e.target_node_id)
if (!s || !t) continue
const sr = s.getBoundingClientRect()
const tr = t.getBoundingClientRect()
const start = anchor(s, tr.top < sr.top)
const end = anchor(t, tr.top < sr.top ? false : true)
const x1 = start.x - wrapRect.left
const y1 = start.y - wrapRect.top
const x2 = end.x - wrapRect.left
const y2 = end.y - wrapRect.top
const dy = (y2 - y1) / 2
paths.push({
id: e.id,
d: `M ${x1} ${y1} C ${x1} ${y1 + dy}, ${x2} ${y2 - dy}, ${x2} ${y2}`,
negative: e.direction === 'negative',
width: Math.max(1, Math.round((e.strength || 0.5) * 3)),
opacity: 0.35 + (e.strength || 0.5) * 0.5,
title: `${e.formula || ''}|强度 ${e.strength}|滞后 ${e.lag_months} 月|${e.direction === 'negative' ? '负向' : '正向'}`,
})
}
edgePaths.value = paths
}
let resizeTimer: any = null
function onResize() {
if (resizeTimer) clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => drawEdges(), 150)
}
onMounted(async () => {
await load()
window.addEventListener('resize', onResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize)
if (resizeTimer) clearTimeout(resizeTimer)
})
// ── 展示辅助 ──
function fmt(v: any) {
const n = Number(v)
if (!isFinite(n)) return '—'
const abs = Math.abs(n)
if (abs >= 10000) return n.toLocaleString('zh-CN', { maximumFractionDigits: 0 })
if (abs >= 100) return n.toLocaleString('zh-CN', { maximumFractionDigits: 1 })
return n.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
}
function pct(r: any) {
const n = Number(r)
return isFinite(n) ? (n * 100).toFixed(0) + '%' : '—'
}
function barWidth(n: any) {
if (n.mode === 'status') return n.level === 'success' ? '100%' : '0%'
const r = Number(n.rate)
if (!isFinite(r)) return '0%'
return Math.max(0, Math.min(1, r)) * 100 + '%'
}
function levelLabel(lv: string) {
return { success: '达成', warning: '接近', danger: '未达成', info: '待回填' }[lv] || lv
}
function noteOf(n: any) {
if (n.reason) return n.reason
if (n.binding_source === 'none') return '未绑定 KPI'
if (n.current_period) return `实际值期间 ${n.current_period}`
return ''
}
function ownerTip(n: any) {
return `责任人:${n.owner || '—'}|KPI:${n.kpi_code || '—'}|绑定来源:${n.binding_source || '—'}`
}
function layerIcon(key: string) {
return { finance: Money, customer: User, process: Setting, learning: Reading }[key] || TrendCharts
}
function doPrint() {
window.print()
}
</script>
<style scoped>
.strategy-map-page { padding: 16px; background: #f5f7fa; min-height: 100%; }
.page-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 10px; }
.page-subtitle { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 500; color: #64748B; margin: 0; }
.title-icon { color: #64748B; font-size: 16px; }
.header-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.asof { font-size: 13px; color: #606266; display: inline-flex; align-items: center; gap: 4px; }
.meta-bar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; font-size: 12.5px; color: #606266; margin-bottom: 8px; }
.axis-hint { color: #909399; }
.legend { display: inline-flex; gap: 12px; }
.legend-item { display: inline-flex; align-items: center; gap: 4px; }
.dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; }
.caliber-note { margin-bottom: 8px; }
.board-wrap { position: relative; background: #fff; border-radius: 6px; padding: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.edge-layer { position: absolute; left: 0; top: 0; pointer-events: none; z-index: 1; }
.swimlane { position: relative; z-index: 2; border-left: 4px solid #dcdfe6; background: #fbfcfe; border-radius: 4px; padding: 8px 10px; margin-bottom: 10px; }
.lane-head { display: flex; align-items: center; gap: 6px; font-weight: 600; font-size: 13.5px; margin-bottom: 8px; }
.lane-icon { font-size: 16px; }
.lane-count { font-weight: 400; color: #909399; font-size: 12px; }
.lane-nodes { display: flex; flex-wrap: wrap; gap: 10px; }
.lane-empty { color: #c0c4cc; font-size: 12px; }
.node-card { width: 232px; background: #fff; border: 1px solid #ebeef5; border-top: 3px solid #94a3b8; border-radius: 5px; padding: 8px 10px 6px; box-shadow: 0 1px 3px rgba(0,0,0,.05); }
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.card-name { font-size: 13.5px; font-weight: 600; color: #1f2d3d; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.unit-tag { flex: none; }
.card-value { display: flex; align-items: baseline; gap: 4px; margin: 6px 0 5px; }
.cur { font-size: 16px; font-weight: 700; }
.cur-success { color: #16A34A; }
.cur-warning { color: #EAB308; }
.cur-danger { color: #DC2626; }
.cur-info { color: #94A3B8; }
.arrow-mark { color: #c0c4cc; display: inline-flex; align-items: center; }
.tgt { font-size: 14px; color: #606266; }
.pct { margin-left: auto; font-size: 12.5px; font-weight: 600; }
.bar { height: 6px; background: #f0f2f5; border-radius: 3px; overflow: hidden; }
.bar-in { height: 100%; border-radius: 3px; transition: width .3s; }
.card-foot { display: flex; align-items: center; justify-content: space-between; margin-top: 6px; font-size: 11.5px; color: #606266; }
.foot-left { display: inline-flex; align-items: center; gap: 4px; }
.foot-right { color: #909399; max-width: 108px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tag-plain { border: 1px solid #dcdfe6; border-radius: 3px; padding: 0 3px; color: #909399; font-size: 10.5px; }
.card-note { margin-top: 4px; font-size: 11px; color: #909399; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.print-foot { margin-top: 10px; font-size: 11.5px; color: #909399; line-height: 1.6; }
@media print {
@page { size: A3 landscape; margin: 8mm; }
.strategy-map-page { padding: 0; background: #fff; }
.header-right .el-button { display: none; }
.board-wrap { box-shadow: none; padding: 0; }
.node-card { break-inside: avoid; }
.swimlane { break-inside: avoid; }
}
</style>