feat: OKR模板库改造 — 弹窗模板库联动+时间分解落库+制造业包6个
This commit is contained in:
@@ -155,9 +155,39 @@ def create_plan(
|
||||
db.add(plan)
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
|
||||
# OKR时间分解:KR创建时自动生成3个月度里程碑(按截止日期向前均分)
|
||||
if due_date and not plan.monthly_milestones:
|
||||
try:
|
||||
plan.monthly_milestones = _auto_build_milestones(due_date)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback() # 里程碑生成失败不影响KR创建
|
||||
db.refresh(plan)
|
||||
return plan_to_dict(plan)
|
||||
|
||||
|
||||
def _auto_build_milestones(due_date: datetime) -> list:
|
||||
"""按截止日期向前均分3个月度里程碑"""
|
||||
base = due_date.replace(day=1)
|
||||
month_keys = []
|
||||
for i in range(3, 0, -1):
|
||||
y, m = base.year, base.month - i
|
||||
while m <= 0:
|
||||
m += 12
|
||||
y -= 1
|
||||
month_keys.append(f"{y:04d}-{m:02d}")
|
||||
return [
|
||||
{
|
||||
"month": mk,
|
||||
"label": f"里程碑{i + 1}",
|
||||
"status": "pending",
|
||||
"target": None,
|
||||
}
|
||||
for i, mk in enumerate(month_keys)
|
||||
]
|
||||
|
||||
|
||||
@router.put("/{plan_id}")
|
||||
def update_plan(
|
||||
plan_id: int,
|
||||
|
||||
@@ -97,6 +97,86 @@ def get_objective(obj_id: int, db: Session = Depends(get_db)):
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{okr_id}/decomposition/milestones/{kr_id}")
|
||||
def update_kr_milestones(okr_id: int, kr_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""保存KR的月度里程碑(时间分解视图编辑) — 同步更新KR进度"""
|
||||
obj = db.query(Objective).filter(Objective.id == okr_id).first()
|
||||
if not obj:
|
||||
raise HTTPException(404, "OKR不存在")
|
||||
kr = db.query(ActionPlan).filter(
|
||||
ActionPlan.id == kr_id, ActionPlan.objective_id == okr_id
|
||||
).first()
|
||||
if not kr:
|
||||
raise HTTPException(404, "KR不存在或不属于该OKR")
|
||||
milestones = data.get("milestones")
|
||||
if not isinstance(milestones, list):
|
||||
raise HTTPException(400, "milestones 必须是数组")
|
||||
# 规范化:只保留 month/label/status/target 字段
|
||||
normalized = []
|
||||
for ms in milestones:
|
||||
normalized.append({
|
||||
"month": ms.get("month", ""),
|
||||
"label": ms.get("label", ""),
|
||||
"status": ms.get("status", "pending"),
|
||||
"target": ms.get("target"),
|
||||
})
|
||||
kr.monthly_milestones = normalized
|
||||
# 里程碑完成 → KR进度同步更新(完成数/总数)
|
||||
if normalized:
|
||||
done = sum(1 for ms in normalized if ms.get("status") == "completed")
|
||||
kr.progress = round(done / len(normalized) * 100)
|
||||
if kr.progress >= 100:
|
||||
kr.status = "completed"
|
||||
elif kr.status == "completed" and kr.progress < 100:
|
||||
kr.status = "in_progress"
|
||||
db.commit()
|
||||
return {"ok": True, "kr_id": kr.id, "progress": kr.progress, "milestones": kr.monthly_milestones}
|
||||
|
||||
|
||||
@router.post("/{okr_id}/decomposition/milestones/generate")
|
||||
def auto_generate_milestones(okr_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""为KR自动生成月度里程碑(按季度剩余月份均分)"""
|
||||
obj = db.query(Objective).filter(Objective.id == okr_id).first()
|
||||
if not obj:
|
||||
raise HTTPException(404, "OKR不存在")
|
||||
kr_id = data.get("kr_id")
|
||||
if not kr_id:
|
||||
raise HTTPException(400, "缺少 kr_id")
|
||||
kr = db.query(ActionPlan).filter(
|
||||
ActionPlan.id == kr_id, ActionPlan.objective_id == okr_id
|
||||
).first()
|
||||
if not kr:
|
||||
raise HTTPException(404, "KR不存在或不属于该OKR")
|
||||
# 重新生成:覆盖已有里程碑(幂等)
|
||||
milestones = _build_auto_milestones(kr)
|
||||
kr.monthly_milestones = milestones
|
||||
db.commit()
|
||||
return {"ok": True, "kr_id": kr.id, "milestones": kr.monthly_milestones}
|
||||
|
||||
|
||||
def _build_auto_milestones(kr: ActionPlan) -> list:
|
||||
"""按截止日期向前均分3个月度里程碑"""
|
||||
base = kr.due_date or datetime.now() + timedelta(days=90)
|
||||
base = base.replace(day=1)
|
||||
# 计算前3个月份(从最早的月份开始)
|
||||
month_keys = []
|
||||
for i in range(3, 0, -1):
|
||||
y, m = base.year, base.month - i
|
||||
while m <= 0:
|
||||
m += 12
|
||||
y -= 1
|
||||
month_keys.append(f"{y:04d}-{m:02d}")
|
||||
return [
|
||||
{
|
||||
"month": mk,
|
||||
"label": f"里程碑{i + 1}",
|
||||
"status": "pending",
|
||||
"target": None,
|
||||
}
|
||||
for i, mk in enumerate(month_keys)
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/{obj_id}")
|
||||
def update_objective(obj_id: int, db: Session = Depends(get_db)):
|
||||
"""更新OKR进度(通过查询ActionPlan自动计算)"""
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""OKR模板库 — 制造业行业包(6个模板,source='industry_pack', industry_tag='manufacturing')"""
|
||||
import sys, os, logging
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from app.database import get_session_local
|
||||
from app.models import OKRTemplate
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("seed_manufacturing_pack")
|
||||
|
||||
MANUFACTURING_TEMPLATES = [
|
||||
{
|
||||
"name": "提升产能利用率",
|
||||
"description": "优化排产与设备管理,提升产能利用率",
|
||||
"dimension": "process",
|
||||
"layer": "level2",
|
||||
"industry_tag": "manufacturing",
|
||||
"source": "industry_pack",
|
||||
"sort_order": 301,
|
||||
"preset_krs": [
|
||||
{"name": "产能利用率≥85%", "target_value": "≥85%", "weight": 34, "sort_order": 1},
|
||||
{"name": "设备停机时间下降≥20%", "target_value": "≥20%", "weight": 33, "sort_order": 2},
|
||||
{"name": "OEE综合效率≥75%", "target_value": "≥75%", "weight": 33, "sort_order": 3},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "降低生产成本",
|
||||
"description": "通过精益生产与供应链优化降低制造成本",
|
||||
"dimension": "finance",
|
||||
"layer": "level2",
|
||||
"industry_tag": "manufacturing",
|
||||
"source": "industry_pack",
|
||||
"sort_order": 302,
|
||||
"preset_krs": [
|
||||
{"name": "单位制造成本下降≥10%", "target_value": "≥10%", "weight": 34, "sort_order": 1},
|
||||
{"name": "原材料损耗率≤2%", "target_value": "≤2%", "weight": 33, "sort_order": 2},
|
||||
{"name": "采购成本下降≥5%", "target_value": "≥5%", "weight": 33, "sort_order": 3},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "提升产品一次合格率",
|
||||
"description": "强化质量管控,降低不良品率",
|
||||
"dimension": "process",
|
||||
"layer": "level2",
|
||||
"industry_tag": "manufacturing",
|
||||
"source": "industry_pack",
|
||||
"sort_order": 303,
|
||||
"preset_krs": [
|
||||
{"name": "一次合格率≥98%", "target_value": "≥98%", "weight": 34, "sort_order": 1},
|
||||
{"name": "客户投诉率下降≥30%", "target_value": "≥30%", "weight": 33, "sort_order": 2},
|
||||
{"name": "质量返工成本下降≥20%", "target_value": "≥20%", "weight": 33, "sort_order": 3},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "缩短交付周期",
|
||||
"description": "优化生产计划与供应链协同,缩短订单交付周期",
|
||||
"dimension": "customer",
|
||||
"layer": "level2",
|
||||
"industry_tag": "manufacturing",
|
||||
"source": "industry_pack",
|
||||
"sort_order": 304,
|
||||
"preset_krs": [
|
||||
{"name": "订单交付周期缩短≥15%", "target_value": "≥15%", "weight": 34, "sort_order": 1},
|
||||
{"name": "准时交付率≥95%", "target_value": "≥95%", "weight": 33, "sort_order": 2},
|
||||
{"name": "在制品库存下降≥20%", "target_value": "≥20%", "weight": 33, "sort_order": 3},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "设备智能化升级",
|
||||
"description": "推进设备数字化改造与智能制造",
|
||||
"dimension": "learning",
|
||||
"layer": "level2",
|
||||
"industry_tag": "manufacturing",
|
||||
"source": "industry_pack",
|
||||
"sort_order": 305,
|
||||
"preset_krs": [
|
||||
{"name": "核心设备联网率≥70%", "target_value": "≥70%", "weight": 34, "sort_order": 1},
|
||||
{"name": "自动化覆盖率≥50%", "target_value": "≥50%", "weight": 33, "sort_order": 2},
|
||||
{"name": "数据采集点覆盖关键工序100%", "target_value": "100%", "weight": 33, "sort_order": 3},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "安全生产零事故",
|
||||
"description": "完善安全管理体系,实现安全生产零事故",
|
||||
"dimension": "learning",
|
||||
"layer": "level2",
|
||||
"industry_tag": "manufacturing",
|
||||
"source": "industry_pack",
|
||||
"sort_order": 306,
|
||||
"preset_krs": [
|
||||
{"name": "重大安全事故0起", "target_value": "0起", "weight": 40, "sort_order": 1},
|
||||
{"name": "安全隐患整改率100%", "target_value": "100%", "weight": 30, "sort_order": 2},
|
||||
{"name": "安全培训覆盖率100%", "target_value": "100%", "weight": 30, "sort_order": 3},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def seed_manufacturing_pack():
|
||||
"""插入制造业行业包(幂等:按industry_tag去重)"""
|
||||
db = get_session_local()()
|
||||
try:
|
||||
existing = db.query(OKRTemplate).filter(
|
||||
OKRTemplate.industry_tag == "manufacturing",
|
||||
OKRTemplate.source == "industry_pack",
|
||||
).count()
|
||||
if existing > 0:
|
||||
logger.info(f"已有 {existing} 条制造业行业包模板,跳过")
|
||||
return
|
||||
for item in MANUFACTURING_TEMPLATES:
|
||||
db.add(OKRTemplate(**item))
|
||||
db.commit()
|
||||
logger.info(f"✔ 已插入 {len(MANUFACTURING_TEMPLATES)} 条制造业行业包模板")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"制造业行业包插入失败: {e}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed_manufacturing_pack()
|
||||
logger.info("OKR制造业行业包初始化完成")
|
||||
@@ -85,6 +85,10 @@ export const okrApi = {
|
||||
create: (data: any) => api.post('/okr', data),
|
||||
update: (id: number) => api.patch(`/okr/${id}`),
|
||||
decomposition: (id: number) => api.get(`/okr/${id}/decomposition`),
|
||||
saveMilestones: (okrId: number, krId: number, milestones: any[]) =>
|
||||
api.put(`/okr/${okrId}/decomposition/milestones/${krId}`, { milestones }),
|
||||
generateMilestones: (okrId: number, krId: number) =>
|
||||
api.post(`/okr/${okrId}/decomposition/milestones/generate`, { kr_id: krId }),
|
||||
}
|
||||
|
||||
export const dataApi = {
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
<div class="mc-dim-label">{{ dimensionName }}</div>
|
||||
|
||||
<!-- 模板列表 -->
|
||||
<div class="mc-tmpl-list">
|
||||
<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"
|
||||
@@ -20,7 +21,13 @@
|
||||
>
|
||||
<div class="mc-tmpl-header">
|
||||
<span class="mc-tmpl-name">{{ tmpl.name }}</span>
|
||||
<span v-if="selectedTemplateIdx === idx" class="mc-tmpl-check">✓</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">
|
||||
@@ -104,6 +111,7 @@
|
||||
<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>
|
||||
@@ -230,8 +238,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue"
|
||||
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<{
|
||||
@@ -433,6 +443,9 @@ const DIMENSION_LABELS: Record<string, string> = {
|
||||
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<{
|
||||
@@ -453,13 +466,48 @@ const dimensionName = computed(() => {
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
const key = props.editingLayerKey || "finance"
|
||||
return (CMA_TEMPLATES as any)[key] || []
|
||||
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 || []
|
||||
})
|
||||
@@ -518,6 +566,44 @@ const weightSum = computed(() => {
|
||||
return form.value.krs.reduce((s, kr) => s + (parseInt(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个关键结果')
|
||||
@@ -576,6 +662,7 @@ function onSave() {
|
||||
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,
|
||||
@@ -588,6 +675,11 @@ function onSave() {
|
||||
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() {
|
||||
|
||||
@@ -48,6 +48,14 @@
|
||||
</div>
|
||||
<div v-if="kr.milestones.length === 0" class="no-milestones">
|
||||
暂无里程碑
|
||||
<el-button size="small" text type="primary" :loading="generatingKrId === kr.kr_id" @click="autoGenerate(kr)">
|
||||
一键生成
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-else class="milestone-actions">
|
||||
<el-button size="small" text type="primary" :loading="generatingKrId === kr.kr_id" @click="autoGenerate(kr)">
|
||||
重新生成
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,6 +127,7 @@ const editingKr = ref<any>(null)
|
||||
const editingMsIdx = ref(-1)
|
||||
const editingMs = ref<any>(null)
|
||||
const saving = ref(false)
|
||||
const generatingKrId = ref<number | null>(null)
|
||||
|
||||
function formatMonth(m: string) {
|
||||
if (!m) return ''
|
||||
@@ -163,21 +172,44 @@ async function saveMilestone() {
|
||||
if (!editingKr.value || !editingMs.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
// Update in local data first
|
||||
const kr = data.value.krs.find((k: any) => k.kr_id === editingKr.value.kr_id)
|
||||
if (kr && kr.milestones[editingMsIdx.value]) {
|
||||
kr.milestones[editingMsIdx.value] = { ...editingMs.value }
|
||||
}
|
||||
ElMessage.success('里程碑已更新')
|
||||
// 持久化到后端(里程碑完成时KR进度同步更新)
|
||||
if (kr) {
|
||||
const res: any = await okrApi.saveMilestones(props.okrId, kr.kr_id, kr.milestones)
|
||||
if (res && res.progress != null) {
|
||||
kr.progress = res.progress
|
||||
}
|
||||
}
|
||||
ElMessage.success('里程碑已保存')
|
||||
showEditDialog.value = false
|
||||
emit('refresh')
|
||||
} catch (e: any) {
|
||||
ElMessage.error('保存失败: ' + (e?.message || ''))
|
||||
ElMessage.error('保存失败: ' + (e?.response?.data?.detail || e?.message || ''))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function autoGenerate(kr: any) {
|
||||
if (!kr) return
|
||||
generatingKrId.value = kr.kr_id
|
||||
try {
|
||||
const res: any = await okrApi.generateMilestones(props.okrId, kr.kr_id)
|
||||
if (res?.milestones) {
|
||||
kr.milestones = res.milestones
|
||||
}
|
||||
ElMessage.success('已自动生成月度里程碑')
|
||||
emit('refresh')
|
||||
} catch (e: any) {
|
||||
ElMessage.error('生成失败: ' + (e?.response?.data?.detail || e?.message || ''))
|
||||
} finally {
|
||||
generatingKrId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!props.okrId) return
|
||||
loading.value = true
|
||||
@@ -319,6 +351,14 @@ onMounted(() => {
|
||||
font-size: 12px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.milestone-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 90px;
|
||||
padding: 4px 0;
|
||||
border-left: 1px dashed #EBEEF5;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch, onMounted, nextTick, onUnmounted } from "vue"
|
||||
import { ElMessage, ElMessageBox } from "element-plus"
|
||||
import { mapApi, kpiApi } from "../api/index"
|
||||
import { mapApi, kpiApi, okrTemplateApi } from "../api/index"
|
||||
import KnowledgePanel from '../components/KnowledgePanel.vue'
|
||||
import api from "../api/index"
|
||||
import { LAYER_CONFIG, LAYER_KEYS, getLayerList } from "../config/layers"
|
||||
@@ -574,6 +574,11 @@ function onNodeSave(data: any) {
|
||||
template_name: data.template_name || null, // 来源模板名
|
||||
}
|
||||
|
||||
// 用户自定义O自动入库(第3层:用户贡献模板)
|
||||
if (data.template_id == null && Array.isArray(data.krs) && data.krs.length >= 2) {
|
||||
saveUserTemplate(data, layer)
|
||||
}
|
||||
|
||||
if (editingNodeData.value?._index != null && editingNodeData.value._dimKey === layer) {
|
||||
// 编辑已有节点
|
||||
const idx = editingNodeData.value._index
|
||||
@@ -606,6 +611,36 @@ function onNodeSave(data: any) {
|
||||
nextTick(triggerRecalc)
|
||||
}
|
||||
|
||||
// ── 用户自定义O自动入库(OKR模板库第3层) ──
|
||||
let userTemplateCooldown: Record<string, number> = {}
|
||||
async function saveUserTemplate(data: any, layer: string) {
|
||||
const name = (data.name || '').trim()
|
||||
if (!name) return
|
||||
// 防抖:同一名称30秒内不重复入库
|
||||
const now = Date.now()
|
||||
if (userTemplateCooldown[name] && now - userTemplateCooldown[name] < 30000) return
|
||||
userTemplateCooldown[name] = now
|
||||
try {
|
||||
await okrTemplateApi.create({
|
||||
name,
|
||||
description: (data.description || '').trim(),
|
||||
dimension: layer,
|
||||
preset_krs: (data.krs || []).map((kr: any) => ({
|
||||
name: kr.name,
|
||||
target_value: kr.target_value,
|
||||
weight: kr.weight || 33,
|
||||
})),
|
||||
})
|
||||
// 静默成功,不打扰用户
|
||||
} catch (e: any) {
|
||||
// 已存在同名模板等错误静默忽略
|
||||
const detail = e?.response?.data?.detail || ''
|
||||
if (detail && !String(detail).includes('重复')) {
|
||||
console.warn('[OKR模板库] 用户模板入库失败:', detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNode(dimKey: string, oi: number) {
|
||||
try {
|
||||
const dim = dimensions.find((d: any) => d.key === dimKey)
|
||||
|
||||
Reference in New Issue
Block a user