wip(strategy-map): V1+V3 合版 + v2/v3 口径(完备度/C1 hold/57 归档组)
This commit is contained in:
@@ -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>风险 <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>
|
||||
|
||||
Reference in New Issue
Block a user