feat(cma): strategy map lane KR delete entry (cma-map-kr-delete-entry-20260916)
- MapNode.vue: hover trash icon per KR row emitting delete-kr (inline SVG, no emoji) - StrategyLayer.vue / MapCanvas.vue: passthrough + MyDialog second confirm + okrApi.deleteKr + whole-map save - MapsDialog: single-KR policy hint (keep at least 1 KR per objective)
This commit is contained in:
@@ -61,7 +61,10 @@
|
||||
|
||||
<div class="mc-kr-section">
|
||||
<div class="mc-kr-header">
|
||||
<span>关键结果({{ form.krs.length }}条)</span>
|
||||
<span>
|
||||
关键结果({{ form.krs.length }}条)
|
||||
<span class="mc-kr-min-hint">每个目标至少保留 1 条</span>
|
||||
</span>
|
||||
<span style="display:flex;gap:6px;">
|
||||
<el-button size="small" plain @click="autoDivideWeights">⚖️ 自动平分</el-button>
|
||||
<el-button size="small" type="primary" plain @click="addKr">+ 添加KR</el-button>
|
||||
@@ -72,7 +75,7 @@
|
||||
<div class="mc-kr-row-top">
|
||||
<span class="mc-kr-index">#{{ ki + 1 }}</span>
|
||||
<input v-model="kr.name" class="mc-input mc-kr-name" placeholder="KR名称" />
|
||||
<el-button v-if="form.krs.length > 1" size="small" text type="danger" @click="removeKr(ki)">×</el-button>
|
||||
<el-button v-if="form.krs.length > 1" size="small" text type="danger" title="删除该关键结果" @click="removeKr(ki)">×</el-button>
|
||||
</div>
|
||||
<div class="mc-kr-row-bottom">
|
||||
<div class="mc-kr-field">
|
||||
@@ -937,6 +940,13 @@ function planStatusLabel(s: string): string {
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
}
|
||||
/* 单条 KR 口径提示(2026-09-16):不允许删到 0,UI 明示 */
|
||||
.mc-kr-min-hint {
|
||||
margin-left: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #909399;
|
||||
}
|
||||
.mc-kr-row {
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 6px;
|
||||
|
||||
@@ -51,6 +51,26 @@
|
||||
<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>
|
||||
<!-- KR删除入口(2026-09-16):hover 显示,内联 SVG(Tabler trash),禁用 Emoji -->
|
||||
<button
|
||||
type="button"
|
||||
class="kr-sub-del"
|
||||
:title="canDeleteKr ? '删除该关键结果' : '每个目标至少保留 1 条关键结果'"
|
||||
:aria-label="`删除关键结果 ${kr.name || ki + 1}`"
|
||||
@click.stop="$emit('delete-kr', { index: ki, kr })"
|
||||
>
|
||||
<svg
|
||||
width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 7h16" />
|
||||
<path d="M10 11v6" />
|
||||
<path d="M14 11v6" />
|
||||
<path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12" />
|
||||
<path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="kr-sub-bottom">
|
||||
<div class="kr-sub-progress-wrap">
|
||||
@@ -178,6 +198,7 @@ const emit = defineEmits<{
|
||||
(e: 'kpi-click', kpiCode: string): void
|
||||
(e: 'plan-click'): void
|
||||
(e: 'edit-kr', payload: { index: number; kr: any }): void
|
||||
(e: 'delete-kr', payload: { index: number; kr: any }): void
|
||||
(e: 'add-kr'): void
|
||||
}>()
|
||||
|
||||
@@ -188,6 +209,17 @@ const hasKrs = computed(() => {
|
||||
return props.objective?.krs && Array.isArray(props.objective.krs) && props.objective.krs.length > 0
|
||||
})
|
||||
|
||||
/**
|
||||
* 是否允许删除 KR。
|
||||
* 口径(2026-09-16):每个目标至少保留 1 条关键结果——不允许删到 0。
|
||||
* 依据:后端 maps.py `_sync_objectives_krs` 在 `krs` 为空时跳过同步(`if not krs: continue`),
|
||||
* 删到 0 会让 krs 表残留孤儿行;且无 KR 的目标无法计算达成率,与「KR 是 O 的度量单元」口径相悖。
|
||||
* 单条 KR 时按钮仍显示(可发现),点击给出明确提示而不是静默置灰。
|
||||
*/
|
||||
const canDeleteKr = computed(() => {
|
||||
return Array.isArray(props.objective?.krs) && props.objective.krs.length > 1
|
||||
})
|
||||
|
||||
const objIcon = computed(() => {
|
||||
if (props.objective.icon && props.iconMap) {
|
||||
return (props.iconMap as Record<string, string>)[props.objective.icon]
|
||||
@@ -484,6 +516,35 @@ function getKrProgressText(kr: any): string {
|
||||
color: #909399;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* KR 删除入口:hover 才显形,避免与 KR 文本拥挤;键盘聚焦时同样可见(可访问性) */
|
||||
.kr-sub-del {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #c0c4cc;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity .15s, color .15s, background .15s, border-color .15s;
|
||||
}
|
||||
.kr-sub-item:hover .kr-sub-del { opacity: 1; }
|
||||
.kr-sub-del:focus-visible {
|
||||
opacity: 1;
|
||||
outline: none;
|
||||
border-color: #f56c6c;
|
||||
}
|
||||
.kr-sub-del:hover {
|
||||
color: #f56c6c;
|
||||
background: #fef0f0;
|
||||
border-color: #fbc4c4;
|
||||
}
|
||||
.kr-sub-del:active { transform: scale(0.92); }
|
||||
.kr-sub-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
@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 })"
|
||||
@delete-kr="(p: any) => $emit('delete-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>
|
||||
@@ -116,6 +117,7 @@ const emit = defineEmits<{
|
||||
(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: 'delete-kr', payload: { dimKey: string; idx: number; obj: any; ki: number; kr: any }): void
|
||||
(e: 'add-kr', payload: { dimKey: string; idx: number; obj: any }): void
|
||||
}>()
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
@node-click="(dk: string, oi: number, obj: any) => onNodeClick(dk, oi, obj)"
|
||||
@reorder="(dk: string) => onLayerReorder(dk)"
|
||||
@edit-kr="(p: any) => onEditKr(p.dimKey, p.idx, p.obj, p.ki, p.kr)"
|
||||
@delete-kr="(p: any) => onDeleteKr(p.dimKey, p.idx, p.obj, p.ki, p.kr)"
|
||||
@add-kr="(p: any) => onAddKr(p.dimKey, p.idx, p.obj)"
|
||||
/>
|
||||
|
||||
@@ -167,6 +168,20 @@
|
||||
@delete-connection="onManagerDeleteConnection"
|
||||
@update-connection="onManagerUpdateConnection"
|
||||
/>
|
||||
|
||||
<!-- KR 删除二次确认(2026-09-16):项目统一弹窗组件 MyDialog(el-dialog 有历史 bug) -->
|
||||
<MyDialog v-model="showKrDeleteConfirm" title="删除关键结果" :width="420">
|
||||
<div class="kr-del-confirm-text">
|
||||
确定删除关键结果「<strong>{{ krDeleteTarget?.name }}</strong>」?
|
||||
</div>
|
||||
<div class="kr-del-confirm-hint">
|
||||
删除后该 KR 会从目标中移除,并同步删除 krs 表中对应记录(不可撤销)。
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button size="small" @click="cancelDeleteKr">取消</el-button>
|
||||
<el-button size="small" type="danger" :loading="krDeleting" @click="confirmDeleteKr">删除</el-button>
|
||||
</template>
|
||||
</MyDialog>
|
||||
</div>
|
||||
<!-- P1-3: 关联知识点 -->
|
||||
<div style="margin-top:12px;">
|
||||
@@ -189,6 +204,7 @@ import ConnectionLines from "../components/strategy/ConnectionLines.vue"
|
||||
import MapCanvasToolbar from "../components/map-canvas/MapCanvasToolbar.vue"
|
||||
import MapCanvasDialogs from "../components/map-canvas/MapCanvasDialogs.vue"
|
||||
import ConnectionManagerDialog from "../components/map-canvas/ConnectionManagerDialog.vue"
|
||||
import MyDialog from "../components/MyDialog.vue"
|
||||
import html2canvas from "html2canvas"
|
||||
|
||||
// ── 四层泳道配置 ──
|
||||
@@ -966,6 +982,67 @@ function onAddKr(dimKey: string, oi: number, obj: any) {
|
||||
debouncedSaveCanvas()
|
||||
}
|
||||
|
||||
// ── KR 删除(2026-09-16:泳道内删除入口,补齐「目标展开后没有删除图标」缺口) ──
|
||||
const showKrDeleteConfirm = ref(false)
|
||||
const krDeleting = ref(false)
|
||||
const krDeleteTarget = ref<{ dimKey: string; oi: number; ki: number; name: string } | null>(null)
|
||||
|
||||
/**
|
||||
* KR 删除入口:单条时不允许删到 0(口径见 _reports/cma-map-kr-delete-entry-20260916-return.md)
|
||||
* 依据:① 后端 maps.py `_sync_objectives_krs` 在目标 krs 为空时 `continue`(跳过同步),
|
||||
* 删到 0 不会传播到 krs 表 → 残留孤儿行;② 无 KR 的目标无法度量达成率。
|
||||
* 单条 KR 时按钮仍然显示(可发现),点击给出明确提示,避免"图标消失=功能缺失"的观感。
|
||||
*/
|
||||
function onDeleteKr(dimKey: string, oi: number, obj: any, ki: number, kr: any) {
|
||||
const dim = dimensions.find((d: any) => d.key === dimKey)
|
||||
const target = dim?.objectives?.[oi]
|
||||
const list: any[] = target?.krs || []
|
||||
if (!target || ki < 0 || ki >= list.length) return
|
||||
if (list.length <= 1) {
|
||||
ElMessage.warning('每个目标至少保留 1 条关键结果;如需替换请先添加新的 KR')
|
||||
return
|
||||
}
|
||||
krDeleteTarget.value = { dimKey, oi, ki, name: kr?.name || `KR ${ki + 1}` }
|
||||
showKrDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
function cancelDeleteKr() {
|
||||
showKrDeleteConfirm.value = false
|
||||
krDeleteTarget.value = null
|
||||
}
|
||||
|
||||
async function confirmDeleteKr() {
|
||||
const t = krDeleteTarget.value
|
||||
if (!t || krDeleting.value) return
|
||||
const dim = dimensions.find((d: any) => d.key === t.dimKey)
|
||||
const target = dim?.objectives?.[t.oi]
|
||||
const kr = target?.krs?.[t.ki]
|
||||
if (!target || !kr) { cancelDeleteKr(); return }
|
||||
krDeleting.value = true
|
||||
// 落库 id:_objective_id 由地图保存时后端回写;_kr_id 由 KR 同步/地图保存时回写
|
||||
const objectiveId: number | null = target._objective_id || null
|
||||
const krId: number | null = kr._kr_id || kr.id || null
|
||||
const removedName = kr.name || `KR ${t.ki + 1}`
|
||||
// 1) 本地移除(界面即时无残留)
|
||||
target.krs.splice(t.ki, 1)
|
||||
cancelDeleteKr()
|
||||
try {
|
||||
if (objectiveId && krId) {
|
||||
// 2) 精确删除 krs 表对应行
|
||||
await okrApi.deleteKr(objectiveId, krId)
|
||||
}
|
||||
// 3) 地图 JSON 为权威:整体保存(服务端 _sync_objectives_krs 亦会清理标题不在 JSON 中的 KR 行)
|
||||
if (isSaving) await new Promise(r => setTimeout(r, 600))
|
||||
await saveCanvas(true)
|
||||
ElMessage.success(`关键结果「${removedName}」已删除`)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(`删除失败:${e?.response?.data?.detail || e?.message || '未知错误'}`)
|
||||
} finally {
|
||||
krDeleting.value = false
|
||||
}
|
||||
nextTick(triggerRecalc)
|
||||
}
|
||||
|
||||
// ── 数据迁移:旧格式 kpis[] 自动转为 krs[] ──
|
||||
function migrateOldKpiFormat() {
|
||||
for (const dim of dimensions) {
|
||||
@@ -1373,4 +1450,21 @@ onUnmounted(() => {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── KR 删除确认(MyDialog 内容样式,2026-09-16) ── */
|
||||
.kr-del-confirm-text {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.kr-del-confirm-text strong {
|
||||
color: #f56c6c;
|
||||
word-break: break-all;
|
||||
}
|
||||
.kr-del-confirm-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user