diff --git a/frontend/src/components/map-canvas/MapCanvasDialogs.vue b/frontend/src/components/map-canvas/MapCanvasDialogs.vue index e3b40d2f..b08e4484 100644 --- a/frontend/src/components/map-canvas/MapCanvasDialogs.vue +++ b/frontend/src/components/map-canvas/MapCanvasDialogs.vue @@ -61,7 +61,10 @@
- 关键结果({{ form.krs.length }}条) + + 关键结果({{ form.krs.length }}条) + 每个目标至少保留 1 条 + ⚖️ 自动平分 + 添加KR @@ -72,7 +75,7 @@
#{{ ki + 1 }} - × + ×
@@ -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; diff --git a/frontend/src/components/strategy-map/MapNode.vue b/frontend/src/components/strategy-map/MapNode.vue index 63083575..1ae4b3ad 100644 --- a/frontend/src/components/strategy-map/MapNode.vue +++ b/frontend/src/components/strategy-map/MapNode.vue @@ -51,6 +51,26 @@ {{ ki + 1 }} {{ kr.name }} 目标: {{ kr.target_value }} + +
@@ -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)[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; diff --git a/frontend/src/components/strategy-map/StrategyLayer.vue b/frontend/src/components/strategy-map/StrategyLayer.vue index faf6d3f8..17698e55 100644 --- a/frontend/src/components/strategy-map/StrategyLayer.vue +++ b/frontend/src/components/strategy-map/StrategyLayer.vue @@ -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 })" />
@@ -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 }>() diff --git a/frontend/src/views/MapCanvas.vue b/frontend/src/views/MapCanvas.vue index 0ec9b948..eb15f6b6 100644 --- a/frontend/src/views/MapCanvas.vue +++ b/frontend/src/views/MapCanvas.vue @@ -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" /> + + + +
+ 确定删除关键结果「{{ krDeleteTarget?.name }}」? +
+
+ 删除后该 KR 会从目标中移除,并同步删除 krs 表中对应记录(不可撤销)。 +
+ +
@@ -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; +}