feat: OKR模板库改造 — 弹窗模板库联动+时间分解落库+制造业包6个
This commit is contained in:
@@ -155,9 +155,39 @@ def create_plan(
|
|||||||
db.add(plan)
|
db.add(plan)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(plan)
|
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)
|
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}")
|
@router.put("/{plan_id}")
|
||||||
def update_plan(
|
def update_plan(
|
||||||
plan_id: int,
|
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}")
|
@router.patch("/{obj_id}")
|
||||||
def update_objective(obj_id: int, db: Session = Depends(get_db)):
|
def update_objective(obj_id: int, db: Session = Depends(get_db)):
|
||||||
"""更新OKR进度(通过查询ActionPlan自动计算)"""
|
"""更新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),
|
create: (data: any) => api.post('/okr', data),
|
||||||
update: (id: number) => api.patch(`/okr/${id}`),
|
update: (id: number) => api.patch(`/okr/${id}`),
|
||||||
decomposition: (id: number) => api.get(`/okr/${id}/decomposition`),
|
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 = {
|
export const dataApi = {
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
<div class="mc-dim-label">{{ dimensionName }}</div>
|
<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
|
<div
|
||||||
v-for="(tmpl, idx) in filteredTemplates"
|
v-for="(tmpl, idx) in filteredTemplates"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
@@ -20,7 +21,13 @@
|
|||||||
>
|
>
|
||||||
<div class="mc-tmpl-header">
|
<div class="mc-tmpl-header">
|
||||||
<span class="mc-tmpl-name">{{ tmpl.name }}</span>
|
<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>
|
||||||
<div class="mc-tmpl-desc">{{ tmpl.description }}</div>
|
<div class="mc-tmpl-desc">{{ tmpl.description }}</div>
|
||||||
<div class="mc-tmpl-krs">
|
<div class="mc-tmpl-krs">
|
||||||
@@ -104,6 +111,7 @@
|
|||||||
<div v-if="dialogStep === 1" class="mc-footer-buttons">
|
<div v-if="dialogStep === 1" class="mc-footer-buttons">
|
||||||
<el-button size="small" @click="onCancel">取消</el-button>
|
<el-button size="small" @click="onCancel">取消</el-button>
|
||||||
<el-button size="small" plain @click="skipTemplate">✏️ 跳过模板,自定义</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">
|
<el-button size="small" type="primary" @click="nextStep">
|
||||||
下一步 {{ selectedTemplateIdx !== null ? '(已选模板)' : '(直接跳过)' }}
|
下一步 {{ selectedTemplateIdx !== null ? '(已选模板)' : '(直接跳过)' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -230,8 +238,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { ElMessage } from "element-plus"
|
||||||
|
import { okrTemplateApi } from "../../api/index"
|
||||||
|
|
||||||
// ── Props ──
|
// ── Props ──
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -433,6 +443,9 @@ const DIMENSION_LABELS: Record<string, string> = {
|
|||||||
const dialogStep = ref(1) // 1 = 模板选择, 2 = O+KR编辑
|
const dialogStep = ref(1) // 1 = 模板选择, 2 = O+KR编辑
|
||||||
const selectedTemplateIdx = ref<number | null>(null)
|
const selectedTemplateIdx = ref<number | null>(null)
|
||||||
const usedTemplate = ref<any | null>(null)
|
const usedTemplate = ref<any | null>(null)
|
||||||
|
const backendTemplates = ref<any[]>([]) // 后端模板库(含行业包/用户自定义)
|
||||||
|
const templateLoading = ref(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
// 内部表单(O+KR结构)
|
// 内部表单(O+KR结构)
|
||||||
const form = ref<{
|
const form = ref<{
|
||||||
@@ -453,13 +466,48 @@ const dimensionName = computed(() => {
|
|||||||
|
|
||||||
const filteredTemplates = computed(() => {
|
const filteredTemplates = computed(() => {
|
||||||
const key = props.editingLayerKey || "finance"
|
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(() => {
|
const dialogTitle = computed(() => {
|
||||||
return props.isEditing ? "编辑目标" : "添加目标"
|
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(() => {
|
const kpiOptions = computed(() => {
|
||||||
return props.allKpis || []
|
return props.allKpis || []
|
||||||
})
|
})
|
||||||
@@ -518,6 +566,44 @@ const weightSum = computed(() => {
|
|||||||
return form.value.krs.reduce((s, kr) => s + (parseInt(kr.weight) || 0), 0)
|
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() {
|
function addKr() {
|
||||||
if (form.value.krs.length >= 5) {
|
if (form.value.krs.length >= 5) {
|
||||||
ElMessage.warning('每个目标最多5个关键结果')
|
ElMessage.warning('每个目标最多5个关键结果')
|
||||||
@@ -576,6 +662,7 @@ function onSave() {
|
|||||||
o_name: form.value.o_name.trim(),
|
o_name: form.value.o_name.trim(),
|
||||||
o_description: form.value.o_description.trim(),
|
o_description: form.value.o_description.trim(),
|
||||||
template_name: usedTemplate.value?.name || null,
|
template_name: usedTemplate.value?.name || null,
|
||||||
|
template_id: usedTemplate.value?.id || null,
|
||||||
dimension: props.editingLayerKey,
|
dimension: props.editingLayerKey,
|
||||||
krs: form.value.krs.map((kr, i) => ({
|
krs: form.value.krs.map((kr, i) => ({
|
||||||
name: kr.name,
|
name: kr.name,
|
||||||
@@ -588,6 +675,11 @@ function onSave() {
|
|||||||
name: form.value.o_name.trim(),
|
name: form.value.o_name.trim(),
|
||||||
description: form.value.o_description.trim(),
|
description: form.value.o_description.trim(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 模板使用次数 +1(仅后端来源的模板,静默失败不影响保存)
|
||||||
|
if (usedTemplate.value?.id) {
|
||||||
|
okrTemplateApi.incrementUse(usedTemplate.value.id).catch(() => {})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCancel() {
|
function onCancel() {
|
||||||
|
|||||||
@@ -48,6 +48,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="kr.milestones.length === 0" class="no-milestones">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,6 +127,7 @@ const editingKr = ref<any>(null)
|
|||||||
const editingMsIdx = ref(-1)
|
const editingMsIdx = ref(-1)
|
||||||
const editingMs = ref<any>(null)
|
const editingMs = ref<any>(null)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
const generatingKrId = ref<number | null>(null)
|
||||||
|
|
||||||
function formatMonth(m: string) {
|
function formatMonth(m: string) {
|
||||||
if (!m) return ''
|
if (!m) return ''
|
||||||
@@ -163,21 +172,44 @@ async function saveMilestone() {
|
|||||||
if (!editingKr.value || !editingMs.value) return
|
if (!editingKr.value || !editingMs.value) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
// Update in local data first
|
|
||||||
const kr = data.value.krs.find((k: any) => k.kr_id === editingKr.value.kr_id)
|
const kr = data.value.krs.find((k: any) => k.kr_id === editingKr.value.kr_id)
|
||||||
if (kr && kr.milestones[editingMsIdx.value]) {
|
if (kr && kr.milestones[editingMsIdx.value]) {
|
||||||
kr.milestones[editingMsIdx.value] = { ...editingMs.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
|
showEditDialog.value = false
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error('保存失败: ' + (e?.message || ''))
|
ElMessage.error('保存失败: ' + (e?.response?.data?.detail || e?.message || ''))
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
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() {
|
async function loadData() {
|
||||||
if (!props.okrId) return
|
if (!props.okrId) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -319,6 +351,14 @@ onMounted(() => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 10px 0;
|
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 {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
|
|||||||
@@ -147,7 +147,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, watch, onMounted, nextTick, onUnmounted } from "vue"
|
import { ref, reactive, computed, watch, onMounted, nextTick, onUnmounted } from "vue"
|
||||||
import { ElMessage, ElMessageBox } from "element-plus"
|
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 KnowledgePanel from '../components/KnowledgePanel.vue'
|
||||||
import api from "../api/index"
|
import api from "../api/index"
|
||||||
import { LAYER_CONFIG, LAYER_KEYS, getLayerList } from "../config/layers"
|
import { LAYER_CONFIG, LAYER_KEYS, getLayerList } from "../config/layers"
|
||||||
@@ -574,6 +574,11 @@ function onNodeSave(data: any) {
|
|||||||
template_name: data.template_name || null, // 来源模板名
|
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) {
|
if (editingNodeData.value?._index != null && editingNodeData.value._dimKey === layer) {
|
||||||
// 编辑已有节点
|
// 编辑已有节点
|
||||||
const idx = editingNodeData.value._index
|
const idx = editingNodeData.value._index
|
||||||
@@ -606,6 +611,36 @@ function onNodeSave(data: any) {
|
|||||||
nextTick(triggerRecalc)
|
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) {
|
async function deleteNode(dimKey: string, oi: number) {
|
||||||
try {
|
try {
|
||||||
const dim = dimensions.find((d: any) => d.key === dimKey)
|
const dim = dimensions.find((d: any) => d.key === dimKey)
|
||||||
|
|||||||
Reference in New Issue
Block a user