feat: O→KR→KPI Phase 0 - KR sub-nodes on canvas, KR edit dialog, data migration
- MapNode.vue: Add collapsible KR sub-list with progress bars under each O node - MapCanvasDialogs.vue: Add KPI selector per KR, editing mode (skip template step, pre-fill data), validation (max 5 KR, target value required) - StrategyLayer.vue: Pass through edit-kr/add-kr events - MapCanvas.vue: Data migration (old kpis[]→krs[]), KR add/edit handlers, pass editingObj/allKpis to dialog
This commit is contained in:
@@ -41,7 +41,7 @@
|
|||||||
第二步:编辑 O + KR
|
第二步:编辑 O + KR
|
||||||
========================================================== -->
|
========================================================== -->
|
||||||
<div v-if="dialogStep === 2" class="mc-okr-step">
|
<div v-if="dialogStep === 2" class="mc-okr-step">
|
||||||
<div class="mc-step-hint">步骤 2/2 — 编辑目标与关键结果</div>
|
<div class="mc-step-hint">{{ isEditing ? '编辑目标与关键结果' : '步骤 2/2 — 编辑目标与关键结果' }}</div>
|
||||||
|
|
||||||
<div class="mc-form-item">
|
<div class="mc-form-item">
|
||||||
<label>🎯 目标名称 <span class="mc-required">*</span></label>
|
<label>🎯 目标名称 <span class="mc-required">*</span></label>
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mc-kr-row-bottom">
|
<div class="mc-kr-row-bottom">
|
||||||
<div class="mc-kr-field">
|
<div class="mc-kr-field">
|
||||||
<label>目标值</label>
|
<label>目标值 <span class="mc-required">*</span></label>
|
||||||
<input v-model="kr.target_value" class="mc-input mc-kr-input" placeholder="输入数字,回车自动加%" @keydown.enter.prevent="formatTargetValue(ki)" />
|
<input v-model="kr.target_value" class="mc-input mc-kr-input" placeholder="输入数字,回车自动加%" @keydown.enter.prevent="formatTargetValue(ki)" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mc-kr-field">
|
<div class="mc-kr-field">
|
||||||
@@ -85,6 +85,15 @@
|
|||||||
<option value="100">100%</option>
|
<option value="100">100%</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mc-kr-field">
|
||||||
|
<label>关联KPI</label>
|
||||||
|
<select v-model="kr.kpi_code" class="mc-input mc-kr-input">
|
||||||
|
<option value="">— 不关联 —</option>
|
||||||
|
<option v-for="kpi in kpiOptions" :key="kpi.kpi_code" :value="kpi.kpi_code">
|
||||||
|
{{ kpi.kpi_name || kpi.kpi_code }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="weightSum !== null && weightSum !== 100" class="mc-kr-warning">⚠️ 权重合计 {{ weightSum }}%,应为100%</div>
|
<div v-if="weightSum !== null && weightSum !== 100" class="mc-kr-warning">⚠️ 权重合计 {{ weightSum }}%,应为100%</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +109,7 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="dialogStep === 2" class="mc-footer-buttons">
|
<div v-if="dialogStep === 2" class="mc-footer-buttons">
|
||||||
<el-button size="small" @click="prevStep">上一步</el-button>
|
<el-button v-if="!isEditing" size="small" @click="prevStep">上一步</el-button>
|
||||||
<el-button size="small" @click="onCancel">取消</el-button>
|
<el-button size="small" @click="onCancel">取消</el-button>
|
||||||
<el-button size="small" type="primary" @click="onSave">保存</el-button>
|
<el-button size="small" type="primary" @click="onSave">保存</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -222,6 +231,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from "vue"
|
import { ref, computed, watch } from "vue"
|
||||||
|
import { ElMessage } from "element-plus"
|
||||||
|
|
||||||
// ── Props ──
|
// ── Props ──
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -240,6 +250,8 @@ const props = defineProps<{
|
|||||||
planPopupObjName: string
|
planPopupObjName: string
|
||||||
planPopupList: any[]
|
planPopupList: any[]
|
||||||
editingLayerKey?: string // 当前编辑的维度key
|
editingLayerKey?: string // 当前编辑的维度key
|
||||||
|
editingObj?: any // 正在编辑的目标对象(含krs)
|
||||||
|
allKpis?: any[] // KPI字典数据(用于KR关联KPI)
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// ── Emits ──
|
// ── Emits ──
|
||||||
@@ -426,7 +438,7 @@ const usedTemplate = ref<any | null>(null)
|
|||||||
const form = ref<{
|
const form = ref<{
|
||||||
o_name: string
|
o_name: string
|
||||||
o_description: string
|
o_description: string
|
||||||
krs: { name: string; target_value: string; weight: string }[]
|
krs: { name: string; target_value: string; weight: string; kpi_code: string }[]
|
||||||
}>({
|
}>({
|
||||||
o_name: "",
|
o_name: "",
|
||||||
o_description: "",
|
o_description: "",
|
||||||
@@ -448,6 +460,10 @@ const dialogTitle = computed(() => {
|
|||||||
return props.isEditing ? "编辑目标" : "添加目标"
|
return props.isEditing ? "编辑目标" : "添加目标"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const kpiOptions = computed(() => {
|
||||||
|
return props.allKpis || []
|
||||||
|
})
|
||||||
|
|
||||||
// ── 方法 ──
|
// ── 方法 ──
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
dialogStep.value = 1
|
dialogStep.value = 1
|
||||||
@@ -468,9 +484,9 @@ function skipTemplate() {
|
|||||||
o_name: "",
|
o_name: "",
|
||||||
o_description: "",
|
o_description: "",
|
||||||
krs: [
|
krs: [
|
||||||
{ name: "", target_value: "", weight: "33" },
|
{ name: "", target_value: "", weight: "33", kpi_code: "" },
|
||||||
{ name: "", target_value: "", weight: "33" },
|
{ name: "", target_value: "", weight: "33", kpi_code: "" },
|
||||||
{ name: "", target_value: "", weight: "34" },
|
{ name: "", target_value: "", weight: "34", kpi_code: "" },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -488,6 +504,7 @@ function nextStep() {
|
|||||||
name: kr.name,
|
name: kr.name,
|
||||||
target_value: kr.target_value,
|
target_value: kr.target_value,
|
||||||
weight: String(kr.weight),
|
weight: String(kr.weight),
|
||||||
|
kpi_code: kr.kpi_code || "",
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -502,7 +519,11 @@ const weightSum = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function addKr() {
|
function addKr() {
|
||||||
form.value.krs.push({ name: "", target_value: "", weight: "33" })
|
if (form.value.krs.length >= 5) {
|
||||||
|
ElMessage.warning('每个目标最多5个关键结果')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
form.value.krs.push({ name: "", target_value: "", weight: "33", kpi_code: "" })
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeKr(idx: number) {
|
function removeKr(idx: number) {
|
||||||
@@ -528,8 +549,26 @@ function checkWeightSum() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onSave() {
|
function onSave() {
|
||||||
|
// 验证目标名称
|
||||||
if (!form.value.o_name?.trim()) {
|
if (!form.value.o_name?.trim()) {
|
||||||
// 简单提示 — 父组件会检测
|
ElMessage.warning('请输入目标名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 验证KR
|
||||||
|
for (let i = 0; i < form.value.krs.length; i++) {
|
||||||
|
const kr = form.value.krs[i]
|
||||||
|
if (!kr.name?.trim()) {
|
||||||
|
ElMessage.warning(`第 ${i + 1} 条KR名称不能为空`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!kr.target_value?.trim()) {
|
||||||
|
ElMessage.warning(`第 ${i + 1} 条KR目标值不能为空`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 验证KR数量
|
||||||
|
if (form.value.krs.length > 5) {
|
||||||
|
ElMessage.warning('每个目标最多5个关键结果')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 构造完整数据发给父组件
|
// 构造完整数据发给父组件
|
||||||
@@ -542,6 +581,7 @@ function onSave() {
|
|||||||
name: kr.name,
|
name: kr.name,
|
||||||
target_value: kr.target_value,
|
target_value: kr.target_value,
|
||||||
weight: parseInt(kr.weight) || 33,
|
weight: parseInt(kr.weight) || 33,
|
||||||
|
kpi_code: kr.kpi_code || null,
|
||||||
sort_order: i + 1,
|
sort_order: i + 1,
|
||||||
})),
|
})),
|
||||||
// 兼容旧字段(父组件 onNodeSave 需要 name)
|
// 兼容旧字段(父组件 onNodeSave 需要 name)
|
||||||
@@ -559,7 +599,28 @@ function onCancel() {
|
|||||||
watch(
|
watch(
|
||||||
() => props.showNodeDialog,
|
() => props.showNodeDialog,
|
||||||
(val) => {
|
(val) => {
|
||||||
if (!val) {
|
if (val) {
|
||||||
|
// 编辑模式:跳过模板步骤,直接进入O+KR编辑,预填数据
|
||||||
|
if (props.isEditing && props.editingObj) {
|
||||||
|
dialogStep.value = 2
|
||||||
|
selectedTemplateIdx.value = null
|
||||||
|
usedTemplate.value = null
|
||||||
|
const obj = props.editingObj
|
||||||
|
const existingKrs = (obj.krs || []).map((kr: any) => ({
|
||||||
|
name: kr.name || "",
|
||||||
|
target_value: kr.target_value || "",
|
||||||
|
weight: String(kr.weight || 33),
|
||||||
|
kpi_code: kr.kpi_code || "",
|
||||||
|
}))
|
||||||
|
form.value = {
|
||||||
|
o_name: obj.name || obj.o_name || "",
|
||||||
|
o_description: obj.description || obj.o_description || "",
|
||||||
|
krs: existingKrs.length > 0 ? existingKrs : [{ name: "", target_value: "", weight: "33", kpi_code: "" }],
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
resetForm()
|
resetForm()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
'node-level-red': level === 'red',
|
'node-level-red': level === 'red',
|
||||||
'node-level-yellow': level === 'yellow',
|
'node-level-yellow': level === 'yellow',
|
||||||
'node-level-green': level === 'green',
|
'node-level-green': level === 'green',
|
||||||
|
'kr-expanded': krExpanded,
|
||||||
}"
|
}"
|
||||||
:draggable="draggable"
|
:draggable="draggable"
|
||||||
@dragstart="onDragStart"
|
@dragstart="onDragStart"
|
||||||
@@ -17,12 +18,63 @@
|
|||||||
@drop.prevent="onDrop"
|
@drop.prevent="onDrop"
|
||||||
@click="onClick"
|
@click="onClick"
|
||||||
>
|
>
|
||||||
|
<!-- O标题行 -->
|
||||||
<div class="node-title">
|
<div class="node-title">
|
||||||
<span class="node-level-dot" :class="'dot-' + (level || 'gray')"></span>
|
<span class="node-level-dot" :class="'dot-' + (level || 'gray')"></span>
|
||||||
<el-icon v-if="objIcon" style="margin-right:4px;"><component :is="objIcon" /></el-icon>
|
<el-icon v-if="objIcon" style="margin-right:4px;"><component :is="objIcon" /></el-icon>
|
||||||
{{ objective.name }}
|
{{ objective.name }}
|
||||||
|
<!-- KR折叠/展开按钮 -->
|
||||||
|
<span
|
||||||
|
v-if="hasKrs"
|
||||||
|
class="kr-toggle-btn"
|
||||||
|
@click.stop="krExpanded = !krExpanded"
|
||||||
|
:title="krExpanded ? '收起关键结果' : '展开关键结果'"
|
||||||
|
>
|
||||||
|
{{ krExpanded ? '▲' : '▼' }}
|
||||||
|
<span class="kr-toggle-count">{{ objective.krs.length }}条KR</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="objective.description" class="node-desc">{{ objective.description }}</div>
|
<div v-if="objective.description" class="node-desc">{{ objective.description }}</div>
|
||||||
|
|
||||||
|
<!-- KR子节点列表(展开时显示) -->
|
||||||
|
<div v-if="hasKrs && krExpanded" class="kr-sub-list">
|
||||||
|
<div
|
||||||
|
v-for="(kr, ki) in objective.krs"
|
||||||
|
:key="ki"
|
||||||
|
class="kr-sub-item"
|
||||||
|
@click.stop="$emit('edit-kr', { index: ki, kr })"
|
||||||
|
>
|
||||||
|
<div class="kr-sub-top">
|
||||||
|
<span class="kr-sub-index">{{ ki + 1 }}</span>
|
||||||
|
<span class="kr-sub-name" :title="kr.name">{{ kr.name }}</span>
|
||||||
|
<span v-if="kr.target_value" class="kr-sub-target">目标: {{ kr.target_value }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kr-sub-bottom">
|
||||||
|
<div class="kr-sub-progress-wrap">
|
||||||
|
<div class="kr-sub-progress-bg">
|
||||||
|
<div
|
||||||
|
class="kr-sub-progress-fill"
|
||||||
|
:style="{ width: getKrProgress(kr) + '%', background: getKrProgressColor(kr) }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="kr-sub-progress-text">{{ getKrProgressText(kr) }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="kr.weight" class="kr-sub-weight">{{ kr.weight }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 底部+KR按钮 -->
|
||||||
|
<el-button size="small" type="primary" plain class="kr-add-btn" @click.stop="$emit('add-kr')">
|
||||||
|
+ KR
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 无KR时的展开提示 -->
|
||||||
|
<div v-if="!hasKrs && krExpanded" class="kr-empty-hint" @click.stop>
|
||||||
|
<span>暂无关键结果</span>
|
||||||
|
<el-button size="small" type="primary" plain @click.stop="$emit('add-kr')">+ 添加KR</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 原有KPI展示 -->
|
||||||
<div class="node-kpis">
|
<div class="node-kpis">
|
||||||
<el-tag
|
<el-tag
|
||||||
v-for="kpi in (objective.kpis||[])" :key="kpi"
|
v-for="kpi in (objective.kpis||[])" :key="kpi"
|
||||||
@@ -84,7 +136,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
objective: any
|
objective: any
|
||||||
@@ -114,8 +166,17 @@ const emit = defineEmits<{
|
|||||||
(e: 'link-drag-start', event: MouseEvent): void
|
(e: 'link-drag-start', event: MouseEvent): void
|
||||||
(e: 'kpi-click', kpiCode: string): void
|
(e: 'kpi-click', kpiCode: string): void
|
||||||
(e: 'plan-click'): void
|
(e: 'plan-click'): void
|
||||||
|
(e: 'edit-kr', payload: { index: number; kr: any }): void
|
||||||
|
(e: 'add-kr'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// KR展开/折叠状态
|
||||||
|
const krExpanded = ref(true)
|
||||||
|
|
||||||
|
const hasKrs = computed(() => {
|
||||||
|
return props.objective?.krs && Array.isArray(props.objective.krs) && props.objective.krs.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
const objIcon = computed(() => {
|
const objIcon = computed(() => {
|
||||||
if (props.objective.icon && props.iconMap) {
|
if (props.objective.icon && props.iconMap) {
|
||||||
return (props.iconMap as Record<string, string>)[props.objective.icon]
|
return (props.iconMap as Record<string, string>)[props.objective.icon]
|
||||||
@@ -162,6 +223,33 @@ function progressColor(level: string): string {
|
|||||||
if (level === 'red') return '#f56c6c'
|
if (level === 'red') return '#f56c6c'
|
||||||
return '#dcdfe6'
|
return '#dcdfe6'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KR进度计算
|
||||||
|
function getKrProgress(kr: any): number {
|
||||||
|
if (kr.current_value != null && kr.target_value) {
|
||||||
|
const target = parseFloat(String(kr.target_value).replace(/[≥≤>]/g, '').replace(/[^0-9.]/g, ''))
|
||||||
|
const current = parseFloat(String(kr.current_value))
|
||||||
|
if (!isNaN(target) && target > 0 && !isNaN(current)) {
|
||||||
|
return Math.min(100, Math.round((current / target) * 100))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKrProgressColor(kr: any): string {
|
||||||
|
const pct = getKrProgress(kr)
|
||||||
|
if (pct >= 90) return '#67c23a'
|
||||||
|
if (pct >= 60) return '#e6a23c'
|
||||||
|
if (pct > 0) return '#f56c6c'
|
||||||
|
return '#dcdfe6'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKrProgressText(kr: any): string {
|
||||||
|
const pct = getKrProgress(kr)
|
||||||
|
if (pct > 0) return pct + '%'
|
||||||
|
if (kr.current_value != null) return String(kr.current_value)
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -188,7 +276,7 @@ function progressColor(level: string): string {
|
|||||||
.node-level-red { border-left: 3px solid #f56c6c !important; background: #fef0f0; }
|
.node-level-red { border-left: 3px solid #f56c6c !important; background: #fef0f0; }
|
||||||
.node-level-yellow { border-left: 3px solid #e6a23c !important; background: #fdf6ec; }
|
.node-level-yellow { border-left: 3px solid #e6a23c !important; background: #fdf6ec; }
|
||||||
.node-level-green { border-left: 3px solid #67c23a !important; background: #f0f9eb; }
|
.node-level-green { border-left: 3px solid #67c23a !important; background: #f0f9eb; }
|
||||||
.node-title { font-size: 13px; font-weight: 500; margin-bottom: 4px; display: flex; align-items: center; }
|
.node-title { font-size: 13px; font-weight: 500; margin-bottom: 4px; display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
|
||||||
.node-desc { font-size: 12px; color: #888; margin-bottom: 4px; }
|
.node-desc { font-size: 12px; color: #888; margin-bottom: 4px; }
|
||||||
.node-kpis { display: flex; flex-wrap: wrap; gap: 2px; }
|
.node-kpis { display: flex; flex-wrap: wrap; gap: 2px; }
|
||||||
|
|
||||||
@@ -256,4 +344,133 @@ function progressColor(level: string): string {
|
|||||||
.dot-yellow { background: #e6a23c; }
|
.dot-yellow { background: #e6a23c; }
|
||||||
.dot-red { background: #f56c6c; }
|
.dot-red { background: #f56c6c; }
|
||||||
.dot-gray { background: #dcdfe6; }
|
.dot-gray { background: #dcdfe6; }
|
||||||
|
|
||||||
|
/* ── KR子节点样式 ── */
|
||||||
|
.kr-toggle-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #409eff;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #ecf5ff;
|
||||||
|
user-select: none;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.kr-toggle-btn:hover {
|
||||||
|
background: #d9ecff;
|
||||||
|
}
|
||||||
|
.kr-toggle-count {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kr-sub-list {
|
||||||
|
margin-top: 6px;
|
||||||
|
border-top: 1px dashed #e4e7ed;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
.kr-sub-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #e8f0fe;
|
||||||
|
background: #f8faff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.kr-sub-item:hover {
|
||||||
|
background: #ecf5ff;
|
||||||
|
border-color: #409eff;
|
||||||
|
}
|
||||||
|
.kr-sub-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.kr-sub-index {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #409eff;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.kr-sub-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.kr-sub-target {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #909399;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.kr-sub-bottom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.kr-sub-progress-wrap {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.kr-sub-progress-bg {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
background: #e4e7ed;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.kr-sub-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
.kr-sub-progress-text {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #606266;
|
||||||
|
min-width: 28px;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.kr-sub-weight {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #909399;
|
||||||
|
background: #f0f5ff;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.kr-empty-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
border-top: 1px dashed #e4e7ed;
|
||||||
|
}
|
||||||
|
.kr-add-btn {
|
||||||
|
width: 100% !important;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -50,7 +50,9 @@
|
|||||||
@link-drag-start="(e: MouseEvent) => $emit('link-drag-start', e, layerKey, index, element)"
|
@link-drag-start="(e: MouseEvent) => $emit('link-drag-start', e, layerKey, index, element)"
|
||||||
@kpi-click="(code: string) => $emit('kpi-click', code)"
|
@kpi-click="(code: string) => $emit('kpi-click', code)"
|
||||||
@plan-click="$emit('show-plan-list', { dimKey: layerKey, idx: index, obj: element })"
|
@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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
@@ -107,6 +109,8 @@ const emit = defineEmits<{
|
|||||||
(e: 'update:label', val: string): void
|
(e: 'update:label', val: string): void
|
||||||
(e: 'update:color', val: string): void
|
(e: 'update:color', val: string): void
|
||||||
(e: 'update:icon', 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)
|
const bodyRef = ref<HTMLElement | null>(null)
|
||||||
|
|||||||
@@ -76,6 +76,8 @@
|
|||||||
@show-plan-list="(p: any) => showPlanList(p.dimKey, p.idx, p.obj)"
|
@show-plan-list="(p: any) => showPlanList(p.dimKey, p.idx, p.obj)"
|
||||||
@node-click="(dk: string, oi: number, obj: any) => onNodeClick(dk, oi, obj)"
|
@node-click="(dk: string, oi: number, obj: any) => onNodeClick(dk, oi, obj)"
|
||||||
@reorder="(dk: string) => onLayerReorder(dk)"
|
@reorder="(dk: string) => onLayerReorder(dk)"
|
||||||
|
@edit-kr="(p: any) => onEditKr(p.dimKey, p.idx, p.obj, p.ki, p.kr)"
|
||||||
|
@add-kr="(p: any) => onAddKr(p.dimKey, p.idx, p.obj)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 跨层箭头指示器(根据视角切换方向) -->
|
<!-- 跨层箭头指示器(根据视角切换方向) -->
|
||||||
@@ -121,6 +123,8 @@
|
|||||||
:plan-popup-obj-name="planPopupObjName"
|
:plan-popup-obj-name="planPopupObjName"
|
||||||
:plan-popup-list="planPopupList"
|
:plan-popup-list="planPopupList"
|
||||||
:editing-layer-key="editingLayerKey"
|
:editing-layer-key="editingLayerKey"
|
||||||
|
:editing-obj="editingNodeData"
|
||||||
|
:all-kpis="allKpis"
|
||||||
@update:show-node-dialog="v => showNodeDialog = v"
|
@update:show-node-dialog="v => showNodeDialog = v"
|
||||||
@update:show-causality-dialog="v => showCausalityDialog = v"
|
@update:show-causality-dialog="v => showCausalityDialog = v"
|
||||||
@update:show-versions="v => showVersions = v"
|
@update:show-versions="v => showVersions = v"
|
||||||
@@ -357,6 +361,8 @@ function loadCanvas() {
|
|||||||
}
|
}
|
||||||
// 确保四层都存在
|
// 确保四层都存在
|
||||||
ensureFourLayers()
|
ensureFourLayers()
|
||||||
|
// 数据迁移:旧格式 kpis[] → krs[]
|
||||||
|
migrateOldKpiFormat()
|
||||||
connections.value = map?.canvas_data?.connections || []
|
connections.value = map?.canvas_data?.connections || []
|
||||||
loading.value = false
|
loading.value = false
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@@ -534,6 +540,11 @@ function openEditDialog(dimKey: string, oi: number, obj: any) {
|
|||||||
owner: obj.owner || '',
|
owner: obj.owner || '',
|
||||||
isLeading: obj.isLeading ?? obj.is_leading ?? false,
|
isLeading: obj.isLeading ?? obj.is_leading ?? false,
|
||||||
description: obj.description || '',
|
description: obj.description || '',
|
||||||
|
// O+KR 扩展字段(传递给弹窗预填)
|
||||||
|
krs: obj.krs || [],
|
||||||
|
o_name: obj.name,
|
||||||
|
o_description: obj.description || '',
|
||||||
|
_rawObjective: obj, // 保留原始引用以便直接修改KR
|
||||||
}
|
}
|
||||||
// 保存编辑索引以便更新
|
// 保存编辑索引以便更新
|
||||||
editingNodeData.value._dimKey = dimKey
|
editingNodeData.value._dimKey = dimKey
|
||||||
@@ -567,11 +578,15 @@ function onNodeSave(data: any) {
|
|||||||
// 编辑已有节点
|
// 编辑已有节点
|
||||||
const idx = editingNodeData.value._index
|
const idx = editingNodeData.value._index
|
||||||
if (dim.objectives[idx]) {
|
if (dim.objectives[idx]) {
|
||||||
// 保留原有kpis等额外字段
|
// 保留原有kpis/krs等额外字段(当新数据未提供时)
|
||||||
const extra = {
|
const extra = {
|
||||||
kpis: dim.objectives[idx].kpis || [],
|
kpis: dim.objectives[idx].kpis || [],
|
||||||
icon: dim.objectives[idx].icon || 'target',
|
icon: dim.objectives[idx].icon || 'target',
|
||||||
}
|
}
|
||||||
|
// 如果新数据没有提供krs,保留旧krs
|
||||||
|
if (!data.krs || data.krs.length === 0) {
|
||||||
|
node.krs = dim.objectives[idx].krs || []
|
||||||
|
}
|
||||||
dim.objectives[idx] = { ...node, ...extra }
|
dim.objectives[idx] = { ...node, ...extra }
|
||||||
ElMessage.success("节点已更新")
|
ElMessage.success("节点已更新")
|
||||||
}
|
}
|
||||||
@@ -604,6 +619,51 @@ async function deleteNode(dimKey: string, oi: number) {
|
|||||||
} catch { /* cancel */ }
|
} catch { /* cancel */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── KR操作 ──
|
||||||
|
function onEditKr(dimKey: string, oi: number, obj: any, ki: number, kr: any) {
|
||||||
|
// 打开O编辑弹窗并定位到对应KR
|
||||||
|
openEditDialog(dimKey, oi, obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAddKr(dimKey: string, oi: number, obj: any) {
|
||||||
|
const dim = dimensions.find((d: any) => d.key === dimKey)
|
||||||
|
if (!dim || !dim.objectives[oi]) return
|
||||||
|
const target = dim.objectives[oi]
|
||||||
|
if (!target.krs) target.krs = []
|
||||||
|
if (target.krs.length >= 5) {
|
||||||
|
ElMessage.warning('每个目标最多5个关键结果')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target.krs.push({
|
||||||
|
name: "",
|
||||||
|
target_value: "",
|
||||||
|
weight: 33,
|
||||||
|
kpi_code: null,
|
||||||
|
sort_order: target.krs.length + 1,
|
||||||
|
})
|
||||||
|
ElMessage.success("已添加空白KR,请编辑完善")
|
||||||
|
debouncedSaveCanvas()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 数据迁移:旧格式 kpis[] 自动转为 krs[] ──
|
||||||
|
function migrateOldKpiFormat() {
|
||||||
|
for (const dim of dimensions) {
|
||||||
|
for (const obj of dim.objectives || []) {
|
||||||
|
// 仅当有 kpis 数组 且 没有 krs 时执行迁移
|
||||||
|
if (obj.kpis && Array.isArray(obj.kpis) && obj.kpis.length > 0 && (!obj.krs || obj.krs.length === 0)) {
|
||||||
|
obj.krs = obj.kpis.map((kpiCode: string, idx: number) => ({
|
||||||
|
name: kpiNameMap[kpiCode] || kpiCode,
|
||||||
|
target_value: "",
|
||||||
|
weight: Math.round(100 / obj.kpis.length),
|
||||||
|
kpi_code: kpiCode,
|
||||||
|
sort_order: idx + 1,
|
||||||
|
}))
|
||||||
|
console.log(`[迁移] 目标「${obj.name}」: ${obj.kpis.length} 条KPI已转为KR`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 保存 ──
|
// ── 保存 ──
|
||||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let autoSaveTimer: ReturnType<typeof setInterval> | null = null
|
let autoSaveTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|||||||
Reference in New Issue
Block a user