Files
cma-management/frontend/src/components/map-canvas/MapCanvasDialogs.vue
T
Hermes CI Fix 61936302b2 feat(KR): KR完整修复 — krs表打通+方向符号operator+权重自由输入
- 后端okr.py: KR读取从ActionPlan改为krs表, 新增KR CRUD API(POST/PUT/DELETE /okr/{objective_id}/krs) + 批量sync
- operator方向符号: krs表加operator/tolerance/weight/sort_order/monthly_milestones列
- progress方向感知计算(>=/>: current/target, <=/<: target/current, =: 容差), 达成→status=achieved
- 关联KPI自动继承方向(threshold_green解析: F_COST_RATIO<=18等)
- maps.py: 保存地图时自动同步objectives+krs表, JSON→krs数据迁移脚本
- 前端: 权重下拉改自由数字输入(可小数33.33) + 方向选择器(≥/≤/>/</=) + 自动平分按钮 + KPI方向继承提示
- pytest: 10个新测试(krs CRUD/方向感知/权重校验/多租户隔离) + 更新旧KR测试
- 迁移: 现有strategic_maps JSON 12条KR已写入krs表
2026-08-27 10:59:56 +08:00

1004 lines
35 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- ==========================================================
第一步选择模板弹窗
========================================================== -->
<MyDialog :model-value="showNodeDialog" @update:model-value="$emit('update:showNodeDialog', $event)" :title="dialogTitle" :width="620">
<div v-if="dialogStep === 1" class="mc-template-step">
<div class="mc-step-hint">步骤 1/2 选择目标模板或自定义</div>
<!-- 维度名称显示 -->
<div class="mc-dim-label">{{ dimensionName }}</div>
<!-- 模板列表 -->
<div v-if="templateLoading" class="mc-tmpl-empty">模板库加载中...</div>
<div v-else class="mc-tmpl-list">
<div
v-for="(tmpl, idx) in filteredTemplates"
:key="idx"
class="mc-tmpl-card"
:class="{ selected: selectedTemplateIdx === idx }"
@click="selectedTemplateIdx = idx"
>
<div class="mc-tmpl-header">
<span class="mc-tmpl-name">{{ tmpl.name }}</span>
<span class="mc-tmpl-tags">
<el-tag v-if="tmpl.source === 'industry_pack'" size="small" type="warning" style="margin-right:6px;">
{{ industryTagLabel(tmpl.industry_tag) }}
</el-tag>
<el-tag v-else-if="tmpl.source === 'user'" size="small" type="success" style="margin-right:6px;">用户</el-tag>
<span v-if="selectedTemplateIdx === idx" class="mc-tmpl-check"></span>
</span>
</div>
<div class="mc-tmpl-desc">{{ tmpl.description }}</div>
<div class="mc-tmpl-krs">
<span v-for="(kr, ki) in tmpl.preset_krs" :key="ki" class="mc-tmpl-kr-tag">
{{ kr.name }}
</span>
</div>
</div>
</div>
<!-- 无模板提示理论上不会出现留防御 -->
<div v-if="filteredTemplates.length === 0" class="mc-tmpl-empty">
当前维度没有预设模板请直接自定义
</div>
</div>
<!-- ==========================================================
第二步编辑 O + KR
========================================================== -->
<div v-if="dialogStep === 2" class="mc-okr-step">
<div class="mc-step-hint">{{ isEditing ? '编辑目标与关键结果' : '步骤 2/2 — 编辑目标与关键结果' }}</div>
<div class="mc-form-item">
<label>🎯 目标名称 <span class="mc-required">*</span></label>
<input v-model="form.o_name" class="mc-input" placeholder="请输入目标名称" />
</div>
<div class="mc-form-item">
<label>📝 目标描述</label>
<textarea v-model="form.o_description" class="mc-input mc-textarea" rows="2" placeholder="目标描述(可选)"></textarea>
</div>
<div class="mc-kr-section">
<div class="mc-kr-header">
<span>关键结果{{ form.krs.length }}</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>
</span>
</div>
<div v-for="(kr, ki) in form.krs" :key="ki" class="mc-kr-row">
<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>
</div>
<div class="mc-kr-row-bottom">
<div class="mc-kr-field">
<label>方向 <span class="mc-required">*</span></label>
<select v-model="kr.operator" class="mc-input mc-kr-input">
<option value=">="> 大于等于</option>
<option value="<="> 小于等于</option>
<option value=">">&gt; 大于</option>
<option value="<">&lt; 小于</option>
<option value="=">= 等于</option>
</select>
</div>
<div class="mc-kr-field">
<label>目标值 <span class="mc-required">*</span></label>
<input v-model.number="kr.target_value" type="number" step="0.01" min="0" class="mc-input mc-kr-input" placeholder="目标数值" @input="formatTargetValue(ki)" />
</div>
<div class="mc-kr-field">
<label>权重0-100可小数</label>
<input v-model.number="kr.weight" type="number" step="0.01" min="0" max="100" class="mc-input mc-kr-input" placeholder="权重%" @input="checkWeightSum" />
</div>
<div class="mc-kr-field">
<label>关联KPI</label>
<select v-model="kr.kpi_code" class="mc-input mc-kr-input" @change="onKpiChange(ki)">
<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 v-if="weightSum !== null && weightSum !== 100" class="mc-kr-warning"> 权重合计 {{ weightSum }}%应为100%</div>
</div>
</div>
</div>
<template #footer>
<div v-if="dialogStep === 1" class="mc-footer-buttons">
<el-button size="small" @click="onCancel">取消</el-button>
<el-button size="small" plain @click="skipTemplate"> 跳过模板自定义</el-button>
<el-button size="small" text type="primary" @click="goTemplateLibrary">📚 从模板库选择更多</el-button>
<el-button size="small" type="primary" @click="nextStep">
下一步 {{ selectedTemplateIdx !== null ? '(已选模板)' : '(直接跳过)' }}
</el-button>
</div>
<div v-if="dialogStep === 2" class="mc-footer-buttons">
<el-button v-if="!isEditing" size="small" @click="prevStep">上一步</el-button>
<el-button size="small" @click="onCancel">取消</el-button>
<el-button size="small" type="primary" @click="onSave">保存</el-button>
</div>
</template>
</MyDialog>
<!-- ==========================================================
KPI 因果链推荐弹窗不变
========================================================== -->
<MyDialog :model-value="showCausalityDialog" @update:model-value="$emit('update:showCausalityDialog', $event)" title="KPI因果链推荐" :width="680">
<div style="max-height:400px;overflow-y:auto;">
<div v-if="causalityLoading" style="text-align:center;padding:20px;color:#999;">加载中...</div>
<div v-else-if="causalityRecommendations.length === 0" style="text-align:center;padding:20px;color:#999;">暂无因果链推荐</div>
<div v-else>
<div v-for="(rec,idx) in causalityRecommendations" :key="idx" style="padding:10px 0;border-bottom:1px solid #f0f0f0;display:flex;justify-content:space-between;align-items:center;">
<div>
<strong>{{ rec.source_kpi_code || rec.source_code }}</strong>
<span style="color:#999;margin:0 8px;"></span>
<strong>{{ rec.target_kpi_code || rec.target_code }}</strong>
<span style="margin-left:8px;font-size:12px;color:#666;">强度: {{ rec.strength || '-' }}</span>
</div>
<el-button size="small" @click="$emit('apply-causality', rec)">应用</el-button>
</div>
</div>
</div>
<template #footer>
<el-button size="small" @click="$emit('update:showCausalityDialog', false)">取消</el-button>
<el-button size="small" type="primary" @click="$emit('apply-all-causality')">一键全部应用</el-button>
</template>
</MyDialog>
<!-- ==========================================================
版本历史弹窗不变
========================================================== -->
<MyDialog :model-value="showVersions" @update:model-value="$emit('update:showVersions', $event)" title="版本历史" :width="620">
<div style="max-height:400px;overflow-y:auto;">
<table class="mc-table" v-if="versions.length > 0">
<thead><tr><th>说明</th><th>创建时间</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="v in versions" :key="v.id">
<td>{{ v.comment || '-' }}</td>
<td>{{ v.created_at }}</td>
<td><el-button size="small" type="warning" @click="$emit('rollback-version', v.id)">回滚</el-button></td>
</tr>
</tbody>
</table>
<div v-else style="text-align:center;color:#999;padding:20px;">暂无版本记录</div>
</div>
</MyDialog>
<!-- ==========================================================
KPI 详情浮层不变
========================================================== -->
<MyDialog :model-value="showKpiDetail" @update:model-value="$emit('update:showKpiDetail', $event)" :title="'KPI详情'" :width="560">
<div style="max-height:360px;overflow-y:auto;">
<div v-if="kpiDetailList.length === 0" style="text-align:center;color:#999;padding:20px;">
该目标暂未关联KPI
</div>
<div v-for="(item, idx) in kpiDetailList" :key="idx" class="kpi-detail-card" :class="'kpi-detail-' + getKpiLevel(item)">
<div class="kpi-detail-top">
<span class="kpi-detail-badge">{{ badgeIcon(getKpiLevel(item)) }}</span>
<span class="kpi-detail-code">{{ item.code }}</span>
<span class="kpi-detail-name">{{ item.name }}</span>
</div>
<div class="kpi-detail-values">
<div class="kdv-item">
<span class="kdv-label">实际值</span>
<span class="kdv-val">{{ item.actual != null ? fmtKpiVal(item.actual) : '—' }}</span>
</div>
<div class="kdv-item">
<span class="kdv-label">目标值</span>
<span class="kdv-val">{{ item.target != null ? fmtKpiVal(item.target) : '—' }}</span>
</div>
<div v-if="item.actual != null && item.target" class="kdv-item">
<span class="kdv-label">达成率</span>
<span class="kdv-val" :style="{ color: getKpiLevel(item) === 'red' ? '#f56c6c' : getKpiLevel(item) === 'yellow' ? '#e6a23c' : '#67c23a' }">
{{ (item.actual / item.target * 100).toFixed(1) }}%
</span>
</div>
<div v-if="item.unit" class="kdv-item">
<span class="kdv-label">单位</span>
<span class="kdv-val">{{ item.unit }}</span>
</div>
</div>
</div>
</div>
<template #footer>
<el-button size="small" @click="$emit('update:showKpiDetail', false)">关闭</el-button>
<el-button size="small" type="primary" @click="$emit('edit-kpi-obj')">编辑目标</el-button>
</template>
</MyDialog>
<!-- ==========================================================
行动方案列表弹窗不变
========================================================== -->
<MyDialog :model-value="showPlanPopup" @update:model-value="$emit('update:showPlanPopup', $event)" :title="'行动方案'" :width="600">
<div style="max-height:400px;overflow-y:auto;">
<div v-if="planPopupList.length === 0" style="text-align:center;color:#999;padding:20px;">
暂无关联的行动方案
</div>
<div v-for="p in planPopupList" :key="p.id" class="plan-popup-card">
<div class="plan-popup-top">
<span class="plan-popup-status" :class="'pps-' + p.status">{{ planStatusLabel(p.status) }}</span>
<span class="plan-popup-title">{{ p.title }}</span>
</div>
<div class="plan-popup-meta">
<span v-if="p.assignee">👤 {{ p.assignee }}</span>
<span v-if="p.due_date">📅 {{ p.due_date.slice(0, 10) }}</span>
<span v-if="p.progress > 0">进度 {{ p.progress }}%</span>
</div>
</div>
</div>
<template #footer>
<el-button size="small" @click="$emit('update:showPlanPopup', false)">关闭</el-button>
<el-button size="small" type="primary" @click="$emit('quick-create-plan')">+ 新建行动方案</el-button>
</template>
</MyDialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue"
import { useRouter } from "vue-router"
import { ElMessage } from "element-plus"
import { okrTemplateApi } from "../../api/index"
// ── Props ──
const props = defineProps<{
showNodeDialog: boolean
showCausalityDialog: boolean
showVersions: boolean
showKpiDetail: boolean
showPlanPopup: boolean
isEditing: boolean
form: any // 保持兼容
causalityLoading: boolean
causalityRecommendations: any[]
versions: any[]
kpiDetailObjName: string
kpiDetailList: any[]
planPopupObjName: string
planPopupList: any[]
editingLayerKey?: string // 当前编辑的维度key
editingObj?: any // 正在编辑的目标对象(含krs
allKpis?: any[] // KPI字典数据(用于KR关联KPI
}>()
// ── Emits ──
const emit = defineEmits<{
(e: 'update:showNodeDialog', v: boolean): void
(e: 'update:showCausalityDialog', v: boolean): void
(e: 'update:showVersions', v: boolean): void
(e: 'update:showKpiDetail', v: boolean): void
(e: 'update:showPlanPopup', v: boolean): void
(e: 'save-node-dialog', data: any): void
(e: 'apply-causality', rec: any): void
(e: 'apply-all-causality'): void
(e: 'rollback-version', id: number): void
(e: 'edit-kpi-obj'): void
(e: 'quick-create-plan'): void
}>()
// ── 14 个 CMA 标准模板(硬编码) ──
const CMA_TEMPLATES = {
finance: [
{
name: "扩大收入规模",
description: "通过新市场开拓、新产品线、新客户群实现收入增长",
dimension: "finance",
preset_krs: [
{ name: "营收增长率≥20%", target_value: "≥20%", weight: 40 },
{ name: "新客户收入占比≥15%", target_value: "≥15%", weight: 30 },
{ name: "客单价提升≥10%", target_value: "≥10%", weight: 30 },
],
},
{
name: "优化成本结构",
description: "通过成本降低和费用管控提升盈利能力",
dimension: "finance",
preset_krs: [
{ name: "毛利率提升≥3%", target_value: "≥3%", weight: 40 },
{ name: "管理费用率≤15%", target_value: "≤15%", weight: 30 },
{ name: "销售费用率≤10%", target_value: "≤10%", weight: 30 },
],
},
{
name: "提升资产效率",
description: "提高资产周转速度,释放沉淀资金",
dimension: "finance",
preset_krs: [
{ name: "库存周转天数≤45天", target_value: "≤45天", weight: 34 },
{ name: "应收账款周转天数≤45天", target_value: "≤45天", weight: 33 },
{ name: "总资产周转率≥1.0", target_value: "≥1.0", weight: 33 },
],
},
{
name: "保障现金流安全",
description: "确保经营现金流为正,防范流动性风险",
dimension: "finance",
preset_krs: [
{ name: "现金比率≥20%", target_value: "≥20%", weight: 34 },
{ name: "经营现金流为正", target_value: "为正", weight: 33 },
{ name: "自由现金流≥0", target_value: "≥0", weight: 33 },
],
},
],
customer: [
{
name: "产品领先",
description: "提供行业最优的产品质量和功能",
dimension: "customer",
preset_krs: [
{ name: "产品合格率≥99%", target_value: "≥99%", weight: 34 },
{ name: "NPS净推荐值≥60", target_value: "≥60", weight: 33 },
{ name: "市场份额提升≥2%", target_value: "≥2%", weight: 33 },
],
},
{
name: "客户亲密",
description: "建立深度客户关系,提升客户粘性",
dimension: "customer",
preset_krs: [
{ name: "客户满意度≥90%", target_value: "≥90%", weight: 34 },
{ name: "大客户留存率≥95%", target_value: "≥95%", weight: 33 },
{ name: "服务响应时间≤2小时", target_value: "≤2小时", weight: 33 },
],
},
{
name: "卓越运营",
description: "以最优的价格和最高的便利性服务客户",
dimension: "customer",
preset_krs: [
{ name: "价格竞争力行业TOP3", target_value: "TOP3", weight: 34 },
{ name: "按时交付率≥98%", target_value: "≥98%", weight: 33 },
{ name: "客户投诉率≤1%", target_value: "≤1%", weight: 33 },
],
},
],
process: [
{
name: "运营高效顺畅",
description: "优化供应链和交付流程,提升运营效率",
dimension: "process",
preset_krs: [
{ name: "交付及时率≥95%", target_value: "≥95%", weight: 34 },
{ name: "库存周转≤45天", target_value: "≤45天", weight: 33 },
{ name: "流程自动化率≥30%", target_value: "≥30%", weight: 33 },
],
},
{
name: "客户管理有序",
description: "建立标准化客户管理流程",
dimension: "process",
preset_krs: [
{ name: "新客获取成本下降≥10%", target_value: "≥10%", weight: 34 },
{ name: "客户流失率≤5%", target_value: "≤5%", weight: 33 },
{ name: "客户线索转化率≥20%", target_value: "≥20%", weight: 33 },
],
},
{
name: "创新驱动增长",
description: "通过产品和服务创新驱动业务增长",
dimension: "process",
preset_krs: [
{ name: "新产品收入占比≥15%", target_value: "≥15%", weight: 34 },
{ name: "研发投入占比≥5%", target_value: "≥5%", weight: 33 },
{ name: "年度创新提案≥12项", target_value: "≥12项", weight: 33 },
],
},
{
name: "合规稳健运营",
description: "确保合规运营,防范法律和税务风险",
dimension: "process",
preset_krs: [
{ name: "合规事件0起", target_value: "0起", weight: 34 },
{ name: "审计通过率100%", target_value: "100%", weight: 33 },
{ name: "税务申报及时率100%", target_value: "100%", weight: 33 },
],
},
],
learning: [
{
name: "团队能力升级",
description: "提升员工核心技能和关键岗位胜任度",
dimension: "learning",
preset_krs: [
{ name: "关键岗位培训完成率100%", target_value: "100%", weight: 34 },
{ name: "认证持有率≥80%", target_value: "≥80%", weight: 33 },
{ name: "人均培训时长≥40小时/年", target_value: "≥40小时/年", weight: 33 },
],
},
{
name: "数字系统赋能",
description: "通过数字化系统提升工作效率",
dimension: "learning",
preset_krs: [
{ name: "系统覆盖率≥90%", target_value: "≥90%", weight: 34 },
{ name: "数据自动化率≥70%", target_value: "≥70%", weight: 33 },
{ name: "报表自动生成率≥80%", target_value: "≥80%", weight: 33 },
],
},
{
name: "组织协同对齐",
description: "建立高绩效文化和战略共识",
dimension: "learning",
preset_krs: [
{ name: "员工满意度≥85%", target_value: "≥85%", weight: 34 },
{ name: "战略认知度≥80%", target_value: "≥80%", weight: 33 },
{ name: "跨部门协作评分≥4分", target_value: "≥4分", weight: 33 },
],
},
],
}
// ── 维度中文名映射 ──
const DIMENSION_LABELS: Record<string, string> = {
finance: "💰 财务层",
customer: "👥 客户层",
process: "⚙️ 内部流程层",
learning: "📚 学习成长层",
}
// ── 状态 ──
const dialogStep = ref(1) // 1 = 模板选择, 2 = O+KR编辑
const selectedTemplateIdx = ref<number | null>(null)
const usedTemplate = ref<any | null>(null)
const backendTemplates = ref<any[]>([]) // 后端模板库(含行业包/用户自定义)
const templateLoading = ref(false)
const router = useRouter()
// 内部表单(O+KR结构)
const form = ref<{
o_name: string
o_description: string
krs: { name: string; operator: string; target_value: number | null; weight: number | null; kpi_code: string }[]
}>({
o_name: "",
o_description: "",
krs: [],
})
// ── 计算属性 ──
const dimensionName = computed(() => {
const key = props.editingLayerKey || "finance"
return DIMENSION_LABELS[key] || key
})
const filteredTemplates = computed(() => {
const key = props.editingLayerKey || "finance"
const hardcoded = (CMA_TEMPLATES as any)[key] || []
// 后端模板库(14个CMA标准 + 行业包 + 用户自定义)按维度过滤,与硬编码去重合并
const fromBackend = backendTemplates.value
.filter((t: any) => t.dimension === key && t.is_active !== 0)
.map((t: any) => ({
id: t.id,
name: t.name,
description: t.description,
dimension: t.dimension,
source: t.source,
industry_tag: t.industry_tag,
preset_krs: (t.preset_krs || []).map((kr: any) => ({
name: kr.name,
target_value: kr.target_value,
weight: kr.weight,
kpi_code: kr.kpi_code || "",
})),
}))
const seen = new Set<string>()
const merged: any[] = []
for (const t of [...fromBackend, ...hardcoded]) {
if (seen.has(t.name)) continue
seen.add(t.name)
merged.push(t)
}
return merged
})
const dialogTitle = computed(() => {
return props.isEditing ? "编辑目标" : "添加目标"
})
const INDUSTRY_LABELS: Record<string, string> = {
trading: "贸易经销",
it_service: "IT服务",
manufacturing: "制造业",
general: "通用",
}
function industryTagLabel(tag?: string): string {
return (tag && INDUSTRY_LABELS[tag]) || tag || "行业包"
}
const kpiOptions = computed(() => {
return props.allKpis || []
})
// ── 方法 ──
function resetForm() {
dialogStep.value = 1
selectedTemplateIdx.value = null
usedTemplate.value = null
form.value = {
o_name: "",
o_description: "",
krs: [],
}
}
function skipTemplate() {
dialogStep.value = 2
selectedTemplateIdx.value = null
usedTemplate.value = null
form.value = {
o_name: "",
o_description: "",
krs: [
{ name: "", operator: ">=", target_value: null, weight: 33, kpi_code: "" },
{ name: "", operator: ">=", target_value: null, weight: 33, kpi_code: "" },
{ name: "", operator: ">=", target_value: null, weight: 34, kpi_code: "" },
],
}
}
function nextStep() {
if (selectedTemplateIdx.value === null) return
const tmpl = filteredTemplates.value[selectedTemplateIdx.value]
if (!tmpl) return
usedTemplate.value = tmpl
dialogStep.value = 2
form.value = {
o_name: tmpl.name,
o_description: tmpl.description,
krs: tmpl.preset_krs.map((kr: any) => {
const parsed = parseKrTarget(kr.target_value)
return {
name: kr.name,
operator: parsed.operator,
target_value: parsed.value,
weight: Number(kr.weight) || 33,
kpi_code: kr.kpi_code || "",
}
}),
}
}
// ── KR 目标值解析(兼容旧格式 "≥20%" / "<=18" → operator + 数值) ──
function parseKrTarget(raw: any): { operator: string; value: number | null } {
if (raw == null || raw === "") return { operator: ">=", value: null }
if (typeof raw === "number") return { operator: ">=", value: raw }
const s = String(raw).trim()
let op = ">="
let rest = s
for (const cand of [">=", "<=", ">", "<", "="]) {
if (s.startsWith(cand)) { op = cand; rest = s.slice(cand.length); break }
}
if (rest.startsWith("≥")) { op = ">="; rest = rest.slice(1) }
else if (rest.startsWith("≤")) { op = "<="; rest = rest.slice(1) }
const m = rest.match(/[-+]?\d*\.?\d+/)
return { operator: op, value: m ? parseFloat(m[0]) : null }
}
function prevStep() {
dialogStep.value = 1
selectedTemplateIdx.value = null
}
const weightSum = computed(() => {
return form.value.krs.reduce((s, kr) => s + (parseFloat(String(kr.weight)) || 0), 0)
})
// ── 从后端加载模板库(失败时静默回退到硬编码模板) ──
async function loadBackendTemplates() {
templateLoading.value = true
try {
const res: any = await okrTemplateApi.list({})
const items = res?.items || []
// 后端模板需要标准化为前端结构
backendTemplates.value = items.map((t: any) => ({
id: t.id,
name: t.name,
description: t.description,
dimension: t.dimension,
source: t.source,
industry_tag: t.industry_tag,
is_active: t.is_active,
preset_krs: (t.preset_krs || []).map((kr: any) => ({
name: kr.name,
target_value: kr.target_value,
weight: kr.weight,
kpi_code: kr.kpi_code || "",
})),
}))
} catch (e) {
// 网络/权限失败:使用硬编码14个CMA模板兜底
backendTemplates.value = []
} finally {
templateLoading.value = false
}
}
onMounted(() => {
loadBackendTemplates()
})
function goTemplateLibrary() {
router.push('/okr-templates')
}
function addKr() {
if (form.value.krs.length >= 5) {
ElMessage.warning('每个目标最多5个关键结果')
return
}
form.value.krs.push({ name: "", operator: ">=", target_value: null, weight: 33, kpi_code: "" })
}
function removeKr(idx: number) {
form.value.krs.splice(idx, 1)
}
// ── 自动平分权重(N个KR → 均分,余数给最后一个) ──
function autoDivideWeights() {
const n = form.value.krs.length
if (n === 0) return
const base = Math.floor(100 / n)
const remainder = 100 - base * n
form.value.krs.forEach((kr, i) => {
kr.weight = i === n - 1 ? base + remainder : base
})
ElMessage.success(`已自动平分: ${form.value.krs.map(k => k.weight).join('/')}`)
}
// ── 关联KPI变化 → 自动继承方向(从KPI阈值解析) ──
function onKpiChange(ki: number) {
const kr = form.value.krs[ki]
if (!kr) return
const kpi = kpiOptions.value.find((k: any) => k.kpi_code === kr.kpi_code)
if (!kpi) return
const threshold = kpi.threshold_green || kpi.threshold_yellow || ""
if (!threshold) return
const parsed = parseKrTarget(threshold)
// 仅当用户未手动设置方向/目标值时继承
if (!kr.operator || kr.operator === ">=") kr.operator = parsed.operator
if (kr.target_value == null) kr.target_value = parsed.value
ElMessage.success(`已继承KPI方向: ${kr.operator} ${kr.target_value}`)
}
function formatTargetValue(idx: number) {
// 数值输入,不需要追加%(weightSum实时校验)
const kr = form.value.krs[idx]
if (!kr) return
if (kr.target_value == null || kr.target_value === "") return
// 自动同步到KR名称:如果名称是纯文字无数字,追加方向+目标值
if (kr.name && !/\d/.test(kr.name)) {
const opSymbol = kr.operator === ">=" ? "≥" : kr.operator === "<=" ? "≤" : kr.operator
kr.name = kr.name + opSymbol + kr.target_value
}
}
function checkWeightSum() {
// 触发computed重新计算,warning自动显示
}
function onSave() {
// 验证目标名称
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 == null || kr.target_value === "") {
ElMessage.warning(`第 ${i + 1} 条KR目标值不能为空`)
return
}
}
// 验证KR数量
if (form.value.krs.length > 5) {
ElMessage.warning('每个目标最多5个关键结果')
return
}
// 构造完整数据发给父组件
emit("save-node-dialog", {
o_name: form.value.o_name.trim(),
o_description: form.value.o_description.trim(),
template_name: usedTemplate.value?.name || null,
template_id: usedTemplate.value?.id || null,
dimension: props.editingLayerKey,
krs: form.value.krs.map((kr, i) => ({
name: kr.name,
operator: kr.operator || ">=",
target_value: kr.target_value,
weight: parseFloat(String(kr.weight)) || 33,
kpi_code: kr.kpi_code || null,
sort_order: i + 1,
})),
// 兼容旧字段(父组件 onNodeSave 需要 name
name: form.value.o_name.trim(),
description: form.value.o_description.trim(),
})
// 模板使用次数 +1(仅后端来源的模板,静默失败不影响保存)
if (usedTemplate.value?.id) {
okrTemplateApi.incrementUse(usedTemplate.value.id).catch(() => {})
}
}
function onCancel() {
emit("update:showNodeDialog", false)
resetForm()
}
// 监听弹窗打开 → 重置状态
watch(
() => props.showNodeDialog,
(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) => {
const parsed = parseKrTarget(kr.target_value)
return {
name: kr.name || "",
operator: kr.operator || parsed.operator,
target_value: kr.target_value != null && typeof kr.target_value === "number"
? kr.target_value : parsed.value,
weight: Number(kr.weight != null ? kr.weight : 33) || 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: "", operator: ">=", target_value: null, weight: 33, kpi_code: "" }],
}
} else {
resetForm()
}
} else {
resetForm()
}
}
)
// ── 以下方法保持向后兼容(其他弹窗使用) ──
function getKpiLevel(item: any): string {
if (!item.actual || !item.target) return "gray"
const ratio = item.actual / item.target
if (ratio >= 0.9) return "green"
if (ratio >= 0.7) return "yellow"
return "red"
}
function badgeIcon(level: string): string {
return level === "green" ? "🟢" : level === "yellow" ? "🟡" : level === "red" ? "🔴" : "⚪"
}
function fmtKpiVal(v: any): string {
if (v == null) return "—"
return typeof v === "number" ? v.toFixed(2) : String(v)
}
function planStatusLabel(s: string): string {
const m: Record<string, string> = { pending: "待处理", in_progress: "进行中", completed: "已完成", cancelled: "已取消" }
return m[s] || s
}
</script>
<style scoped>
/* ── 步骤提示 ── */
.mc-step-hint {
font-size: 13px;
color: #909399;
margin-bottom: 14px;
padding-bottom: 8px;
border-bottom: 1px dashed #e4e7ed;
}
.mc-dim-label {
font-size: 15px;
font-weight: 600;
color: #303133;
margin-bottom: 12px;
}
/* ── 模板选择 ── */
.mc-tmpl-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 360px;
overflow-y: auto;
}
.mc-tmpl-card {
border: 1px solid #e4e7ed;
border-radius: 6px;
padding: 10px 14px;
cursor: pointer;
transition: all 0.2s;
}
.mc-tmpl-card:hover {
border-color: #409eff;
background: #ecf5ff;
}
.mc-tmpl-card.selected {
border-color: #409eff;
background: #ecf5ff;
}
.mc-tmpl-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.mc-tmpl-name {
font-weight: 600;
font-size: 14px;
color: #303133;
}
.mc-tmpl-check {
color: #409eff;
font-weight: bold;
font-size: 16px;
}
.mc-tmpl-desc {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.mc-tmpl-krs {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.mc-tmpl-kr-tag {
font-size: 11px;
background: #f0f9eb;
color: #67c23a;
padding: 2px 6px;
border-radius: 3px;
}
.mc-tmpl-empty {
text-align: center;
color: #999;
padding: 30px 0;
}
/* ── O+KR 编辑 ── */
.mc-form-item {
margin-bottom: 12px;
}
.mc-form-item label {
display: block;
font-size: 13px;
font-weight: 500;
color: #606266;
margin-bottom: 4px;
}
.mc-required {
color: #f56c6c;
}
.mc-input {
width: 100%;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 6px 10px;
font-size: 13px;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
}
.mc-input:focus {
border-color: #409eff;
}
.mc-textarea {
resize: vertical;
font-family: inherit;
}
/* ── KR 区域 ── */
.mc-kr-section {
margin-top: 8px;
}
.mc-kr-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
color: #606266;
}
.mc-kr-row {
border: 1px solid #e4e7ed;
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 8px;
}
.mc-kr-row-top {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.mc-kr-index {
font-weight: 600;
color: #409eff;
font-size: 13px;
min-width: 22px;
}
.mc-kr-name {
flex: 1;
}
.mc-kr-row-bottom {
display: flex;
gap: 12px;
}
.mc-kr-field {
flex: 1;
}
.mc-kr-field label {
display: block;
font-size: 11px;
color: #909399;
margin-bottom: 2px;
}
.mc-kr-input {
font-size: 12px;
}
/* ── 底部按钮 ── */
.mc-footer-buttons {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* ── 以下保持旧样式(其他弹窗使用) ── */
.mc-dialog-body { padding: 0; }
.mc-row { display: flex; gap: 8px; margin-bottom: 12px; }
.mc-input-half { flex: 1; }
.mc-icon-picker { display: flex; gap: 6px; }
.mc-icon-btn {
width: 32px; height: 32px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
cursor: pointer;
font-size: 16px;
}
.mc-icon-btn.active { border-color: #409eff; background: #ecf5ff; }
.mc-radio-label { margin-right: 12px; font-size: 13px; color: #606266; cursor: pointer; }
.mc-table { width: 100%; border-collapse: collapse; }
.mc-table th, .mc-table td { padding: 8px 10px; border-bottom: 1px solid #ebeef5; text-align: left; font-size: 13px; }
.mc-table th { background: #f5f7fa; font-weight: 500; color: #606266; }
/* KPI详情卡片 */
.kpi-detail-card { padding: 10px 0; border-bottom: 1px solid #f0f0f0; }
.kpi-detail-top { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.kpi-detail-badge { font-size: 14px; }
.kpi-detail-code { font-size: 12px; color: #909399; }
.kpi-detail-name { font-size: 14px; font-weight: 500; }
.kpi-detail-values { display: flex; gap: 16px; }
.kdv-item { font-size: 12px; color: #606266; }
.kdv-label { color: #909399; margin-right: 4px; }
.kdv-val { font-weight: 500; }
/* 行动方案卡片 */
.plan-popup-card { padding: 10px 0; border-bottom: 1px solid #f0f0f0; }
.plan-popup-top { display: flex; align-items: center; gap: 8px; }
.plan-popup-status { font-size: 11px; padding: 1px 6px; border-radius: 3px; }
.pps-pending { background: #fdf6ec; color: #e6a23c; }
.pps-in_progress { background: #ecf5ff; color: #409eff; }
.pps-completed { background: #f0f9eb; color: #67c23a; }
.pps-cancelled { background: #fef0f0; color: #f56c6c; }
.plan-popup-title { font-size: 13px; font-weight: 500; }
.plan-popup-meta { display: flex; gap: 12px; margin-top: 4px; font-size: 12px; color: #909399; }
</style>
.mc-kr-warning { color: #e6a23c; font-size: 12px; margin-top: 4px; }