Files
cma-management/frontend/src/components/strategy-map/StrategyLayer.vue
T

255 lines
7.7 KiB
Vue

<template>
<div class="strategy-layer" :class="`layer-${layerKey}`" :style="{ borderColor: color }">
<div class="layer-header" :style="{ background: color }">
<span class="layer-icon">{{ icon }}</span>
<span class="layer-name">{{ label }}</span>
<span class="layer-objective-count">{{ objectives.length }} 个目标</span>
<el-dropdown trigger="click" @command="onLayerAction">
<el-button class="layer-more-btn" size="small" text>
<el-icon><MoreFilled /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-item command="rename">重命名</el-dropdown-item>
<el-dropdown-item command="color">更换颜色</el-dropdown-item>
<el-dropdown-item command="icon">更换图标</el-dropdown-item>
</template>
</el-dropdown>
</div>
<div class="layer-body" ref="bodyRef">
<!-- vuedraggable: 每层独立group 禁止跨层拖拽 -->
<draggable
v-if="objectives.length > 0"
:list="objectives"
:group="layerKey"
item-key="name"
:animation="150"
@end="onDragEnd"
class="layer-draggable"
>
<template #item="{ element, index }">
<div class="layer-node-wrapper">
<MapNode
:key="`${layerKey}-${index}`"
:ref="el => setNodeRef(`${layerKey}-${index}`, el)"
:objective="element"
:node-key="`${layerKey}-${index}`"
:index="index"
:level="getNodeLevel(`${layerKey}-${index}`)"
:progress-data="getNodeProgress(`${layerKey}-${index}`)"
:draggable="false"
:is-linking-source="linkingFromKey === `${layerKey}-${index}`"
:is-linking-target="!!linkingFromKey && linkingFromKey !== `${layerKey}-${index}`"
:is-drag-connect-target="dragConnectTarget === `${layerKey}-${index}`"
:link-eligibility="linkEligibilityMap?.[`${layerKey}-${index}`] ?? null"
:icon-map="iconMap"
:kpi-name-map="kpiNameMap"
:all-kpis="allKpis"
:chain-dimmed="chainNodeKeys.length > 0 && !chainNodeKeys.includes(`${layerKey}-${index}`)"
@click="onNodeClick(layerKey, index, element)"
@edit="$emit('edit-objective', { dimKey: layerKey, idx: index, obj: element })"
@delete="$emit('delete-objective', { dimKey: layerKey, idx: index })"
@link-click="$emit('start-link', { key: `${layerKey}-${index}`, obj: element })"
@link-drag-start="(e: MouseEvent) => $emit('link-drag-start', e, layerKey, index, element)"
@kpi-click="(code: string) => $emit('kpi-click', code)"
@plan-click="$emit('show-plan-list', { dimKey: layerKey, idx: index, obj: element })"
@edit-kr="(p: any) => $emit('edit-kr', { dimKey: layerKey, idx: index, obj: element, ki: p.index, kr: p.kr })"
@add-kr="$emit('add-kr', { dimKey: layerKey, idx: index, obj: element })"
/>
</div>
</template>
</draggable>
<!-- 空状态占位提示 -->
<div v-if="objectives.length === 0" class="layer-empty">
<el-empty description="暂无目标" :image-size="60">
<el-button type="primary" size="small" @click="$emit('add-objective', layerKey)">
点击 + 添加目标
</el-button>
</el-empty>
</div>
<!-- 底部添加按钮 -->
<el-button type="primary" size="small" class="layer-add-btn" @click="$emit('add-objective', layerKey)">
+ 添加目标
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { MoreFilled } from '@element-plus/icons-vue'
import draggable from 'vuedraggable'
import MapNode from './MapNode.vue'
const props = defineProps<{
layerKey: string
label: string
icon: string
color: string
objectives: any[]
linkingFromKey?: string | null
dragConnectTarget?: string | null
/** 拖拽连线时的目标合法性:valid=合法高亮 / forbidden=反方向灰显 */
linkEligibilityMap?: Record<string, 'valid' | 'forbidden'>
iconMap?: Record<string, string>
kpiNameMap?: Record<string, string>
allKpis?: any[]
/** 因果链查看模式下,不在链上的节点变淡 */
chainNodeKeys?: string[]
getNodeLevel: (key: string) => string
getNodeProgress: (key: string) => any
}>()
const emit = defineEmits<{
(e: 'add-objective', dimKey: string): void
(e: 'edit-objective', payload: { dimKey: string; idx: number; obj: any }): void
(e: 'delete-objective', payload: { dimKey: string; idx: number }): void
(e: 'start-link', payload: { key: string; obj: any }): void
(e: 'link-drag-start', event: MouseEvent, dimKey: string, oi: number, obj: any): void
(e: 'kpi-click', code: string): void
(e: 'show-plan-list', payload: { dimKey: string; idx: number; obj: any }): void
(e: 'node-click', dimKey: string, oi: number, obj: any): void
(e: 'reorder', dimKey: string): void
(e: 'layer-action', cmd: string): void
(e: 'update:label', val: string): void
(e: 'update:color', val: string): void
(e: 'update:icon', val: string): void
(e: 'edit-kr', payload: { dimKey: string; idx: number; obj: any; ki: number; kr: any }): void
(e: 'add-kr', payload: { dimKey: string; idx: number; obj: any }): void
}>()
const bodyRef = ref<HTMLElement | null>(null)
// Node DOM refs for line calculation (reactive so parent can aggregate)
const nodeRefs = reactive<Record<string, any>>({})
function setNodeRef(key: string, el: any) {
if (el) nodeRefs[key] = el
}
// Expose nodeRefs to parent for connection line calculation
defineExpose({ nodeRefs, bodyRef })
function onLayerAction(cmd: string) {
emit('layer-action', cmd)
}
/**
* 拖拽排序完成回调(由 vuedraggable/SortableJS 触发)
* 由于 :list 传递的是父级响应式数组的引用,SortableJS 已直接完成了数组重排,
* 此处仅需通知父级重算连线位置和触发保存
*/
function onDragEnd() {
emit('reorder', props.layerKey)
}
function onNodeClick(dimKey: string, oi: number, obj: any) {
emit('node-click', dimKey, oi, obj)
}
</script>
<script lang="ts">
import { reactive } from 'vue'
export default {
inheritAttrs: false,
}
</script>
<style scoped>
.strategy-layer {
border: none;
border-radius: 12px;
display: flex;
flex-direction: column;
background: #fff;
min-height: 120px;
width: 100%;
box-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
transition: box-shadow 0.2s;
overflow: hidden;
}
.strategy-layer:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
.layer-header {
padding: 12px 16px;
color: #fff;
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
font-weight: 600;
flex-shrink: 0;
user-select: none;
letter-spacing: 0.3px;
}
.layer-icon {
font-size: 18px;
line-height: 1;
}
.layer-name {
flex: 1;
}
.layer-objective-count {
font-size: 11px;
opacity: 0.85;
font-weight: 400;
background: rgba(255,255,255,0.2);
padding: 2px 10px;
border-radius: 10px;
}
.layer-more-btn {
color: rgba(255,255,255,0.7) !important;
padding: 2px !important;
}
.layer-more-btn:hover {
color: #fff !important;
}
.layer-body {
padding: 12px 14px;
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
overflow-y: auto;
background: #f8f9fb;
}
/* vuedraggable 容器 */
.layer-draggable {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-height: 40px;
}
/* 每个节点包裹元素 */
.layer-node-wrapper {
flex: 0 0 auto;
width: 100%;
}
/* 空状态 */
.layer-empty {
display: flex;
justify-content: center;
align-items: center;
min-height: 80px;
padding: 12px 0;
}
/* 底部添加按钮 */
.layer-add-btn {
width: 100% !important;
flex-shrink: 0;
margin-top: 6px;
}
</style>