255 lines
7.7 KiB
Vue
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>
|