feat: P0/P1/P2全部功能 — 四层泳道/视角切换/KPI看板/预警/差异反打/预算/知识面板/回顾会/情景预测/Excel导入/角色权限
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
<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}`"
|
||||
:icon-map="iconMap"
|
||||
:kpi-name-map="kpiNameMap"
|
||||
:all-kpis="allKpis"
|
||||
@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 })"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<!-- 空状态占位提示 -->
|
||||
<div v-if="objectives.length === 0" class="layer-empty">
|
||||
<el-empty description="暂无目标" :image-size="60">
|
||||
<el-button text type="primary" size="small" @click="$emit('add-objective', layerKey)">
|
||||
点击 + 添加目标
|
||||
</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<!-- 底部添加按钮 -->
|
||||
<el-button text 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
|
||||
iconMap?: Record<string, string>
|
||||
kpiNameMap?: Record<string, string>
|
||||
allKpis?: any[]
|
||||
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
|
||||
}>()
|
||||
|
||||
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: 2px solid #e8e8e8;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fafafa;
|
||||
min-height: 120px;
|
||||
transition: border-color 0.2s;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.layer-header {
|
||||
padding: 10px 14px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.layer-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.layer-objective-count {
|
||||
font-size: 11px;
|
||||
opacity: 0.8;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.layer-more-btn {
|
||||
color: rgba(255,255,255,0.7) !important;
|
||||
padding: 2px !important;
|
||||
}
|
||||
.layer-more-btn:hover {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.layer-body {
|
||||
padding: 10px 14px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 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: 4px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user