wip(strategy-map): V1+V3 合版 + v2/v3 口径(完备度/C1 hold/57 归档组)

This commit is contained in:
Hermes CI Fix
2026-10-19 10:33:40 +08:00
parent 0fc2c6109e
commit 4d20c27de4
16 changed files with 2116 additions and 89 deletions
+553 -65
View File
@@ -25,51 +25,100 @@
@toggle-chain-mode="toggleChainMode"
/>
<MapSummaryBar
:finance="financeSummary"
:achievement="overallAchievement"
:achievement-count="achievementCount"
:summary="reviewSummary"
:loading="loading"
/>
<MapEditBar
:search="searchText"
:filter="filterMode"
:visible-layers="visibleLayers"
:layer-configs="layerUiConfigs"
:can-undo="undoStack.length > 0"
:tree-open="treeOpen"
@update:search="v => searchText = v"
@update:filter="v => filterMode = v"
@toggle-layer="toggleLayer"
@add-objective="openAddDialog"
@connect="showConnManager = true"
@undo="undoLast"
@fit="zoomFit"
@toggle-tree="treeOpen = !treeOpen"
@completeness="showCompleteness = true"
/>
<!-- 因果链查看模式提示 -->
<div v-if="chainMode" class="chain-mode-bar">
<span>🔍 因果链查看模式 — 点击节点聚焦其相关因果链(再次点击取消),其余节点自动变淡</span>
<span class="chain-ico">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="11" cy="11" r="6" /><path d="M20 20l-4.2-4.2" /></svg>
因果链查看模式 — 点击节点聚焦其相关因果链(再次点击取消),其余节点自动变淡
</span>
<el-button size="small" type="warning" @click="toggleChainMode">退出模式</el-button>
</div>
<!-- 连线模式提示 -->
<div v-if="linkingFrom" class="linking-bar">
<span>🔗 从「{{ linkingFrom.obj.name }}」连线 — 点击另一个目标完成连线</span>
<span class="chain-ico">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1" /><path d="M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" /></svg>
从「{{ linkingFrom.obj.name }}」连线 — 点击另一个目标完成连线
</span>
<el-button size="small" @click="cancelLink">取消</el-button>
</div>
<!-- 空状态提示 -->
<div v-if="!loading && dimensions.every(d => d.objectives.length === 0)" class="empty-state">
<el-empty description="四层泳道已就绪,点击各层的「+ 添加目标」开始填写">
<template #image>
<svg viewBox="0 0 120 120" width="120" height="120">
<rect x="10" y="10" width="100" height="22" rx="4" fill="#FEF0F0" />
<rect x="10" y="38" width="100" height="22" rx="4" fill="#ECF5FF" />
<rect x="10" y="66" width="100" height="22" rx="4" fill="#F0F9EB" />
<rect x="10" y="94" width="100" height="22" rx="4" fill="#FDF6EC" />
</svg>
</template>
</el-empty>
</div>
<!-- 仪表盘主视图:左目标树 / 中画布 / 右详情 -->
<div class="map-body-row">
<MapTargetTree
v-show="treeOpen"
:groups="treeGroups"
:search="searchText"
:filter="filterMode"
:active-key="selectedKey"
:collapsed="collapsedGroups"
:visible-count="visibleObjectiveCount"
:total-count="totalObjectiveCount"
@select="onTreeNodeSelect"
@toggle-group="k => collapsedGroups[k] = !collapsedGroups[k]"
@add-objective="openAddDialog"
@drag-start="onTreeDragStart"
@drag-end="onTreeDragEnd"
/>
<div class="map-canvas-col">
<!-- 画布主体(田字格2×2布局,transform: scale 缩放) -->
<div class="canvas-scroll-wrap" ref="scrollWrapRef">
<div
class="canvas-scroll-wrap"
ref="scrollWrapRef"
@dragover.prevent="onTreeDragOver"
@dragleave="onTreeDragLeave"
@drop.prevent="onTreeDrop"
>
<div class="canvas-zoom-wrap" :style="{ width: zoomWrapWidth, height: zoomWrapHeight }">
<div class="canvas-body" :class="{ 'grid-mode': gridMode }" ref="bodyRef" :style="bodyZoomStyle">
<!-- 四层泳道(纵向排列,固定顺序:财务→客户→流程→学习) -->
<div
v-for="(cfg, idx) in orderedLayerConfigs"
:key="cfg.key"
v-show="visibleLayers[cfg.key]"
:ref="el => setLayerRef(cfg.key, el)"
class="layer-swimlane-wrapper"
:style="{ borderLeft: '4px solid ' + cfg.color }"
:style="{ borderLeft: '4px solid ' + layerColor(cfg.key) }"
>
<StrategyLayer
:ref="el => setLayerCompRef(cfg.key, el)"
:layer-key="cfg.key"
:label="cfg.label"
:icon="cfg.icon"
:color="cfg.color"
:icon="''"
:color="layerColor(cfg.key)"
:objectives="getLayerNodes(cfg.key)"
:collapsed="!!collapsedLayers[cfg.key]"
:dim-active="filterActive"
:dimmed-keys="matchedKeyList"
@toggle-collapse="toggleLayerCollapse(cfg.key)"
@reorder-start="pushUndo"
:linking-from-key="linkingFrom?.key ?? null"
:drag-connect-target="dragConnectTarget"
:link-eligibility-map="linkEligibilityMap"
@@ -106,6 +155,44 @@
</div><!-- end canvas-zoom-wrap -->
</div><!-- end canvas-scroll-wrap -->
<!-- 常驻图例(语义色与阈值:绿≥90% / 黄60-90% / 红<60%) -->
<div class="map-legend" aria-label="达成率图例">
<span class="lg-item"><i class="lg-dot lv-green"></i>达标 ≥90%</span>
<span class="lg-item"><i class="lg-dot lv-yellow"></i>关注 60–90%</span>
<span class="lg-item"><i class="lg-dot lv-red"></i>风险 &lt;60%</span>
<span class="lg-item"><i class="lg-dot lv-gray"></i>无数据</span>
<span class="lg-note">达成率按指标方向归一,取目标关联 KPI 均值</span>
</div>
</div><!-- end map-canvas-col -->
<MapNodeDetail
v-if="selectedNode"
:obj="selectedNode.obj"
:dim-key="selectedNode.dimKey"
:index="selectedNode.index"
:layer-label="layerLabel(selectedNode.dimKey)"
:layer-color="layerColor(selectedNode.dimKey)"
:progress="getNodeProgress(selectedKey || '')"
:connections="connections"
:node-label-map="nodeLabelMap"
:trend-map="trendMap"
:trend-loading="trendLoading"
@close="closeDetail"
@edit="openEditDialog(selectedNode.dimKey, selectedNode.index, selectedNode.obj)"
@open-kpi-detail="showKpiDetailModal(selectedNode.dimKey, selectedNode.index, selectedNode.obj)"
@open-kpi="goKPI"
@open-plan="showPlanList(selectedNode.dimKey, selectedNode.index, selectedNode.obj)"
@select-key="selectByKey"
@go-kpi-library="goKpiLibrary"
/>
<aside v-else class="map-detail-empty" aria-label="目标详情占位">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="2" /><path d="M7 9h10M7 13h7M7 17h4" />
</svg>
<p>选中左侧目标或画布节点<br />查看达成率、KPI 与因果上下游</p>
</aside>
</div><!-- end map-body-row -->
<!-- SVG 连线层(覆盖整个画布,绘制跨层+同层箭头) -->
<ConnectionLines
ref="connectionLinesRef"
@@ -182,17 +269,32 @@
<el-button size="small" type="danger" :loading="krDeleting" @click="confirmDeleteKr">删除</el-button>
</template>
</MyDialog>
<!-- 数据完备度与清单(口径 CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0) -->
<MyDialog v-model="showCompleteness" title="数据完备度 · 战略地图" :width="880">
<MapCompleteness v-if="showCompleteness" />
</MyDialog>
<!-- 版本回滚二次确认(MyDialog;不使用 ElMessageBox 确认) -->
<MyDialog v-model="showRollbackConfirm" title="版本回滚" :width="440">
<div class="kr-del-confirm-text">确定回滚到此版本?当前内容将被覆盖。</div>
<div class="kr-del-confirm-hint">回滚会以所选版本覆盖当前画布内容,执行后页面自动刷新。</div>
<template #footer>
<el-button size="small" @click="showRollbackConfirm = false">取消</el-button>
<el-button size="small" type="danger" :loading="rollbacking" @click="confirmRollback">回滚</el-button>
</template>
</MyDialog>
</div>
<!-- P1-3: 关联知识点 -->
<div style="margin-top:12px;">
<KnowledgePanel related-page="/maps" />
<KnowledgePanel related-page="/maps/canvas" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted, nextTick, onUnmounted } from "vue"
import { useRoute } from "vue-router"
import { ElMessage, ElMessageBox } from "element-plus"
import { ElMessage } from "element-plus"
import { mapApi, kpiApi, okrTemplateApi, okrApi } from "../api/index"
import KnowledgePanel from '../components/KnowledgePanel.vue'
import api from "../api/index"
@@ -200,6 +302,11 @@ import { LAYER_CONFIG, LAYER_KEYS, getLayerList } from "../config/layers"
import { useFormGuard } from "../composables/useFormGuard"
import { sanitizeKrName } from "../utils/krName"
import StrategyLayer from "../components/strategy-map/StrategyLayer.vue"
import MapSummaryBar from "../components/strategy-map/MapSummaryBar.vue"
import MapEditBar from "../components/strategy-map/MapEditBar.vue"
import MapTargetTree from "../components/strategy-map/MapTargetTree.vue"
import MapNodeDetail from "../components/strategy-map/MapNodeDetail.vue"
import MapCompleteness from "../components/strategy-map/MapCompleteness.vue"
import ConnectionLines from "../components/strategy/ConnectionLines.vue"
import MapCanvasToolbar from "../components/map-canvas/MapCanvasToolbar.vue"
import MapCanvasDialogs from "../components/map-canvas/MapCanvasDialogs.vue"
@@ -238,6 +345,46 @@ const kpiNameMap = reactive<Record<string, string>>({})
const dimensions = reactive<any[]>([])
const loading = ref(true)
// ── 仪表盘视图状态(2026-10-18 V1+V3 合版) ──
/** 四层视觉色(本页按设计规范呈现,不改动地图存储的颜色值) */
const LAYER_VISUAL: Record<string, { short: string; color: string }> = {
finance: { short: 'L1', color: '#0F172A' },
customer: { short: 'L2', color: '#635BFF' },
process: { short: 'L3', color: '#0E9F6E' },
learning: { short: 'L4', color: '#F59E0B' },
}
const layerUiConfigs = computed(() => orderedLayerConfigs.map((c: any) => ({
key: c.key,
label: c.label,
short: LAYER_VISUAL[c.key]?.short || c.key,
color: LAYER_VISUAL[c.key]?.color || c.color,
})))
function layerColor(key: string): string {
return LAYER_VISUAL[key]?.color || LAYER_CONFIG[key]?.color || '#635BFF'
}
function layerLabel(key: string): string {
return dimensions.find((d: any) => d.key === key)?.label || LAYER_CONFIG[key]?.label || key
}
const searchText = ref('')
const filterMode = ref<'all' | 'risk' | 'nodata'>('all')
const visibleLayers = reactive<Record<string, boolean>>({ finance: true, customer: true, process: true, learning: true })
const collapsedLayers = reactive<Record<string, boolean>>({})
const collapsedGroups = reactive<Record<string, boolean>>({})
const treeOpen = ref(true)
const selectedNode = ref<{ dimKey: string; index: number; obj: any } | null>(null)
const selectedKey = computed(() => (selectedNode.value ? `${selectedNode.value.dimKey}-${selectedNode.value.index}` : null))
const reviewSummary = ref<any>(null)
const financeSummary = ref<any>(null)
const trendMap = reactive<Record<string, { dir: 'up' | 'down' | 'flat' | null; deltaPct: number | null; period?: string | null }>>({})
const trendLoading = ref(false)
// 撤销栈(仅本页内存,不落库;最多 20 步)
const undoStack = ref<{ dimensions: any; connections: any[] }[]>([])
// 连线
const connections = ref<any[]>([])
const linkingFrom = ref<{ key: string; obj: any } | null>(null)
@@ -273,6 +420,7 @@ function onChainFocus(nodes: string[] | null) { chainFocusNodes.value = nodes }
/** 更新连线语义(effect/label),落库 */
function onUpdateConnection(idx: number, patch: any) {
if (!connections.value[idx]) return
pushUndo()
Object.assign(connections.value[idx], patch)
markDirty()
saveConnections()
@@ -321,14 +469,107 @@ function updateGridMode() {
// 节点状态(红黄绿灯)
const objectiveLevels = reactive<Record<string, string>>({})
interface KpiListItem {
kpi_id?: number
code: string
name: string
actual: number | null
target: number | null
unit: string | null
level?: string
ratio?: number | null
}
interface KpiProgressItem {
ratio: number
/** 达成率百分比(0-…),无数据为 null */
ratio: number | null
level: string
kpiList: { code: string; name: string; actual: number|null; target: number|null; unit: string|null }[]
planSummary: { total: number; pending: number; in_progress: number; completed: number; overdue: number } | null
kpiList: KpiListItem[]
planSummary: any | null
/** 有实测值/有实测值 KPI 数 */
hasData?: boolean
kpiTotal?: number
kpiWithData?: number
}
const kpiProgressMap = reactive<Record<string, KpiProgressItem>>({})
/** 达成率均值(仅统计有实测值的目标);null = 无数据 */
const overallAchievement = computed<number | null>(() => {
const vals = Object.values(kpiProgressMap)
.filter((p: any) => p.hasData && p.ratio != null)
.map((p: any) => Number(p.ratio))
if (vals.length === 0) return null
return vals.reduce((a, b) => a + b, 0) / vals.length
})
const achievementCount = computed(() =>
Object.values(kpiProgressMap).filter((p: any) => p.hasData && p.ratio != null).length
)
/** 搜索/筛选是否激活(激活时画布非匹配节点变淡) */
const filterActive = computed(() => searchText.value.trim() !== '' || filterMode.value !== 'all')
/** 全部节点 key → 名称(因果上下游展示用) */
const nodeLabelMap = computed<Record<string, string>>(() => {
const map: Record<string, string> = {}
for (const dim of dimensions) {
(dim.objectives || []).forEach((o: any, i: number) => { map[`${dim.key}-${i}`] = o.name || `目标 ${i + 1}` })
}
return map
})
function objMatches(o: any, p: any, kw: string): boolean {
if (!kw) return true
const hay = [
o?.name, o?.owner, o?.description,
...(p?.kpiList || []).map((k: any) => k.name),
...(o?.krs || []).map((k: any) => k.name),
].filter(Boolean).join(' ').toLowerCase()
return hay.includes(kw)
}
/** 左侧目标树:四层分组 + 搜索 + 只看风险/无数据 */
const treeGroups = computed(() => {
const kw = searchText.value.trim().toLowerCase()
return orderedLayerConfigs
.filter((cfg: any) => visibleLayers[cfg.key])
.map((cfg: any) => {
const objectives = (getLayerNodes(cfg.key) || []).map((o: any, i: number) => {
const key = `${cfg.key}-${i}`
const p = kpiProgressMap[key]
return {
key,
index: i,
dimKey: cfg.key,
name: o.name || `未命名目标 ${i + 1}`,
owner: o.owner || '',
level: p?.level || 'gray',
ratio: p?.ratio ?? null,
hasData: !!p?.hasData,
kpiCount: (o.kpis?.length || 0) || (p?.kpiList?.length || 0),
krs: o.krs || [],
raw: o,
}
}).filter((o: any) => {
if (!objMatches(o.raw, kpiProgressMap[o.key], kw)) return false
if (filterMode.value === 'risk') return o.level === 'red' || o.level === 'yellow'
if (filterMode.value === 'nodata') return !o.hasData
return true
})
return { key: cfg.key, label: cfg.label, color: layerColor(cfg.key), objectives }
})
})
/** 当前筛选命中节点(画布变淡判据) */
const matchedKeyList = computed<string[]>(() =>
treeGroups.value.flatMap((g: any) => g.objectives.map((o: any) => o.key))
)
const totalObjectiveCount = computed(() =>
dimensions.reduce((n: number, d: any) => n + (d.objectives?.length || 0), 0)
)
const visibleObjectiveCount = computed(() =>
treeGroups.value.reduce((n: number, g: any) => n + g.objectives.length, 0)
)
// 弹窗
const showNodeDialog = ref(false)
const editingNodeData = ref<any>(null)
@@ -357,6 +598,12 @@ function onSaveDialog(data: any) {
const showVersions = ref(false)
const versions = ref<any[]>([])
// 数据完备度弹窗 + 版本回滚确认(MyDialog,替代 ElMessageBox.confirm)
const showCompleteness = ref(false)
const showRollbackConfirm = ref(false)
const rollbacking = ref(false)
const pendingRollbackVerId = ref<number | null>(null)
const showCausalityDialog = ref(false)
const causalityRecommendations = ref<any[]>([])
const causalityLoading = ref(false)
@@ -496,6 +743,8 @@ function loadCanvas() {
chainFocusKey.value = null
chainHoverNodes.value = null
chainFocusNodes.value = null
selectedNode.value = null
reviewSummary.value = null
loading.value = true
api.get("/maps").then((r: any) => {
@@ -577,14 +826,132 @@ function onNodeClick(dimKey: string, oi: number, obj: any) {
chainFocusKey.value = chainFocusKey.value === key ? null : key
return
}
// 有KPI则展示详情
if (kpiProgressMap[key]?.kpiList?.length > 0) {
showKpiDetailModal(dimKey, oi, obj)
} else {
openEditDialog(dimKey, oi, obj)
// 常规:选中节点 → 右侧详情面板(2026-10-18)
selectNode(dimKey, oi, obj)
}
/** 选中目标并在右侧面板展示详情(真数据来自 /review + /kpis/{id}/values) */
function selectNode(dimKey: string, oi: number, obj: any) {
selectedNode.value = { dimKey, index: oi, obj }
loadTrends(obj)
}
function onTreeNodeSelect(o: any) {
const cur = dimensions.find((d: any) => d.key === o.dimKey)?.objectives?.[o.index]
if (cur) selectNode(o.dimKey, o.index, cur)
}
/** 从详情面板的因果 chip 跳到对应节点 */
function selectByKey(key: string) {
const [dimKey, idxRaw] = key.split('-')
const idx = Number(idxRaw)
const obj = dimensions.find((d: any) => d.key === dimKey)?.objectives?.[idx]
if (obj) selectNode(dimKey, idx, obj)
}
function closeDetail() {
selectedNode.value = null
}
/** KPI 趋势:取每支 KPI 最近两期实测值(接口真实数据;不足两期=无趋势) */
async function loadTrends(obj: any) {
for (const key of Object.keys(trendMap)) delete trendMap[key]
if (!obj) return
const progress = kpiProgressMap[selectedKey.value || '']
const list = (progress?.kpiList || []).filter((k: any) => k.kpi_id)
if (list.length === 0) return
trendLoading.value = true
try {
await Promise.all(list.map(async (k: any) => {
try {
const r: any = await api.get(`/kpis/${k.kpi_id}/values`)
const vals = (r?.data || [])
.filter((v: any) => v.actual_value != null)
.sort((a: any, b: any) => String(b.period).localeCompare(String(a.period)))
if (vals.length === 0) return
const latest = Number(vals[0].actual_value)
if (vals.length < 2) {
trendMap[k.code] = { dir: null, deltaPct: null, period: vals[0].period }
return
}
const prev = Number(vals[1].actual_value)
const delta = latest - prev
const deltaPct = prev !== 0 ? (delta / Math.abs(prev)) * 100 : null
trendMap[k.code] = {
dir: Math.abs(delta) < 1e-9 ? 'flat' : delta > 0 ? 'up' : 'down',
deltaPct,
period: vals[0].period,
}
} catch { /* 单支 KPI 取数失败不影响其它 */ }
}))
} finally {
trendLoading.value = false
}
}
// ── 层筛选 / 层折叠 ──
function toggleLayer(key: string) {
visibleLayers[key] = !visibleLayers[key]
nextTick(() => { updateGridMode(); triggerRecalc(); updateZoomWrapHeight() })
}
function toggleLayerCollapse(key: string) {
collapsedLayers[key] = !collapsedLayers[key]
nextTick(() => { triggerRecalc(); updateZoomWrapHeight() })
}
// ── 撤销(本页内存栈) ──
function pushUndo() {
undoStack.value.push({
dimensions: JSON.parse(JSON.stringify(dimensions.map((d: any) => ({
key: d.key, label: d.label, name: d.label || d.name, icon: d.icon, color: d.color, objectives: d.objectives,
})))),
connections: JSON.parse(JSON.stringify(connections.value)),
})
if (undoStack.value.length > 20) undoStack.value.shift()
}
function undoLast() {
const snap = undoStack.value.pop()
if (!snap) return
dimensions.splice(0, dimensions.length, ...JSON.parse(JSON.stringify(snap.dimensions)))
connections.value = JSON.parse(JSON.stringify(snap.connections))
selectedNode.value = null
markDirty()
nextTick(() => { triggerRecalc(); updateZoomWrapHeight(); loadObjectiveLevels() })
ElMessage.success('已撤销上一步')
}
// ── 目标树 → 画布拖拽建因果连线(复用 completeConnection / dragConnectTarget) ──
const treeDragSource = ref<{ key: string; obj: any } | null>(null)
function onTreeDragStart(payload: { dimKey: string; index: number; obj: any }) {
treeDragSource.value = { key: `${payload.dimKey}-${payload.index}`, obj: payload.obj }
startLink(payload.dimKey, payload.index, payload.obj)
}
function onTreeDragOver(e: DragEvent) {
if (!treeDragSource.value) return
const el = document.elementFromPoint(e.clientX, e.clientY)?.closest('[data-node-key]') as HTMLElement | null
const key = el?.getAttribute('data-node-key') || null
dragConnectTarget.value = key && key !== treeDragSource.value.key ? key : null
}
function onTreeDragLeave(e: DragEvent) {
const related = (e as any).relatedTarget as Node | null
const box = scrollWrapRef.value
if (box && related && box.contains(related)) return
dragConnectTarget.value = null
}
function onTreeDrop() {
const target = dragConnectTarget.value
const src = treeDragSource.value
dragConnectTarget.value = null
treeDragSource.value = null
if (src && target && target !== src.key) completeConnection(src.key, target)
else cancelLink()
}
function onTreeDragEnd() {
treeDragSource.value = null
dragConnectTarget.value = null
cancelLink()
}
/**
* 层内重排完成回调 — 由 vuedraggable 在拖拽排序后触发
* SortableJS 已通过 :list 引用直接更新了响应式数组,
@@ -602,6 +969,7 @@ function completeConnection(fromKey: string, toKey: string) {
if (connections.value.some((c: any) => c.from === fromKey && c.to === toKey)) {
ElMessage.warning("已存在相同连线"); cancelLink(); return
}
pushUndo()
api.post(`/maps/${selectedMap.value}/connections`, {
from: fromKey, to: toKey,
}).then((r: any) => {
@@ -770,6 +1138,7 @@ async function onManagerAddConnection(payload: { from: string; to: string; effec
function onManagerDeleteConnection(idx: number) {
const conn = connections.value[idx]
if (!conn) return
pushUndo()
api.delete(`/maps/${selectedMap.value}/connections`, { data: { from: conn.from, to: conn.to } })
.then((r: any) => {
if (r?.updated_at) baseUpdatedAt.value = r.updated_at
@@ -792,6 +1161,7 @@ function onDeleteConnection(idx: number) {
if (selectedConnIdx.value !== idx) return
const conn = connections.value[idx]
if (!conn) return
pushUndo()
api.delete(`/maps/${selectedMap.value}/connections`, {
data: { from: conn.from, to: conn.to },
}).then((r: any) => {
@@ -809,7 +1179,8 @@ function onDeleteConnection(idx: number) {
// ── 节点CRUD ──
function openAddDialog(layerKey: string) {
editingLayerKey.value = layerKey
const key = layerKey && LAYER_CONFIG[layerKey] ? layerKey : 'finance'
editingLayerKey.value = key
editingNodeData.value = null
dialogForm.name = ''
dialogForm.description = ''
@@ -848,6 +1219,7 @@ function openEditDialog(dimKey: string, oi: number, obj: any) {
}
function onNodeSave(data: any) {
pushUndo()
const layer = editingLayerKey.value || data.layer || data.dimKey
const dim = dimensions.find((d: any) => d.key === layer)
if (!dim) return
@@ -979,6 +1351,7 @@ async function deleteNode(dimKey: string, oi: number) {
const dim = dimensions.find((d: any) => d.key === dimKey)
if (dim) {
const key = `${dimKey}-${oi}`
pushUndo()
connections.value = connections.value.filter(c => c.from !== key && c.to !== key)
dim.objectives.splice(oi, 1)
ElMessage.success("节点已删除")
@@ -1002,6 +1375,7 @@ function onAddKr(dimKey: string, oi: number, obj: any) {
ElMessage.warning('每个目标最多5个关键结果')
return
}
pushUndo()
target.krs.push({
name: "",
target_value: "",
@@ -1055,6 +1429,7 @@ async function confirmDeleteKr() {
const krId: number | null = kr._kr_id || kr.id || null
const removedName = kr.name || `KR ${t.ki + 1}`
// 1) 本地移除(界面即时无残留)
pushUndo()
target.krs.splice(t.ki, 1)
cancelDeleteKr()
try {
@@ -1321,13 +1696,22 @@ async function loadVersions() {
}
async function rollback(verId: number) {
pendingRollbackVerId.value = verId
showRollbackConfirm.value = true
}
async function confirmRollback() {
if (pendingRollbackVerId.value == null) return
rollbacking.value = true
try {
await ElMessageBox.confirm("确定回滚到此版本?当前内容将被覆盖。")
await api.post(`/maps/${selectedMap.value}/versions/${verId}/rollback`)
await api.post(`/maps/${selectedMap.value}/versions/${pendingRollbackVerId.value}/rollback`)
ElMessage.success("回滚成功,刷新页面")
showRollbackConfirm.value = false
window.location.reload()
} catch (e: any) {
if (e !== "cancel") ElMessage.error("回滚失败")
ElMessage.error("回滚失败")
} finally {
rollbacking.value = false
}
}
@@ -1336,46 +1720,54 @@ async function loadObjectiveLevels() {
if (!selectedMap.value) return
try {
const r: any = await api.get(`/maps/${selectedMap.value}/review?level=strategic`)
reviewSummary.value = r.summary || null
for (const key of Object.keys(objectiveLevels)) delete objectiveLevels[key]
for (const key of Object.keys(kpiProgressMap)) delete kpiProgressMap[key]
for (const key of Object.keys(trendMap)) delete trendMap[key]
const dims = r.dimensions || []
for (const dim of dims) {
const dimKey = dim.key
const localDim = dimensions.find((d: any) => d.key === dimKey)
if (!localDim) continue
for (let i = 0; i < localDim.objectives.length; i++) {
const reviewObjs = dim.objectives || []
const count = Math.max(localDim.objectives.length, reviewObjs.length)
for (let i = 0; i < count; i++) {
const key = `${dimKey}-${i}`
kpiProgressMap[key] = { ratio: 0, level: 'gray', kpiList: [], planSummary: null }
objectiveLevels[key] = 'gray'
}
dim.objectives.forEach((obj: any) => {
const localIdx = localDim.objectives.findIndex((o: any) => o.name === obj.name)
if (localIdx >= 0) {
const key = `${dimKey}-${localIdx}`
objectiveLevels[key] = obj.level || 'gray'
const kpis = obj.kpis || []
const kpiList: any[] = []
let totalRatio = 0
let count = 0
for (const k of kpis) {
kpiList.push({
code: k.kpi_code, name: k.kpi_name, actual: k.actual_value, target: k.target_value, unit: k.unit,
})
if (k.actual_value != null && k.target_value) {
totalRatio += k.actual_value / k.target_value; count++
}
}
if (kpiList.length > 0) {
kpiProgressMap[key] = {
ratio: count > 0 ? Math.round((totalRatio / count) * 100) : 0,
level: obj.level || 'gray', kpiList,
planSummary: obj.action_plan_summary || null,
}
} else if (obj.action_plan_summary?.total > 0) {
kpiProgressMap[key] = { ratio: 0, level: obj.level || 'gray', kpiList: [], planSummary: obj.action_plan_summary }
}
const localObj = localDim.objectives[i]
let robj = reviewObjs[i]
// 后端按目标顺序返回(含无 KPI 目标)⇒ 索引对齐;名称不一致时按名称兜底
if (robj && localObj && robj.name !== localObj.name) {
robj = reviewObjs.find((o: any) => o.name === localObj.name) || robj
}
})
if (!robj) continue
objectiveLevels[key] = robj.level || 'gray'
const kpis: KpiListItem[] = (robj.kpis || []).map((k: any) => ({
kpi_id: k.kpi_id,
code: k.kpi_code,
name: k.kpi_name || k.kpi_code,
actual: k.actual_value ?? null,
target: k.target_value ?? null,
unit: k.unit ?? null,
level: k.level,
ratio: k.ratio != null ? Number(k.ratio) * 100 : null,
}))
const hasData = robj.has_actual === true && robj.ratio != null
kpiProgressMap[key] = {
ratio: hasData ? Math.round(Number(robj.ratio) * 100) : null,
level: robj.level || 'gray',
kpiList: kpis,
planSummary: robj.action_plan_summary || null,
hasData,
kpiTotal: robj.kpi_total != null ? robj.kpi_total : kpis.length,
kpiWithData: robj.kpi_with_data != null ? robj.kpi_with_data : kpis.filter(k => k.ratio != null).length,
}
}
}
// 选中节点仍有效则刷新引用(撤销/保存后保持详情面板数据一致)
if (selectedNode.value) {
const cur = dimensions.find((d: any) => d.key === selectedNode.value!.dimKey)?.objectives?.[selectedNode.value!.index]
if (cur) selectedNode.value = { ...selectedNode.value, obj: cur }
else selectedNode.value = null
}
} catch { /* silent */ }
}
@@ -1432,6 +1824,25 @@ function goAlignment() {
window.location.href = url
}
function goKpiLibrary() {
window.location.href = '/kpis'
}
/** KPI 摘要条(营收/毛利率):来源 /dashboard/finance-analysis,缺失即 null(不造数) */
async function loadFinanceSummary() {
try {
const r: any = await api.get('/dashboard/finance-analysis')
const s = r?.summary || {}
financeSummary.value = {
total_sales: s.total_sales ?? null,
gross_profit_rate: s.gross_profit_rate ?? null,
period: r?.period || '',
}
} catch {
financeSummary.value = null
}
}
/** 导出画布为PNG图片(transform缩放下截图不完整:临时回100%导出) */
async function exportImage() {
const el = document.querySelector('.canvas-scroll-wrap') as HTMLElement
@@ -1505,6 +1916,7 @@ async function applyAllCausality() {
// 滚动触发连线重算(rAF节流,避免滚动/拖拽时卡顿)
let scrollRaf = 0
let resizeObserver: ResizeObserver | null = null
function onCanvasScroll() {
if (scrollRaf) return
scrollRaf = requestAnimationFrame(() => {
@@ -1517,6 +1929,12 @@ function onWindowResize() {
updateZoomWrapHeight()
triggerRecalc()
}
/** 账套切换(同浏览器跨标签页):entity 变化即整页重载,杜绝跨实体混数据 */
function onStorageEntityChange(e: StorageEvent) {
if (e.key === 'cma_entity_id' && e.newValue !== e.oldValue) {
window.location.reload()
}
}
onMounted(async () => {
const r: any = await mapApi.list()
@@ -1532,6 +1950,7 @@ onMounted(async () => {
ensureFourLayers()
loading.value = false
}
void loadFinanceSummary()
const k: any = await kpiApi.list({ page_size: 100 })
allKpis.value = k.data || []
for (const kpi of allKpis.value) {
@@ -1553,12 +1972,24 @@ onMounted(async () => {
})
scrollWrapRef.value?.addEventListener('scroll', onCanvasScroll, { passive: true })
window.addEventListener('resize', onWindowResize)
window.addEventListener('storage', onStorageEntityChange)
// 目标树/详情面板/层筛选改变画布宽度时重算连线(不依赖窗口 resize)
if (scrollWrapRef.value && typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => {
updateZoomWrapHeight()
nextTick(() => connectionLinesRef.value?.recalcAll())
})
resizeObserver.observe(scrollWrapRef.value)
}
})
onUnmounted(() => {
disposed = true // 先置位:阻断仍在 await 中的 onMounted 续段再注册定时器(防泄漏)
scrollWrapRef.value?.removeEventListener('scroll', onCanvasScroll)
window.removeEventListener('resize', onWindowResize)
window.removeEventListener('storage', onStorageEntityChange)
resizeObserver?.disconnect()
resizeObserver = null
if (saveTimer) clearTimeout(saveTimer)
stopAutoSave()
})
@@ -1581,6 +2012,8 @@ onUnmounted(() => {
color: #b88230;
}
.chain-mode-bar span { flex: 1; }
.chain-ico { display: inline-flex; align-items: center; gap: 6px; }
.chain-ico svg { width: 14px; height: 14px; flex: 0 0 14px; }
.empty-state {
flex: 1; display: flex; align-items: center; justify-content: center;
@@ -1661,7 +2094,62 @@ onUnmounted(() => {
.kr-del-confirm-hint {
margin-top: 8px;
font-size: 12px;
color: #909399;
color: var(--bh-text-secondary);
line-height: 1.5;
}
/* ── 仪表盘主视图三栏(2026-10-18 V1+V3 合版)── */
.map-body-row {
flex: 1;
min-height: 0;
display: flex;
gap: 10px;
align-items: stretch;
}
.map-canvas-col {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.map-detail-empty {
width: 320px;
flex: 0 0 320px;
border: 1px dashed #E5E7EB;
border-radius: 8px;
background: #FCFCFD;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: #94A3B8;
padding: 20px;
text-align: center;
}
.map-detail-empty svg { width: 28px; height: 28px; color: #CBD5E1; }
.map-detail-empty p { margin: 0; font-size: 12px; line-height: 1.7; }
.map-legend {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 14px;
padding: 6px 2px 0;
font-size: 11px;
color: #64748B;
flex-wrap: wrap;
}
.lg-item { display: inline-flex; align-items: center; gap: 4px; }
.lg-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.lg-dot.lv-green { background: #00C896; }
.lg-dot.lv-yellow { background: #F59E0B; }
.lg-dot.lv-red { background: #EF4444; }
.lg-dot.lv-gray { background: #CBD5E1; }
.lg-note { margin-left: auto; color: #94A3B8; }
@media (max-width: 1280px) {
.map-detail-empty { display: none; }
}
</style>