feat: 战略地图贝塞尔因果链+节点KPI可视化(P0+P1)
This commit is contained in:
@@ -186,7 +186,13 @@ def add_connection(map_id: int, data: dict, db: Session = Depends(get_db)):
|
|||||||
if c.get("from") == from_id and c.get("to") == to_id:
|
if c.get("from") == from_id and c.get("to") == to_id:
|
||||||
raise HTTPException(400, "已存在相同的连线")
|
raise HTTPException(400, "已存在相同的连线")
|
||||||
|
|
||||||
conns.append({"from": from_id, "to": to_id, "style": "solid"})
|
conns.append({
|
||||||
|
"from": from_id,
|
||||||
|
"to": to_id,
|
||||||
|
"style": "solid",
|
||||||
|
"effect": data.get("effect", "positive"),
|
||||||
|
"label": data.get("label", ""),
|
||||||
|
})
|
||||||
m.canvas_data["connections"] = conns
|
m.canvas_data["connections"] = conns
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"connections": conns}
|
return {"connections": conns}
|
||||||
@@ -418,13 +424,13 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
|
|||||||
lv = latest_values.get(kpi_def.id, {})
|
lv = latest_values.get(kpi_def.id, {})
|
||||||
actual = lv.get("actual_value")
|
actual = lv.get("actual_value")
|
||||||
target = kpi_def.target_value
|
target = kpi_def.target_value
|
||||||
# 判断红黄绿灯
|
# 判断红黄绿灯(绿≥90% / 黄60-90% / 红<60%)
|
||||||
level = "gray"
|
level = "gray"
|
||||||
if actual is not None and target:
|
if actual is not None and target:
|
||||||
ratio = actual / target
|
ratio = actual / target
|
||||||
if ratio >= 0.9:
|
if ratio >= 0.9:
|
||||||
level = "green"
|
level = "green"
|
||||||
elif ratio >= 0.7:
|
elif ratio >= 0.6:
|
||||||
level = "yellow"
|
level = "yellow"
|
||||||
else:
|
else:
|
||||||
level = "red"
|
level = "red"
|
||||||
|
|||||||
@@ -23,6 +23,12 @@
|
|||||||
<input type="radio" :checked="viewDirection === 'causal'" @change="$emit('update:viewDirection', 'causal')" /> 🔗 因果
|
<input type="radio" :checked="viewDirection === 'causal'" @change="$emit('update:viewDirection', 'causal')" /> 🔗 因果
|
||||||
</label>
|
</label>
|
||||||
</span>
|
</span>
|
||||||
|
<el-button
|
||||||
|
:type="chainMode ? 'warning' : 'default'"
|
||||||
|
:class="{ 'chain-mode-on': chainMode }"
|
||||||
|
@click="$emit('toggle-chain-mode')"
|
||||||
|
title="点击节点可聚焦查看其相关因果链,其余自动变淡"
|
||||||
|
>🔍 因果链查看{{ chainMode ? '中' : '' }}</el-button>
|
||||||
<el-button @click="$emit('show-causality')">🔗 因果链推荐</el-button>
|
<el-button @click="$emit('show-causality')">🔗 因果链推荐</el-button>
|
||||||
<el-button type="primary" @click="$emit('save-canvas')">保存</el-button>
|
<el-button type="primary" @click="$emit('save-canvas')">保存</el-button>
|
||||||
<el-button type="success" @click="$emit('publish-map')" v-if="currentMap?.status === 'draft'">发布</el-button>
|
<el-button type="success" @click="$emit('publish-map')" v-if="currentMap?.status === 'draft'">发布</el-button>
|
||||||
@@ -54,6 +60,7 @@ const props = defineProps<{
|
|||||||
viewDirection: string
|
viewDirection: string
|
||||||
currentMap: any
|
currentMap: any
|
||||||
linkingFrom: { key: string; obj: any } | null
|
linkingFrom: { key: string; obj: any } | null
|
||||||
|
chainMode: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -70,6 +77,7 @@ const emit = defineEmits<{
|
|||||||
(e: 'show-versions'): void
|
(e: 'show-versions'): void
|
||||||
(e: 'go-alignment'): void
|
(e: 'go-alignment'): void
|
||||||
(e: 'cancel-link'): void
|
(e: 'cancel-link'): void
|
||||||
|
(e: 'toggle-chain-mode'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const selectedMapLocal = ref(props.selectedMap)
|
const selectedMapLocal = ref(props.selectedMap)
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<svg class="connection-svg" ref="svgRef">
|
<svg class="connection-svg" ref="svgRef">
|
||||||
<defs>
|
<defs>
|
||||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
|
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="8" refY="3.5" orient="auto">
|
||||||
<polygon points="0 0, 10 3.5, 0 7" fill="#409eff" />
|
<polygon points="0 0, 10 3.5, 0 7" fill="#409eff" />
|
||||||
</marker>
|
</marker>
|
||||||
<marker id="arrowhead-warn" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
|
<marker id="arrowhead-warn" markerWidth="10" markerHeight="7" refX="8" refY="3.5" orient="auto">
|
||||||
<polygon points="0 0, 10 3.5, 0 7" fill="#e6a23c" />
|
<polygon points="0 0, 10 3.5, 0 7" fill="#e6a23c" />
|
||||||
</marker>
|
</marker>
|
||||||
|
<marker id="arrowhead-red" markerWidth="10" markerHeight="7" refX="8" refY="3.5" orient="auto">
|
||||||
|
<polygon points="0 0, 10 3.5, 0 7" fill="#f56c6c" />
|
||||||
|
</marker>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<!-- 固定跨层箭头:从下层泳道到上层泳道(因果连线) -->
|
<!-- 固定跨层箭头:贝塞尔曲线(从下层泳道到上层泳道) -->
|
||||||
<path
|
<path
|
||||||
v-for="(arrow, idx) in fixedLayerArrows"
|
v-for="(arrow, idx) in fixedLayerArrows"
|
||||||
:key="'fixed-' + idx"
|
:key="'fixed-' + idx"
|
||||||
@@ -24,20 +27,43 @@
|
|||||||
<title>{{ arrow.from }} → {{ arrow.to }}</title>
|
<title>{{ arrow.from }} → {{ arrow.to }}</title>
|
||||||
</path>
|
</path>
|
||||||
|
|
||||||
<!-- 用户自定义连线 -->
|
<!-- 用户自定义连线:贝塞尔曲线 + 语义样式 + 因果链高亮 -->
|
||||||
<line
|
<g v-for="(line, idx) in connectionLines" :key="'conn-' + idx">
|
||||||
v-for="(line, idx) in connectionLines"
|
<path
|
||||||
:key="'conn-' + idx"
|
:d="line.path"
|
||||||
:x1="line.x1" :y1="line.y1" :x2="line.x2" :y2="line.y2"
|
:class="['conn-line', {
|
||||||
:class="['conn-line', { 'conn-selected': selectedConnIdx === idx }]"
|
'conn-selected': selectedConnIdx === idx,
|
||||||
:style="{ '--from-color': line.fromColor, '--to-color': line.toColor }"
|
'conn-chain': chainActive && chainConnIdxs.includes(idx),
|
||||||
marker-end="url(#arrowhead)"
|
'conn-dim': chainActive && !chainConnIdxs.includes(idx),
|
||||||
:stroke="selectedConnIdx === idx ? '#f56c6c' : '#409eff'"
|
}]"
|
||||||
:stroke-width="selectedConnIdx === idx ? 3 : 2"
|
:style="{ '--from-color': line.fromColor, '--to-color': line.toColor }"
|
||||||
@click.stop="$emit('select-connection', idx)"
|
:stroke="strokeFor(line, idx)"
|
||||||
@mouseenter="hoverConnIdx = idx"
|
:stroke-width="selectedConnIdx === idx ? 3 : (chainActive && chainConnIdxs.includes(idx) ? 3.5 : 2)"
|
||||||
@mouseleave="hoverConnIdx = null"
|
:stroke-dasharray="isNegative(line) ? '7,4' : 'none'"
|
||||||
/>
|
fill="none"
|
||||||
|
:marker-end="markerFor(line, idx)"
|
||||||
|
@click.stop="$emit('select-connection', idx)"
|
||||||
|
@mouseenter="onConnEnter(idx)"
|
||||||
|
@mouseleave="onConnLeave"
|
||||||
|
/>
|
||||||
|
<!-- 连线语义标签 -->
|
||||||
|
<g
|
||||||
|
v-if="line.label"
|
||||||
|
:class="['conn-label', { 'conn-label-dim': chainActive && !chainConnIdxs.includes(idx) }]"
|
||||||
|
@click.stop="$emit('select-connection', idx)"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
:x="line.mx - labelW(line) / 2"
|
||||||
|
:y="line.my - 22"
|
||||||
|
:width="labelW(line)"
|
||||||
|
height="16"
|
||||||
|
rx="8"
|
||||||
|
class="conn-label-bg"
|
||||||
|
:style="{ '--label-color': isNegative(line) ? '#f56c6c' : '#409eff' }"
|
||||||
|
/>
|
||||||
|
<text :x="line.mx" :y="line.my - 10.5" text-anchor="middle" font-size="10.5" class="conn-label-text">{{ line.label }}</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
<!-- 连线提示浮层 -->
|
<!-- 连线提示浮层 -->
|
||||||
<g v-if="hoverConnIdx !== null && connectionLines[hoverConnIdx] && hoverConnIdx !== selectedConnIdx">
|
<g v-if="hoverConnIdx !== null && connectionLines[hoverConnIdx] && hoverConnIdx !== selectedConnIdx">
|
||||||
@@ -45,11 +71,13 @@
|
|||||||
:x="connectionLines[hoverConnIdx].mx - 60"
|
:x="connectionLines[hoverConnIdx].mx - 60"
|
||||||
:y="connectionLines[hoverConnIdx].my - 36"
|
:y="connectionLines[hoverConnIdx].my - 36"
|
||||||
width="120" height="22" rx="4" fill="rgba(0,0,0,0.65)"
|
width="120" height="22" rx="4" fill="rgba(0,0,0,0.65)"
|
||||||
|
class="conn-hover-bg"
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
:x="connectionLines[hoverConnIdx].mx"
|
:x="connectionLines[hoverConnIdx].mx"
|
||||||
:y="connectionLines[hoverConnIdx].my - 21"
|
:y="connectionLines[hoverConnIdx].my - 21"
|
||||||
text-anchor="middle" fill="#fff" font-size="11"
|
text-anchor="middle" fill="#fff" font-size="11"
|
||||||
|
class="conn-hover-text"
|
||||||
>点击选中连线</text>
|
>点击选中连线</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
@@ -71,10 +99,13 @@
|
|||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- 绘制中的临时线 -->
|
<!-- 绘制中的临时线 -->
|
||||||
<line v-if="tempLine"
|
<path
|
||||||
:x1="tempLine.x1" :y1="tempLine.y1"
|
v-if="tempLine"
|
||||||
:x2="tempLine.x2" :y2="tempLine.y2"
|
:d="tempLinePath"
|
||||||
stroke="#e6a23c" stroke-width="2" stroke-dasharray="6,3"
|
fill="none"
|
||||||
|
stroke="#e6a23c"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-dasharray="6,3"
|
||||||
marker-end="url(#arrowhead-warn)"
|
marker-end="url(#arrowhead-warn)"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -82,6 +113,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
import { getChainForConnection } from '../../utils/chainGraph'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
connectionLines: any[]
|
connectionLines: any[]
|
||||||
@@ -93,10 +125,66 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'select-connection', idx: number): void
|
(e: 'select-connection', idx: number): void
|
||||||
(e: 'delete-connection', idx: number): void
|
(e: 'delete-connection', idx: number): void
|
||||||
|
(e: 'chain-hover', nodes: string[] | null): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const svgRef = ref<SVGSVGElement | null>(null)
|
const svgRef = ref<SVGSVGElement | null>(null)
|
||||||
const hoverConnIdx = ref<number | null>(null)
|
const hoverConnIdx = ref<number | null>(null)
|
||||||
|
const hoverChain = ref<{ nodes: string[]; connIdxs: number[] } | null>(null)
|
||||||
|
|
||||||
|
const chainActive = computed(() => hoverChain.value !== null)
|
||||||
|
const chainConnIdxs = computed<number[]>(() => hoverChain.value?.connIdxs || [])
|
||||||
|
|
||||||
|
function isNegative(line: any) {
|
||||||
|
return line && line.effect === 'negative'
|
||||||
|
}
|
||||||
|
|
||||||
|
function strokeFor(line: any, idx: number) {
|
||||||
|
if (selectedConnIdx.value === idx) return '#f56c6c'
|
||||||
|
if (chainActive.value && chainConnIdxs.value.includes(idx)) return '#e6a23c'
|
||||||
|
if (chainActive.value) return '#c0c4cc'
|
||||||
|
return isNegative(line) ? '#f56c6c' : '#409eff'
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerFor(line: any, idx: number) {
|
||||||
|
if (selectedConnIdx.value === idx) return 'url(#arrowhead-red)'
|
||||||
|
if (chainActive.value && chainConnIdxs.value.includes(idx)) return 'url(#arrowhead-warn)'
|
||||||
|
return isNegative(line) ? 'url(#arrowhead-red)' : 'url(#arrowhead)'
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelW(line: any) {
|
||||||
|
return (line.label ? line.label.length : 0) * 12 + 16
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConnEnter(idx: number) {
|
||||||
|
hoverConnIdx.value = idx
|
||||||
|
const chain = getChainForConnection(props.connectionLines.map(toConnLike), idx)
|
||||||
|
hoverChain.value = chain
|
||||||
|
emit('chain-hover', chain ? chain.nodes : null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConnLeave() {
|
||||||
|
hoverConnIdx.value = null
|
||||||
|
hoverChain.value = null
|
||||||
|
emit('chain-hover', null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** connectionLines 条目(含 x1/y1/x2/y2)转成 {from,to} 形状供链算法使用 */
|
||||||
|
function toConnLike(line: any, idx: number) {
|
||||||
|
const conns = props.connectionLines
|
||||||
|
// 从原数据推断 from/to:算法只需 from/to 字段
|
||||||
|
return {
|
||||||
|
from: line.from || `n-${idx}`,
|
||||||
|
to: line.to || `n-${idx}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempLinePath = computed(() => {
|
||||||
|
if (!props.tempLine) return ''
|
||||||
|
const { x1, y1, x2, y2 } = props.tempLine
|
||||||
|
const dx = Math.max(Math.abs(x2 - x1) * 0.5, 40)
|
||||||
|
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`
|
||||||
|
})
|
||||||
|
|
||||||
defineExpose({ svgRef })
|
defineExpose({ svgRef })
|
||||||
</script>
|
</script>
|
||||||
@@ -108,11 +196,17 @@ defineExpose({ svgRef })
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
.conn-line {
|
.conn-line {
|
||||||
cursor: pointer; transition: stroke .15s, stroke-width .15s;
|
cursor: pointer; transition: stroke .15s, stroke-width .15s, opacity .2s;
|
||||||
pointer-events: stroke;
|
pointer-events: stroke;
|
||||||
}
|
}
|
||||||
.conn-line:hover { stroke: #e6a23c !important; stroke-width: 4 !important; cursor: pointer; }
|
.conn-line:hover { stroke: #e6a23c !important; stroke-width: 4 !important; cursor: pointer; }
|
||||||
|
.conn-line.conn-chain { filter: drop-shadow(0 0 3px rgba(230,162,60,0.65)); }
|
||||||
|
.conn-line.conn-dim { opacity: 0.25; }
|
||||||
.conn-selected { stroke: #f56c6c !important; stroke-width: 3; }
|
.conn-selected { stroke: #f56c6c !important; stroke-width: 3; }
|
||||||
|
.conn-label { cursor: pointer; pointer-events: all; transition: opacity .2s; }
|
||||||
|
.conn-label-dim { opacity: 0.25; }
|
||||||
|
.conn-label-bg { fill: rgba(255,255,255,0.92); stroke: var(--label-color, #409eff); stroke-width: 1; }
|
||||||
|
.conn-label-text { fill: #303133; font-weight: 600; pointer-events: none; }
|
||||||
.del-btn-bg { cursor: pointer; pointer-events: all; }
|
.del-btn-bg { cursor: pointer; pointer-events: all; }
|
||||||
.del-btn-bg:hover { fill: #e74c3c !important; }
|
.del-btn-bg:hover { fill: #e74c3c !important; }
|
||||||
.del-btn-text { cursor: pointer; pointer-events: all; user-select: none; }
|
.del-btn-text { cursor: pointer; pointer-events: all; user-select: none; }
|
||||||
@@ -120,4 +214,8 @@ defineExpose({ svgRef })
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
.conn-hover-bg,
|
||||||
|
.conn-hover-text {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
'node-level-yellow': level === 'yellow',
|
'node-level-yellow': level === 'yellow',
|
||||||
'node-level-green': level === 'green',
|
'node-level-green': level === 'green',
|
||||||
'kr-expanded': krExpanded,
|
'kr-expanded': krExpanded,
|
||||||
|
'chain-dimmed': chainDimmed,
|
||||||
}"
|
}"
|
||||||
:draggable="draggable"
|
:draggable="draggable"
|
||||||
@dragstart="onDragStart"
|
@dragstart="onDragStart"
|
||||||
@@ -83,33 +84,37 @@
|
|||||||
@click.stop="$emit('kpi-click', kpi)"
|
@click.stop="$emit('kpi-click', kpi)"
|
||||||
>{{ getKpiName(kpi) }}</el-tag>
|
>{{ getKpiName(kpi) }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<!-- KPI达成率迷你进度条 -->
|
<!-- KPI达成率迷你进度条(右下角) -->
|
||||||
<div v-if="progressData" class="node-progress-bar">
|
<div v-if="progressData" class="node-progress-bar">
|
||||||
<div class="node-progress-fill"
|
<span class="node-progress-label">达成率</span>
|
||||||
:style="{
|
<div class="node-progress-track">
|
||||||
width: Math.max(progressData.ratio, 4) + '%',
|
<div class="node-progress-fill"
|
||||||
background: progressColor(progressData.level)
|
:style="{
|
||||||
}"
|
width: Math.max(progressData.ratio, 4) + '%',
|
||||||
></div>
|
background: progressColor(progressData.level)
|
||||||
<span class="node-progress-text">
|
}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="node-progress-text" :style="{ color: progressColor(progressData.level) }">
|
||||||
{{ progressData.ratio > 0 ? progressData.ratio + '%' : '—' }}
|
{{ progressData.ratio > 0 ? progressData.ratio + '%' : '—' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- KPI实际值快照 -->
|
<!-- KPI实际值快照:当前值 / 目标值 / 达成率 -->
|
||||||
<div v-if="progressData?.kpiList?.length" class="node-kpi-snapshots">
|
<div v-if="progressData?.kpiList?.length" class="node-kpi-snapshots">
|
||||||
<div
|
<div
|
||||||
v-for="item in progressData.kpiList.slice(0, 2)" :key="item.code"
|
v-for="item in progressData.kpiList.slice(0, 3)" :key="item.code"
|
||||||
class="kpi-snapshot-row"
|
class="kpi-snapshot-row"
|
||||||
:class="'snap-' + getKpiLevel(item)"
|
:class="'snap-' + getKpiLevel(item)"
|
||||||
>
|
>
|
||||||
<span class="snap-name" :title="item.name">{{ item.name }}</span>
|
<span class="snap-name" :title="item.name">{{ item.name }}</span>
|
||||||
<span class="snap-value">
|
<span class="snap-value">
|
||||||
{{ item.actual != null ? fmtKpiVal(item.actual) : '—' }}
|
<span class="snap-now" :class="'snap-now-' + getKpiLevel(item)">{{ item.actual != null ? fmtKpiVal(item.actual) : '—' }}</span>
|
||||||
<span class="snap-target">/ {{ item.target != null ? fmtKpiVal(item.target) : '—' }}</span>
|
<span class="snap-target">/ {{ item.target != null ? fmtKpiVal(item.target) : '—' }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span class="snap-rate" :class="'rate-' + getKpiLevel(item)">{{ kpiRateText(item) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="progressData.kpiList.length > 2" class="kpi-snapshot-more">
|
<div v-if="progressData.kpiList.length > 3" class="kpi-snapshot-more">
|
||||||
+{{ progressData.kpiList.length - 2 }} 更多
|
+{{ progressData.kpiList.length - 3 }} 更多
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="progressData?.kpiList?.length" class="node-click-hint">点击查看KPI详情</div>
|
<div v-if="progressData?.kpiList?.length" class="node-click-hint">点击查看KPI详情</div>
|
||||||
@@ -152,6 +157,8 @@ const props = defineProps<{
|
|||||||
iconMap?: Record<string, string>
|
iconMap?: Record<string, string>
|
||||||
kpiNameMap?: Record<string, string>
|
kpiNameMap?: Record<string, string>
|
||||||
allKpis?: any[]
|
allKpis?: any[]
|
||||||
|
/** 因果链查看模式下不在链上 → 变淡 */
|
||||||
|
chainDimmed?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -202,12 +209,19 @@ function getKpiLevel(item: any): string {
|
|||||||
if (item.actual != null && item.target) {
|
if (item.actual != null && item.target) {
|
||||||
const ratio = item.actual / item.target
|
const ratio = item.actual / item.target
|
||||||
if (ratio >= 0.9) return 'green'
|
if (ratio >= 0.9) return 'green'
|
||||||
if (ratio >= 0.7) return 'yellow'
|
if (ratio >= 0.6) return 'yellow'
|
||||||
return 'red'
|
return 'red'
|
||||||
}
|
}
|
||||||
return 'gray'
|
return 'gray'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** KPI达成率文本(绿≥90% / 黄60-90% / 红<60%) */
|
||||||
|
function kpiRateText(item: any): string {
|
||||||
|
if (item.actual == null || !item.target) return '—'
|
||||||
|
const ratio = item.actual / item.target
|
||||||
|
return Math.round(ratio * 100) + '%'
|
||||||
|
}
|
||||||
|
|
||||||
function fmtKpiVal(val: any): string {
|
function fmtKpiVal(val: any): string {
|
||||||
if (val == null) return '—'
|
if (val == null) return '—'
|
||||||
if (typeof val === 'number') {
|
if (typeof val === 'number') {
|
||||||
@@ -259,6 +273,11 @@ function getKrProgressText(kr: any): string {
|
|||||||
transition: all 0.2s; position: relative;
|
transition: all 0.2s; position: relative;
|
||||||
}
|
}
|
||||||
.map-node:hover { box-shadow: 0 2px 10px rgba(0,0,0,0.08); }
|
.map-node:hover { box-shadow: 0 2px 10px rgba(0,0,0,0.08); }
|
||||||
|
.map-node.chain-dimmed {
|
||||||
|
opacity: 0.3;
|
||||||
|
filter: saturate(0.35);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
.map-node.linking-source {
|
.map-node.linking-source {
|
||||||
border-color: #e6a23c; box-shadow: 0 0 0 2px rgba(230,162,60,0.3);
|
border-color: #e6a23c; box-shadow: 0 0 0 2px rgba(230,162,60,0.3);
|
||||||
}
|
}
|
||||||
@@ -289,9 +308,26 @@ function getKrProgressText(kr: any): string {
|
|||||||
.snap-yellow { background: #fdf6ec; }
|
.snap-yellow { background: #fdf6ec; }
|
||||||
.snap-red { background: #fef0f0; }
|
.snap-red { background: #fef0f0; }
|
||||||
.snap-gray { background: #f5f5f5; }
|
.snap-gray { background: #f5f5f5; }
|
||||||
.snap-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100px; color: #666; }
|
.snap-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 92px; color: #666; }
|
||||||
.snap-value { font-weight: 600; color: #333; white-space: nowrap; }
|
.snap-value {
|
||||||
|
font-weight: 600; color: #333; white-space: nowrap;
|
||||||
|
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.snap-now-green { color: #67c23a; }
|
||||||
|
.snap-now-yellow { color: #e6a23c; }
|
||||||
|
.snap-now-red { color: #f56c6c; }
|
||||||
|
.snap-now-gray { color: #333; }
|
||||||
.snap-target { font-weight: 400; color: #999; font-size: 10px; }
|
.snap-target { font-weight: 400; color: #999; font-size: 10px; }
|
||||||
|
.snap-rate {
|
||||||
|
font-size: 10px; font-weight: 700; flex-shrink: 0; min-width: 34px; text-align: right;
|
||||||
|
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.rate-green { color: #67c23a; }
|
||||||
|
.rate-yellow { color: #e6a23c; }
|
||||||
|
.rate-red { color: #f56c6c; }
|
||||||
|
.rate-gray { color: #bbb; }
|
||||||
.kpi-snapshot-more { font-size: 10px; color: #409eff; text-align: center; cursor: pointer; padding: 1px; }
|
.kpi-snapshot-more { font-size: 10px; color: #409eff; text-align: center; cursor: pointer; padding: 1px; }
|
||||||
.node-click-hint { font-size: 10px; color: #bbb; text-align: center; margin-top: 2px; }
|
.node-click-hint { font-size: 10px; color: #bbb; text-align: center; margin-top: 2px; }
|
||||||
|
|
||||||
@@ -311,15 +347,23 @@ function getKrProgressText(kr: any): string {
|
|||||||
.ps-done { background: #f0f9eb; color: #67c23a; }
|
.ps-done { background: #f0f9eb; color: #67c23a; }
|
||||||
|
|
||||||
.node-progress-bar {
|
.node-progress-bar {
|
||||||
display: flex; align-items: center; gap: 6px; margin-top: 4px;
|
display: flex; align-items: center; gap: 6px; margin-top: 6px;
|
||||||
height: 10px; position: relative;
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.node-progress-label {
|
||||||
|
font-size: 10px; color: #909399; flex-shrink: 0; line-height: 1;
|
||||||
|
}
|
||||||
|
.node-progress-track {
|
||||||
|
flex: 1; max-width: 90px; height: 6px; background: #e4e7ed;
|
||||||
|
border-radius: 3px; overflow: hidden;
|
||||||
}
|
}
|
||||||
.node-progress-fill {
|
.node-progress-fill {
|
||||||
height: 6px; border-radius: 3px; transition: width 0.4s ease;
|
height: 100%; border-radius: 3px; transition: width 0.4s ease;
|
||||||
min-width: 4px;
|
min-width: 4px;
|
||||||
}
|
}
|
||||||
.node-progress-text {
|
.node-progress-text {
|
||||||
font-size: 10px; font-weight: 600; color: #666;
|
font-size: 10px; font-weight: 700; min-width: 34px; text-align: right;
|
||||||
|
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||||
font-variant-numeric: tabular-nums; line-height: 1;
|
font-variant-numeric: tabular-nums; line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
:icon-map="iconMap"
|
:icon-map="iconMap"
|
||||||
:kpi-name-map="kpiNameMap"
|
:kpi-name-map="kpiNameMap"
|
||||||
:all-kpis="allKpis"
|
:all-kpis="allKpis"
|
||||||
|
:chain-dimmed="chainNodeKeys.length > 0 && !chainNodeKeys.includes(`${layerKey}-${index}`)"
|
||||||
@click="onNodeClick(layerKey, index, element)"
|
@click="onNodeClick(layerKey, index, element)"
|
||||||
@edit="$emit('edit-objective', { dimKey: layerKey, idx: index, obj: element })"
|
@edit="$emit('edit-objective', { dimKey: layerKey, idx: index, obj: element })"
|
||||||
@delete="$emit('delete-objective', { dimKey: layerKey, idx: index })"
|
@delete="$emit('delete-objective', { dimKey: layerKey, idx: index })"
|
||||||
@@ -91,6 +92,8 @@ const props = defineProps<{
|
|||||||
iconMap?: Record<string, string>
|
iconMap?: Record<string, string>
|
||||||
kpiNameMap?: Record<string, string>
|
kpiNameMap?: Record<string, string>
|
||||||
allKpis?: any[]
|
allKpis?: any[]
|
||||||
|
/** 因果链查看模式下,不在链上的节点变淡 */
|
||||||
|
chainNodeKeys?: string[]
|
||||||
getNodeLevel: (key: string) => string
|
getNodeLevel: (key: string) => string
|
||||||
getNodeProgress: (key: string) => any
|
getNodeProgress: (key: string) => any
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -1,138 +1,141 @@
|
|||||||
<template>
|
<template>
|
||||||
<svg class="connection-svg" ref="svgRef">
|
<svg class="connection-svg" ref="svgRef">
|
||||||
<defs>
|
<defs>
|
||||||
<!-- 跨层箭头(↓方向) -->
|
<!-- 跨层箭头(灰) -->
|
||||||
<marker
|
<marker id="arrow-gray" markerWidth="10" markerHeight="7" refX="8" refY="3.5" orient="auto">
|
||||||
id="arrow-down"
|
|
||||||
markerWidth="10"
|
|
||||||
markerHeight="7"
|
|
||||||
refX="5"
|
|
||||||
refY="7"
|
|
||||||
orient="auto"
|
|
||||||
>
|
|
||||||
<polygon points="0,0 10,3.5 0,7" fill="#909399" />
|
<polygon points="0,0 10,3.5 0,7" fill="#909399" />
|
||||||
</marker>
|
</marker>
|
||||||
<!-- 同层箭头(→方向) -->
|
<!-- 正向连线箭头(蓝) -->
|
||||||
<marker
|
<marker id="arrow-blue" markerWidth="10" markerHeight="7" refX="8" refY="3.5" orient="auto">
|
||||||
id="arrow-right"
|
|
||||||
markerWidth="10"
|
|
||||||
markerHeight="7"
|
|
||||||
refX="10"
|
|
||||||
refY="3.5"
|
|
||||||
orient="auto"
|
|
||||||
>
|
|
||||||
<polygon points="0,0 10,3.5 0,7" fill="#409eff" />
|
<polygon points="0,0 10,3.5 0,7" fill="#409eff" />
|
||||||
</marker>
|
</marker>
|
||||||
<!-- 高亮箭头 -->
|
<!-- 负向连线箭头(红) -->
|
||||||
<marker
|
<marker id="arrow-red" markerWidth="10" markerHeight="7" refX="8" refY="3.5" orient="auto">
|
||||||
id="arrow-active"
|
|
||||||
markerWidth="10"
|
|
||||||
markerHeight="7"
|
|
||||||
refX="10"
|
|
||||||
refY="3.5"
|
|
||||||
orient="auto"
|
|
||||||
>
|
|
||||||
<polygon points="0,0 10,3.5 0,7" fill="#f56c6c" />
|
<polygon points="0,0 10,3.5 0,7" fill="#f56c6c" />
|
||||||
</marker>
|
</marker>
|
||||||
|
<!-- 因果链高亮箭头(橙) -->
|
||||||
|
<marker id="arrow-orange" markerWidth="10" markerHeight="7" refX="8" refY="3.5" orient="auto">
|
||||||
|
<polygon points="0,0 10,3.5 0,7" fill="#e6a23c" />
|
||||||
|
</marker>
|
||||||
|
<!-- 绘制中临时线箭头(橙) -->
|
||||||
|
<marker id="arrow-warn" markerWidth="10" markerHeight="7" refX="8" refY="3.5" orient="auto">
|
||||||
|
<polygon points="0,0 10,3.5 0,7" fill="#e6a23c" />
|
||||||
|
</marker>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<!-- 跨层固定箭头:每层底部 → 下层顶部 -->
|
<!-- 跨层固定箭头:贝塞尔曲线(每层底部 → 下层顶部 / 因果视角反向) -->
|
||||||
<line
|
<path
|
||||||
v-for="(line, idx) in crossLayerLines"
|
v-for="(line, idx) in crossLayerLines"
|
||||||
:key="'cross-' + idx"
|
:key="'cross-' + idx"
|
||||||
:x1="line.x1"
|
:d="line.path"
|
||||||
:y1="line.y1"
|
fill="none"
|
||||||
:x2="line.x2"
|
|
||||||
:y2="line.y2"
|
|
||||||
stroke="#909399"
|
stroke="#909399"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-dasharray="6,3"
|
stroke-dasharray="6,3"
|
||||||
marker-end="url(#arrow-down)"
|
marker-end="url(#arrow-gray)"
|
||||||
class="cross-layer-line"
|
:class="['cross-layer-line', { 'cross-dim': chainActive }]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 同层用户手动连线箭头(→方向) -->
|
<!-- 用户自定义连线(贝塞尔曲线 + 语义样式 + 因果链高亮) -->
|
||||||
<g v-for="(conn, idx) in layerConnections" :key="'conn-' + idx">
|
<g v-for="(conn, idx) in layerConnections" :key="'conn-' + idx">
|
||||||
<path
|
<path
|
||||||
:d="conn.path"
|
:d="conn.path"
|
||||||
:class="[
|
:class="[
|
||||||
'conn-line',
|
'conn-line',
|
||||||
{ 'conn-selected': selectedIdx === idx },
|
{ 'conn-selected': selectedIdx === idx },
|
||||||
|
{ 'conn-chain': chainActive && inChain(idx) },
|
||||||
|
{ 'conn-dim': chainActive && !inChain(idx) },
|
||||||
]"
|
]"
|
||||||
:stroke="selectedIdx === idx ? '#f56c6c' : '#409eff'"
|
:stroke="strokeFor(conn, idx)"
|
||||||
:stroke-width="selectedIdx === idx ? 3 : 2"
|
:stroke-width="widthFor(conn, idx)"
|
||||||
|
:stroke-dasharray="dashFor(conn, idx)"
|
||||||
fill="none"
|
fill="none"
|
||||||
marker-end="url(#arrow-right)"
|
:marker-end="markerFor(conn, idx)"
|
||||||
@click.stop="$emit('select-connection', idx)"
|
@click.stop="$emit('select-connection', idx)"
|
||||||
@mouseenter="hoverIdx = idx"
|
@mouseenter="onConnEnter(idx)"
|
||||||
@mouseleave="hoverIdx = null"
|
@mouseleave="onConnLeave"
|
||||||
/>
|
/>
|
||||||
<!-- hover提示 -->
|
<!-- 连线语义标签(正向/负向) -->
|
||||||
<rect
|
<g
|
||||||
v-if="hoverIdx === idx"
|
v-if="conn.label"
|
||||||
:x="conn.mx - 50"
|
:class="['conn-label', { 'conn-label-dim': chainActive && !inChain(idx) }]"
|
||||||
:y="conn.my - 10"
|
:style="{ '--label-color': labelColor(conn) }"
|
||||||
width="100"
|
@click.stop="$emit('select-connection', idx)"
|
||||||
height="20"
|
|
||||||
rx="4"
|
|
||||||
fill="rgba(0,0,0,0.65)"
|
|
||||||
class="conn-hover-bg"
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
v-if="hoverIdx === idx"
|
|
||||||
:x="conn.mx"
|
|
||||||
:y="conn.my + 4"
|
|
||||||
text-anchor="middle"
|
|
||||||
fill="#fff"
|
|
||||||
font-size="11"
|
|
||||||
class="conn-hover-text"
|
|
||||||
>
|
>
|
||||||
点击选中
|
|
||||||
</text>
|
|
||||||
<!-- 删除按钮(选中时) -->
|
|
||||||
<g v-if="selectedIdx === idx">
|
|
||||||
<rect
|
<rect
|
||||||
:x="conn.mx - 20"
|
:x="conn.mx - labelW(conn) / 2"
|
||||||
:y="conn.my - 28"
|
:y="conn.my - 22"
|
||||||
width="88"
|
:width="labelW(conn)"
|
||||||
height="24"
|
height="16"
|
||||||
rx="12"
|
rx="8"
|
||||||
fill="#f56c6c"
|
class="conn-label-bg"
|
||||||
class="del-btn-bg"
|
/>
|
||||||
|
<text
|
||||||
|
:x="conn.mx"
|
||||||
|
:y="conn.my - 10.5"
|
||||||
|
text-anchor="middle"
|
||||||
|
font-size="10.5"
|
||||||
|
class="conn-label-text"
|
||||||
|
>{{ conn.label }}</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- hover提示 -->
|
||||||
|
<g v-if="hoverIdx === idx && selectedIdx !== idx">
|
||||||
|
<rect :x="conn.mx - 50" :y="conn.my - 10" width="100" height="20" rx="4" fill="rgba(0,0,0,0.65)" class="conn-hover-bg" />
|
||||||
|
<text :x="conn.mx" :y="conn.my + 4" text-anchor="middle" fill="#fff" font-size="11" class="conn-hover-text">点击选中连线</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 选中操作区:正/负向 + 标签 + 删除 -->
|
||||||
|
<g v-if="selectedIdx === idx" class="conn-actions">
|
||||||
|
<rect
|
||||||
|
:x="conn.mx - 96" :y="conn.my - 44" width="192" height="26" rx="13"
|
||||||
|
fill="rgba(255,255,255,0.95)" stroke="#dcdfe6" class="conn-actions-bg"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
:x="conn.mx - 90" :y="conn.my - 39" width="54" height="16" rx="8"
|
||||||
|
:fill="conn.effect === 'negative' ? '#f56c6c' : '#409eff'" class="act-btn"
|
||||||
|
@click.stop="toggleEffect(idx)"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
:x="conn.mx - 63" :y="conn.my - 27.5" text-anchor="middle" fill="#fff" font-size="11" font-weight="600"
|
||||||
|
class="act-btn-text" @click.stop="toggleEffect(idx)"
|
||||||
|
>{{ conn.effect === 'negative' ? '负向' : '正向' }}</text>
|
||||||
|
<rect
|
||||||
|
:x="conn.mx - 30" :y="conn.my - 39" width="54" height="16" rx="8" fill="#ecf5ff" class="act-btn"
|
||||||
|
@click.stop="editLabel(idx)"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
:x="conn.mx - 3" :y="conn.my - 27.5" text-anchor="middle" fill="#409eff" font-size="11" font-weight="600"
|
||||||
|
class="act-btn-text" @click.stop="editLabel(idx)"
|
||||||
|
>标签 ✎</text>
|
||||||
|
<rect
|
||||||
|
:x="conn.mx + 30" :y="conn.my - 39" width="54" height="16" rx="8" fill="#f56c6c" class="act-btn"
|
||||||
@click.stop="$emit('delete-connection', idx)"
|
@click.stop="$emit('delete-connection', idx)"
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
:x="conn.mx + 24"
|
:x="conn.mx + 57" :y="conn.my - 27.5" text-anchor="middle" fill="#fff" font-size="11" font-weight="600"
|
||||||
:y="conn.my - 12"
|
class="act-btn-text" @click.stop="$emit('delete-connection', idx)"
|
||||||
text-anchor="middle"
|
>删除</text>
|
||||||
fill="#fff"
|
|
||||||
font-size="12"
|
|
||||||
font-weight="bold"
|
|
||||||
class="del-btn-text"
|
|
||||||
@click.stop="$emit('delete-connection', idx)"
|
|
||||||
>
|
|
||||||
✕ 删除连线
|
|
||||||
</text>
|
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- 绘制中的临时线 -->
|
<!-- 绘制中的临时线 -->
|
||||||
<line
|
<path
|
||||||
v-if="tempLine"
|
v-if="tempLine"
|
||||||
:x1="tempLine.x1"
|
:d="tempLinePath"
|
||||||
:y1="tempLine.y1"
|
fill="none"
|
||||||
:x2="tempLine.x2"
|
|
||||||
:y2="tempLine.y2"
|
|
||||||
stroke="#e6a23c"
|
stroke="#e6a23c"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-dasharray="6,3"
|
stroke-dasharray="6,3"
|
||||||
marker-end="url(#arrow-down)"
|
marker-end="url(#arrow-warn)"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
import { getChainForConnection, getChainForNode } from '../../utils/chainGraph'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
/** 各层DOM元素引用,格式:{ financial: HTMLElement, customer: HTMLElement, ... } */
|
/** 各层DOM元素引用,格式:{ financial: HTMLElement, customer: HTMLElement, ... } */
|
||||||
@@ -141,7 +144,7 @@ const props = defineProps({
|
|||||||
nodeRefs: { type: Object, default: () => ({}) },
|
nodeRefs: { type: Object, default: () => ({}) },
|
||||||
/** 层顺序列表 */
|
/** 层顺序列表 */
|
||||||
layerKeys: { type: Array, default: () => ['financial', 'customer', 'process', 'learning'] },
|
layerKeys: { type: Array, default: () => ['financial', 'customer', 'process', 'learning'] },
|
||||||
/** 同层连线数据 [{from: 'financial-0', to: 'financial-1'}] */
|
/** 同层连线数据 [{from: 'financial-0', to: 'financial-1', effect, label}] */
|
||||||
connections: { type: Array, default: () => [] },
|
connections: { type: Array, default: () => [] },
|
||||||
/** 临时连线 */
|
/** 临时连线 */
|
||||||
tempLine: { type: Object, default: null },
|
tempLine: { type: Object, default: null },
|
||||||
@@ -149,9 +152,19 @@ const props = defineProps({
|
|||||||
containerKey: { type: Number, default: 0 },
|
containerKey: { type: Number, default: 0 },
|
||||||
/** 视角方向:cascade(级联,上层→下层) / causal(因果,下层→上层) */
|
/** 视角方向:cascade(级联,上层→下层) / causal(因果,下层→上层) */
|
||||||
viewDirection: { type: String, default: 'cascade' },
|
viewDirection: { type: String, default: 'cascade' },
|
||||||
|
/** 因果链查看模式是否开启 */
|
||||||
|
chainMode: { type: Boolean, default: false },
|
||||||
|
/** 因果链查看模式下聚焦的节点 key(点击节点触发) */
|
||||||
|
chainFocusKey: { type: String, default: null },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['select-connection', 'delete-connection'])
|
const emit = defineEmits([
|
||||||
|
'select-connection',
|
||||||
|
'delete-connection',
|
||||||
|
'chain-hover',
|
||||||
|
'chain-focus',
|
||||||
|
'update-connection',
|
||||||
|
])
|
||||||
|
|
||||||
const svgRef = ref(null)
|
const svgRef = ref(null)
|
||||||
const hoverIdx = ref(null)
|
const hoverIdx = ref(null)
|
||||||
@@ -159,15 +172,127 @@ const selectedIdx = ref(null)
|
|||||||
const layerConnections = ref([])
|
const layerConnections = ref([])
|
||||||
const crossLayerLines = ref([])
|
const crossLayerLines = ref([])
|
||||||
|
|
||||||
/**
|
// ── 因果链高亮状态 ──
|
||||||
* 计算跨层固定箭头
|
const hoverChain = ref(null) // { nodes: [], connIdxs: [] } | null
|
||||||
* cascade: 从上层底部中心 → 下层顶部中心(管理视角,上层驱动下层)
|
const focusChain = ref(null)
|
||||||
* causal: 从下层顶部中心 → 上层底部中心(因果视角,下层驱动上层)
|
|
||||||
*/
|
const chainActive = computed(() => hoverChain.value !== null || focusChain.value !== null)
|
||||||
|
|
||||||
|
function inChain(idx) {
|
||||||
|
if (hoverChain.value?.connIdxs.includes(idx)) return true
|
||||||
|
if (focusChain.value?.connIdxs.includes(idx)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConnEnter(idx) {
|
||||||
|
hoverIdx.value = idx
|
||||||
|
const chain = getChainForConnection(props.connections, idx)
|
||||||
|
hoverChain.value = chain
|
||||||
|
emit('chain-hover', chain ? chain.nodes : null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConnLeave() {
|
||||||
|
hoverIdx.value = null
|
||||||
|
hoverChain.value = null
|
||||||
|
emit('chain-hover', null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** chainFocusKey 变化 → 重算聚焦因果链并通知父级(用于节点变淡) */
|
||||||
|
function recomputeFocusChain() {
|
||||||
|
let chain = null
|
||||||
|
if (props.chainMode && props.chainFocusKey) {
|
||||||
|
chain = getChainForNode(props.connections, props.chainFocusKey)
|
||||||
|
}
|
||||||
|
focusChain.value = chain
|
||||||
|
emit('chain-focus', chain ? chain.nodes : null)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.chainFocusKey, recomputeFocusChain)
|
||||||
|
watch(() => props.chainMode, (v) => {
|
||||||
|
if (!v) {
|
||||||
|
focusChain.value = null
|
||||||
|
emit('chain-focus', null)
|
||||||
|
} else {
|
||||||
|
recomputeFocusChain()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// 连线增删时保持聚焦链最新
|
||||||
|
watch(() => props.connections, recomputeFocusChain, { deep: true })
|
||||||
|
|
||||||
|
// ── 连线语义样式 ──
|
||||||
|
function isNegative(conn) {
|
||||||
|
return conn && conn.effect === 'negative'
|
||||||
|
}
|
||||||
|
|
||||||
|
function strokeFor(conn, idx) {
|
||||||
|
if (selectedIdx.value === idx) return '#f56c6c'
|
||||||
|
if (chainActive.value && inChain(idx)) return '#e6a23c'
|
||||||
|
if (chainActive.value) return '#c0c4cc'
|
||||||
|
return isNegative(conn) ? '#f56c6c' : '#409eff'
|
||||||
|
}
|
||||||
|
|
||||||
|
function widthFor(conn, idx) {
|
||||||
|
if (selectedIdx.value === idx) return 3
|
||||||
|
if (chainActive.value && inChain(idx)) return 3.5
|
||||||
|
if (chainActive.value) return 1.5
|
||||||
|
return isNegative(conn) ? 2 : 2
|
||||||
|
}
|
||||||
|
|
||||||
|
function dashFor(conn, idx) {
|
||||||
|
if (chainActive.value && inChain(idx)) return 'none'
|
||||||
|
if (chainActive.value) return '4,3'
|
||||||
|
return isNegative(conn) ? '7,4' : 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerFor(conn, idx) {
|
||||||
|
if (selectedIdx.value === idx) return 'url(#arrow-red)'
|
||||||
|
if (chainActive.value && inChain(idx)) return 'url(#arrow-orange)'
|
||||||
|
if (chainActive.value) return 'url(#arrow-gray)'
|
||||||
|
return isNegative(conn) ? 'url(#arrow-red)' : 'url(#arrow-blue)'
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelColor(conn) {
|
||||||
|
return isNegative(conn) ? '#f56c6c' : '#409eff'
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelW(conn) {
|
||||||
|
return (conn.label ? conn.label.length : 0) * 12 + 16
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 连线语义编辑 ──
|
||||||
|
function toggleEffect(idx) {
|
||||||
|
const conn = props.connections[idx]
|
||||||
|
if (!conn) return
|
||||||
|
emit('update-connection', idx, {
|
||||||
|
effect: conn.effect === 'negative' ? 'positive' : 'negative',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function editLabel(idx) {
|
||||||
|
const conn = props.connections[idx]
|
||||||
|
if (!conn) return
|
||||||
|
ElMessageBox.prompt(
|
||||||
|
'输入连线语义标签(如 "↑降低成本" / "↓提升利润"),留空则清除',
|
||||||
|
'连线语义标签',
|
||||||
|
{
|
||||||
|
inputValue: conn.label || '',
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
inputPlaceholder: '如:↑降低成本',
|
||||||
|
}
|
||||||
|
).then(({ value }) => {
|
||||||
|
emit('update-connection', idx, { label: (value || '').trim() })
|
||||||
|
}).catch(() => { /* 取消 */ })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 跨层箭头:贝塞尔曲线 ──
|
||||||
function calcCrossLayerLines() {
|
function calcCrossLayerLines() {
|
||||||
const lines = []
|
const lines = []
|
||||||
const keys = props.layerKeys
|
const keys = props.layerKeys
|
||||||
const isCausal = props.viewDirection === 'causal'
|
const isCausal = props.viewDirection === 'causal'
|
||||||
|
const svg = svgRef.value
|
||||||
|
if (!svg) return
|
||||||
|
const svgRect = svg.getBoundingClientRect()
|
||||||
for (let i = 0; i < keys.length - 1; i++) {
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
const upperEl = props.layerRefs[keys[i]]
|
const upperEl = props.layerRefs[keys[i]]
|
||||||
const lowerEl = props.layerRefs[keys[i + 1]]
|
const lowerEl = props.layerRefs[keys[i + 1]]
|
||||||
@@ -175,35 +300,18 @@ function calcCrossLayerLines() {
|
|||||||
|
|
||||||
const ur = upperEl.getBoundingClientRect()
|
const ur = upperEl.getBoundingClientRect()
|
||||||
const lr = lowerEl.getBoundingClientRect()
|
const lr = lowerEl.getBoundingClientRect()
|
||||||
const svg = svgRef.value
|
const cx = (ur.left + ur.width / 2 - svgRect.left + lr.left + lr.width / 2 - svgRect.left) / 2
|
||||||
if (!svg) continue
|
const y1 = (isCausal ? lr.top : ur.bottom) - svgRect.top
|
||||||
const svgRect = svg.getBoundingClientRect()
|
const y2 = (isCausal ? ur.bottom : lr.top) - svgRect.top
|
||||||
|
const bend = 28 // 曲线横向弯曲幅度
|
||||||
if (isCausal) {
|
// 三次贝塞尔:垂直方向柔和 S 曲线
|
||||||
// 因果视角:从下层顶 → 上层底(箭头向上)
|
const path = `M ${cx} ${y1} C ${cx + bend} ${y1}, ${cx + bend} ${y2}, ${cx} ${y2}`
|
||||||
lines.push({
|
lines.push({ path })
|
||||||
x1: lr.left + lr.width / 2 - svgRect.left,
|
|
||||||
y1: lr.top - svgRect.top,
|
|
||||||
x2: ur.left + ur.width / 2 - svgRect.left,
|
|
||||||
y2: ur.bottom - svgRect.top,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// 级联视角:从上层底 → 下层顶(箭头向下)
|
|
||||||
lines.push({
|
|
||||||
x1: ur.left + ur.width / 2 - svgRect.left,
|
|
||||||
y1: ur.bottom - svgRect.top,
|
|
||||||
x2: lr.left + lr.width / 2 - svgRect.left,
|
|
||||||
y2: lr.top - svgRect.top,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
crossLayerLines.value = lines
|
crossLayerLines.value = lines
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── 同层用户连线:贝塞尔曲线 ──
|
||||||
* 计算同层用户连线
|
|
||||||
* 使用贝塞尔曲线从源节点右侧 → 目标节点左侧
|
|
||||||
*/
|
|
||||||
function calcLayerConnections() {
|
function calcLayerConnections() {
|
||||||
const result = []
|
const result = []
|
||||||
const svg = svgRef.value
|
const svg = svgRef.value
|
||||||
@@ -224,8 +332,8 @@ function calcLayerConnections() {
|
|||||||
const x2 = tr.left - svgRect.left
|
const x2 = tr.left - svgRect.left
|
||||||
const y2 = tr.top + tr.height / 2 - svgRect.top
|
const y2 = tr.top + tr.height / 2 - svgRect.top
|
||||||
|
|
||||||
// 贝塞尔曲线控制点
|
// 贝塞尔曲线控制点(水平拖拽,曲线柔和)
|
||||||
const dx = Math.abs(x2 - x1) * 0.5
|
const dx = Math.max(Math.abs(x2 - x1) * 0.5, 40)
|
||||||
const cp1x = x1 + dx
|
const cp1x = x1 + dx
|
||||||
const cp1y = y1
|
const cp1y = y1
|
||||||
const cp2x = x2 - dx
|
const cp2x = x2 - dx
|
||||||
@@ -239,16 +347,30 @@ function calcLayerConnections() {
|
|||||||
my: (y1 + y2) / 2,
|
my: (y1 + y2) / 2,
|
||||||
from: conn.from,
|
from: conn.from,
|
||||||
to: conn.to,
|
to: conn.to,
|
||||||
|
effect: conn.effect || 'positive',
|
||||||
|
label: conn.label || '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
layerConnections.value = result
|
layerConnections.value = result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 绘制中临时线(贝塞尔) ──
|
||||||
|
const tempLinePath = computed(() => {
|
||||||
|
if (!props.tempLine) return ''
|
||||||
|
const { x1, y1, x2, y2 } = props.tempLine
|
||||||
|
const dx = Math.max(Math.abs(x2 - x1) * 0.5, 40)
|
||||||
|
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`
|
||||||
|
})
|
||||||
|
|
||||||
function recalcAll() {
|
function recalcAll() {
|
||||||
calcCrossLayerLines()
|
calcCrossLayerLines()
|
||||||
calcLayerConnections()
|
calcLayerConnections()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(() => props.containerKey, () => recalcAll())
|
||||||
|
watch(() => props.viewDirection, () => recalcAll())
|
||||||
|
watch(() => props.connections, () => recalcAll(), { deep: true })
|
||||||
|
|
||||||
// 暴露给父组件调用
|
// 暴露给父组件调用
|
||||||
defineExpose({ recalcAll })
|
defineExpose({ recalcAll })
|
||||||
|
|
||||||
@@ -270,34 +392,61 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
.cross-layer-line {
|
.cross-layer-line {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.cross-dim {
|
||||||
|
opacity: 0.18;
|
||||||
}
|
}
|
||||||
.conn-line {
|
.conn-line {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: stroke 0.15s, stroke-width 0.15s;
|
transition: stroke 0.15s, stroke-width 0.15s, opacity 0.2s;
|
||||||
pointer-events: stroke;
|
pointer-events: stroke;
|
||||||
}
|
}
|
||||||
.conn-line:hover {
|
.conn-line.conn-chain {
|
||||||
stroke: #e6a23c !important;
|
filter: drop-shadow(0 0 3px rgba(230, 162, 60, 0.65));
|
||||||
stroke-width: 4 !important;
|
}
|
||||||
|
.conn-line.conn-dim {
|
||||||
|
opacity: 0.25;
|
||||||
}
|
}
|
||||||
.conn-selected {
|
.conn-selected {
|
||||||
stroke: #f56c6c !important;
|
filter: drop-shadow(0 0 3px rgba(245, 108, 108, 0.6));
|
||||||
stroke-width: 3;
|
|
||||||
}
|
}
|
||||||
.del-btn-bg {
|
.conn-label {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
.del-btn-bg:hover {
|
.conn-label-dim {
|
||||||
fill: #e74c3c !important;
|
opacity: 0.25;
|
||||||
}
|
}
|
||||||
.del-btn-text {
|
.conn-label-bg {
|
||||||
cursor: pointer;
|
fill: rgba(255, 255, 255, 0.92);
|
||||||
pointer-events: all;
|
stroke: var(--label-color, #409eff);
|
||||||
user-select: none;
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
.conn-label-text {
|
||||||
|
fill: #303133;
|
||||||
|
font-weight: 600;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.conn-hover-bg,
|
.conn-hover-bg,
|
||||||
.conn-hover-text {
|
.conn-hover-text {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
.conn-actions-bg {
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
.act-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: all;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.act-btn:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.act-btn-text {
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: all;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* 因果链图算法 — 供 ConnectionLines / MapCanvas 共用
|
||||||
|
* 连线数据: { from: 'financial-0', to: 'customer-1', effect?: 'positive'|'negative', label?: string }
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ChainResult {
|
||||||
|
/** 链上节点 key 集合 */
|
||||||
|
nodes: string[]
|
||||||
|
/** 链上连线索引集合 */
|
||||||
|
connIdxs: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdjNode {
|
||||||
|
out: Set<string>
|
||||||
|
in: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAdjacency(connections: any[]): Map<string, AdjNode> {
|
||||||
|
const adj = new Map<string, AdjNode>()
|
||||||
|
for (const c of connections) {
|
||||||
|
if (!c?.from || !c?.to) continue
|
||||||
|
if (!adj.has(c.from)) adj.set(c.from, { out: new Set(), in: new Set() })
|
||||||
|
if (!adj.has(c.to)) adj.set(c.to, { out: new Set(), in: new Set() })
|
||||||
|
adj.get(c.from)!.out.add(c.to)
|
||||||
|
adj.get(c.to)!.in.add(c.from)
|
||||||
|
}
|
||||||
|
return adj
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 start 沿 out 边 BFS(下游) */
|
||||||
|
function descendants(adj: Map<string, AdjNode>, start: string): Set<string> {
|
||||||
|
const seen = new Set<string>([start])
|
||||||
|
const queue = [start]
|
||||||
|
while (queue.length) {
|
||||||
|
const cur = queue.shift()!
|
||||||
|
for (const next of adj.get(cur)?.out ?? []) {
|
||||||
|
if (!seen.has(next)) {
|
||||||
|
seen.add(next)
|
||||||
|
queue.push(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seen
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 start 沿 in 边 BFS(上游) */
|
||||||
|
function ancestors(adj: Map<string, AdjNode>, start: string): Set<string> {
|
||||||
|
const seen = new Set<string>([start])
|
||||||
|
const queue = [start]
|
||||||
|
while (queue.length) {
|
||||||
|
const cur = queue.shift()!
|
||||||
|
for (const prev of adj.get(cur)?.in ?? []) {
|
||||||
|
if (!seen.has(prev)) {
|
||||||
|
seen.add(prev)
|
||||||
|
queue.push(prev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seen
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 某条连线的完整因果链:源节点的所有下游 + 目标节点的所有上游
|
||||||
|
* (即"从因到果"整条路径上的节点与连线)
|
||||||
|
*/
|
||||||
|
export function getChainForConnection(connections: any[], connIdx: number): ChainResult | null {
|
||||||
|
const conn = connections[connIdx]
|
||||||
|
if (!conn?.from || !conn?.to) return null
|
||||||
|
const adj = buildAdjacency(connections)
|
||||||
|
const nodeSet = new Set<string>([...descendants(adj, conn.from), ...ancestors(adj, conn.to)])
|
||||||
|
const connIdxs: number[] = []
|
||||||
|
connections.forEach((c, i) => {
|
||||||
|
if (c?.from && c?.to && nodeSet.has(c.from) && nodeSet.has(c.to)) connIdxs.push(i)
|
||||||
|
})
|
||||||
|
return { nodes: [...nodeSet], connIdxs }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 某节点的相关因果链:其上游(原因)+ 下游(结果)全部节点与连线
|
||||||
|
*/
|
||||||
|
export function getChainForNode(connections: any[], nodeKey: string): ChainResult | null {
|
||||||
|
const adj = buildAdjacency(connections)
|
||||||
|
if (!adj.has(nodeKey)) return null
|
||||||
|
const nodeSet = new Set<string>([...descendants(adj, nodeKey), ...ancestors(adj, nodeKey)])
|
||||||
|
if (nodeSet.size <= 1) return { nodes: [nodeKey], connIdxs: [] }
|
||||||
|
const connIdxs: number[] = []
|
||||||
|
connections.forEach((c, i) => {
|
||||||
|
if (c?.from && c?.to && nodeSet.has(c.from) && nodeSet.has(c.to)) connIdxs.push(i)
|
||||||
|
})
|
||||||
|
return { nodes: [...nodeSet], connIdxs }
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
:view-direction="viewDirection"
|
:view-direction="viewDirection"
|
||||||
:current-map="currentMap"
|
:current-map="currentMap"
|
||||||
:linking-from="linkingFrom"
|
:linking-from="linkingFrom"
|
||||||
|
:chain-mode="chainMode"
|
||||||
@select-map="onSelectMap"
|
@select-map="onSelectMap"
|
||||||
@update:view-direction="v => viewDirection = v"
|
@update:view-direction="v => viewDirection = v"
|
||||||
@zoom-in="zoomIn"
|
@zoom-in="zoomIn"
|
||||||
@@ -20,8 +21,15 @@
|
|||||||
@show-versions="showVersions = true; loadVersions()"
|
@show-versions="showVersions = true; loadVersions()"
|
||||||
@go-alignment="goAlignment"
|
@go-alignment="goAlignment"
|
||||||
@cancel-link="cancelLink"
|
@cancel-link="cancelLink"
|
||||||
|
@toggle-chain-mode="toggleChainMode"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 因果链查看模式提示 -->
|
||||||
|
<div v-if="chainMode" class="chain-mode-bar">
|
||||||
|
<span>🔍 因果链查看模式 — 点击节点聚焦其相关因果链(再次点击取消),其余节点自动变淡</span>
|
||||||
|
<el-button size="small" type="warning" @click="toggleChainMode">退出模式</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 连线模式提示 -->
|
<!-- 连线模式提示 -->
|
||||||
<div v-if="linkingFrom" class="linking-bar">
|
<div v-if="linkingFrom" class="linking-bar">
|
||||||
<span>🔗 从「{{ linkingFrom.obj.name }}」连线 — 点击另一个目标完成连线</span>
|
<span>🔗 从「{{ linkingFrom.obj.name }}」连线 — 点击另一个目标完成连线</span>
|
||||||
@@ -67,6 +75,7 @@
|
|||||||
:all-kpis="allKpis"
|
:all-kpis="allKpis"
|
||||||
:get-node-level="getNodeLevel"
|
:get-node-level="getNodeLevel"
|
||||||
:get-node-progress="getNodeProgress"
|
:get-node-progress="getNodeProgress"
|
||||||
|
:chain-node-keys="chainNodeKeys"
|
||||||
@add-objective="openAddDialog"
|
@add-objective="openAddDialog"
|
||||||
@edit-objective="(p: any) => openEditDialog(p.dimKey, p.idx, p.obj)"
|
@edit-objective="(p: any) => openEditDialog(p.dimKey, p.idx, p.obj)"
|
||||||
@delete-objective="(p: any) => deleteNode(p.dimKey, p.idx)"
|
@delete-objective="(p: any) => deleteNode(p.dimKey, p.idx)"
|
||||||
@@ -102,8 +111,13 @@
|
|||||||
:temp-line="tempLine"
|
:temp-line="tempLine"
|
||||||
:container-key="recalcTrigger"
|
:container-key="recalcTrigger"
|
||||||
:view-direction="viewDirection"
|
:view-direction="viewDirection"
|
||||||
|
:chain-mode="chainMode"
|
||||||
|
:chain-focus-key="chainFocusKey"
|
||||||
@select-connection="onSelectConnection"
|
@select-connection="onSelectConnection"
|
||||||
@delete-connection="onDeleteConnection"
|
@delete-connection="onDeleteConnection"
|
||||||
|
@chain-hover="onChainHover"
|
||||||
|
@chain-focus="onChainFocus"
|
||||||
|
@update-connection="onUpdateConnection"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 所有弹窗统一管理 -->
|
<!-- 所有弹窗统一管理 -->
|
||||||
@@ -179,6 +193,38 @@ const tempLine = ref<{ x1: number; y1: number; x2: number; y2: number } | null>(
|
|||||||
const dragConnectTarget = ref<string | null>(null)
|
const dragConnectTarget = ref<string | null>(null)
|
||||||
const selectedConnIdx = ref<number | null>(null)
|
const selectedConnIdx = ref<number | null>(null)
|
||||||
|
|
||||||
|
// ── 因果链查看模式 ──
|
||||||
|
const chainMode = ref(false)
|
||||||
|
const chainFocusKey = ref<string | null>(null)
|
||||||
|
const chainHoverNodes = ref<string[] | null>(null)
|
||||||
|
const chainFocusNodes = ref<string[] | null>(null)
|
||||||
|
|
||||||
|
/** 当前因果链涉及的节点 key 集合(悬停链 ∪ 聚焦链),用于节点变淡 */
|
||||||
|
const chainNodeKeys = computed<string[]>(() => {
|
||||||
|
const set = new Set<string>()
|
||||||
|
chainHoverNodes.value?.forEach(k => set.add(k))
|
||||||
|
chainFocusNodes.value?.forEach(k => set.add(k))
|
||||||
|
return [...set]
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleChainMode() {
|
||||||
|
chainMode.value = !chainMode.value
|
||||||
|
chainFocusKey.value = null
|
||||||
|
chainHoverNodes.value = null
|
||||||
|
chainFocusNodes.value = null
|
||||||
|
if (!chainMode.value) selectedConnIdx.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChainHover(nodes: string[] | null) { chainHoverNodes.value = nodes }
|
||||||
|
function onChainFocus(nodes: string[] | null) { chainFocusNodes.value = nodes }
|
||||||
|
|
||||||
|
/** 更新连线语义(effect/label),落库 */
|
||||||
|
function onUpdateConnection(idx: number, patch: any) {
|
||||||
|
if (!connections.value[idx]) return
|
||||||
|
Object.assign(connections.value[idx], patch)
|
||||||
|
saveConnections()
|
||||||
|
}
|
||||||
|
|
||||||
// 缩放
|
// 缩放
|
||||||
const zoomLevel = ref(1)
|
const zoomLevel = ref(1)
|
||||||
const scrollWrapRef = ref<HTMLElement | null>(null)
|
const scrollWrapRef = ref<HTMLElement | null>(null)
|
||||||
@@ -342,6 +388,10 @@ function loadCanvas() {
|
|||||||
connections.value = []
|
connections.value = []
|
||||||
linkingFrom.value = null
|
linkingFrom.value = null
|
||||||
selectedConnIdx.value = null
|
selectedConnIdx.value = null
|
||||||
|
chainMode.value = false
|
||||||
|
chainFocusKey.value = null
|
||||||
|
chainHoverNodes.value = null
|
||||||
|
chainFocusNodes.value = null
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
api.get("/maps").then((r: any) => {
|
api.get("/maps").then((r: any) => {
|
||||||
@@ -404,6 +454,11 @@ function onNodeClick(dimKey: string, oi: number, obj: any) {
|
|||||||
completeConnection(linkingFrom.value.key, key)
|
completeConnection(linkingFrom.value.key, key)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 因果链查看模式:点击节点聚焦其相关因果链,再次点击取消
|
||||||
|
if (chainMode.value) {
|
||||||
|
chainFocusKey.value = chainFocusKey.value === key ? null : key
|
||||||
|
return
|
||||||
|
}
|
||||||
// 有KPI则展示详情
|
// 有KPI则展示详情
|
||||||
if (kpiProgressMap[key]?.kpiList?.length > 0) {
|
if (kpiProgressMap[key]?.kpiList?.length > 0) {
|
||||||
showKpiDetailModal(dimKey, oi, obj)
|
showKpiDetailModal(dimKey, oi, obj)
|
||||||
@@ -433,12 +488,12 @@ function completeConnection(fromKey: string, toKey: string) {
|
|||||||
from: fromKey, to: toKey,
|
from: fromKey, to: toKey,
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
ElMessage.success("连线已添加")
|
ElMessage.success("连线已添加")
|
||||||
connections.value.push({ from: fromKey, to: toKey, style: "solid" })
|
connections.value.push({ from: fromKey, to: toKey, style: "solid", effect: "positive", label: "" })
|
||||||
cancelLink()
|
cancelLink()
|
||||||
nextTick(() => { triggerRecalc(); saveConnections() })
|
nextTick(() => { triggerRecalc(); saveConnections() })
|
||||||
}).catch((e: any) => {
|
}).catch((e: any) => {
|
||||||
if (e?.response?.status !== 400) {
|
if (e?.response?.status !== 400) {
|
||||||
connections.value.push({ from: fromKey, to: toKey, style: "solid" })
|
connections.value.push({ from: fromKey, to: toKey, style: "solid", effect: "positive", label: "" })
|
||||||
ElMessage.success("连线已添加(本地)")
|
ElMessage.success("连线已添加(本地)")
|
||||||
} else {
|
} else {
|
||||||
ElMessage.warning(e?.response?.data?.detail || "连线失败")
|
ElMessage.warning(e?.response?.data?.detail || "连线失败")
|
||||||
@@ -976,6 +1031,14 @@ onUnmounted(() => {
|
|||||||
border-radius: 8px; margin-bottom: 8px; font-size: 14px; flex-shrink: 0;
|
border-radius: 8px; margin-bottom: 8px; font-size: 14px; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chain-mode-bar {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 8px 16px; background: #fdf6ec; border: 1px solid #f5dab1;
|
||||||
|
border-radius: 8px; margin-bottom: 8px; font-size: 13px; flex-shrink: 0;
|
||||||
|
color: #b88230;
|
||||||
|
}
|
||||||
|
.chain-mode-bar span { flex: 1; }
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user