feat(strategy-map): live board reading map_objectives + kpi_targets (read-only)

Task: finance-20261011-strategy-map-frontend-fullstack (P2, read-only integration)

- backend/app/utils/strategy_board.py: single source of truth for the level
  caliber (layer colors, inverse-kpi explicit whitelist, status class by
  unit, zero guard, period ranking, latest-actual picking). Pure functions.
- backend/app/api/maps.py: GET /api/cma/maps/{map_id}/strategy-board
  - nodes straight from map_objectives (sorted by sort_order, id)
  - node<->kpi binding with 4-step fallback, binding_source echoed back
  - targets from kpi_targets (target_type compatible: yearly + annual),
    deduped per (kpi_id, period) with yearly preferred
  - actuals from kpi_values actual-only scope, latest period
  - causality edges from kpi_causality, out-of-map endpoints dropped+counted
  - entity_id taken from the token only (no cross-tenant reads)
  - zero writes; nothing is persisted by this endpoint
- frontend/src/views/StrategyMap.vue: /strategy-map live board (4 swim lanes
  bottom-up, node cards, 20 causality arrows in SVG, period switch, A3 print).
  No emoji, null guards everywhere (no toFixed on null).
- api/index.ts + router/index.ts + permission.ts: route + menu registration
  (/strategy-map, zone A read-only, roles ceo/finance/business/it).
- backend/tests/test_strategy_board_20261011.py: 33 cases (spec section 4 /
  4.1 / 4.2 / 4.3 + section 7 fixtures + threshold boundaries + period rank).
- backend/tests/test_map_objectives_kpis_form_20261011.py: 14 cases for the
  four kpis column shapes (list, json string, object list, NULL/[]/weird).

Not included: push + backend restart (production write, separate gate).

(cherry picked from commit c675bde6b4beaf1d8a9d02a80f5434203a53f348)
This commit is contained in:
Hermes CI Fix
2026-10-18 16:31:52 +08:00
parent 2cff58ca6a
commit 75a48f55d2
8 changed files with 1263 additions and 4 deletions
+2
View File
@@ -67,6 +67,8 @@ export const mapApi = {
delete: (id: number) => api.delete(`/maps/${id}`),
batchDelete: (ids: number[]) => api.post('/maps/batch-delete', { ids }),
createWithTemplate: (data: any) => api.post('/maps/create-with-template', data),
// 战略地图活看板(只读,2026-10-11 finance-20261011-strategy-map-frontend-fullstack)
strategyBoard: (id: number, params?: any) => api.get(`/maps/${id}/strategy-board`, { params }),
// 战略地图版本管理(2026-09-08 project-20260908-map-versions-page)
versions: (mapId: number) => api.get(`/maps/${mapId}/versions`),
versionDetail: (mapId: number, vid: number) => api.get(`/maps/${mapId}/versions/${vid}`),
+5 -4
View File
@@ -9,10 +9,10 @@ interface MenuItem {
// ── 角色路由映射 ──
export const ROLE_ROUTES: Record<string, string[]> = {
ceo: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'],
finance: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'],
business: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
it: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification', '/treasury-payment'],
ceo: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/strategy-map', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'],
finance: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/strategy-map', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'],
business: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/strategy-map', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
it: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/strategy-map', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification', '/treasury-payment'],
}
export const ROLE_ACTIONS: Record<string, string[]> = {
@@ -26,6 +26,7 @@ export const MENU_ITEMS: MenuItem[] = [
// ── GROUP 1: 规划与目标(Plan)──
{ path: '/my-dashboard', label: '我的工作台', icon: 'Monitor', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
{ path: '/maps', label: '战略地图', icon: 'DataBoard', roles: ['ceo', 'finance'], group: '🔵 P 规划与目标' },
{ path: '/strategy-map', label: '战略地图看板', icon: 'DataBoard', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
{ path: '/budget', label: '预算管理', icon: 'Coin', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
{ path: '/alignment', label: '战略执行看板', icon: 'Opportunity', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
+2
View File
@@ -17,6 +17,8 @@ const routes = [
{ path: 'learning-dashboard', name: 'LearningDashboard', component: () => import('@/views/LearningDashboard.vue'), meta: { title: '学习成长看板', roles: ['ceo', 'finance', 'it'], zone: 'A' } },
{ path: 'maps-review', name: 'MapReviewList', component: () => import('@/views/MapReview.vue'), meta: { title: '战略回顾会', roles: ['ceo', 'finance'], zone: 'B' } },
{ path: 'maps/canvas/:id', name: 'MapCanvas', component: () => import('@/views/MapCanvas.vue'), meta: { title: '战略地图画布', roles: ['ceo', 'finance'], zone: 'B', editable: true } },
// /strategy-map: A区(只读活看板 —— 读库渲染,页内零写入口;2026-10-11 finance-20261011-strategy-map-frontend-fullstack)
{ path: 'strategy-map', name: 'StrategyMap', component: () => import('@/views/StrategyMap.vue'), meta: { title: '战略地图看板', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
{ path: 'maps/review/:id', name: 'MapReview', component: () => import('@/views/MapReview.vue'), meta: { title: '战略回顾会', roles: ['ceo', 'finance'], zone: 'B' } },
{ path: 'my-dashboard', name: 'MyDashboard', component: () => import('@/views/MyDashboard.vue'), meta: { title: '我的工作台', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
{ path: 'knowledge', name: 'CMAKnowledge', component: () => import('@/views/CMAKnowledge.vue'), meta: { title: 'CMA知识库', roles: ['ceo', 'finance', 'business', 'it'], zone: 'B', editable: true } },
+411
View File
@@ -0,0 +1,411 @@
<template>
<div class="strategy-map-page" ref="pageRef">
<!-- ── 页头:地图标题 + 版本 + 数据截止期 + 期间切换(只读页,无任何写入口)── -->
<div class="page-header">
<h2 class="page-title">
<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>
</h2>
<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-title { display: flex; align-items: center; gap: 8px; font-size: 19px; font-weight: 600; color: #1f2d3d; margin: 0; }
.title-icon { color: #409eff; }
.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>