feat: OKR模板库改造 — 弹窗模板库联动+时间分解落库+制造业包6个

This commit is contained in:
Hermes CI Fix
2026-08-11 00:11:25 +08:00
parent 32fe3e4d50
commit 0b191d278e
7 changed files with 413 additions and 8 deletions
+30
View File
@@ -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,
+80
View File
@@ -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自动计算)"""
+124
View File
@@ -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制造业行业包初始化完成")
+4
View File
@@ -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; }
+36 -1
View File
@@ -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)