- 后端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表
1004 lines
35 KiB
Vue
1004 lines
35 KiB
Vue
<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=">">> 大于</option>
|
||
<option value="<">< 小于</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; }
|