diff --git a/backend/app/api/bot_bridge.py b/backend/app/api/bot_bridge.py index 95622085..3e17a45b 100644 --- a/backend/app/api/bot_bridge.py +++ b/backend/app/api/bot_bridge.py @@ -13,7 +13,7 @@ from app.models import ( User, StrategicMap, KPIDefinition, KPITemplate, KPIValue, DataSourceConfig, KPIAlert, OperationLog, NotificationChannel, NotificationLog, RolePermission, ActionPlan, OrgNode, - StrategicMapVersion, MapObjective, + StrategicMapVersion, MapObjective, Objective, ) from app.models.budget_plan import BudgetPlan from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation @@ -451,6 +451,21 @@ def bot_query( for c in sc ] + if q in ("okr", "all"): + objs = db.query(Objective).filter(Objective.status == "active").all() + result["okr"] = [] + for o in objs: + krs = db.query(ActionPlan).filter(ActionPlan.objective_id == o.id).all() + result["okr"].append({ + "id": o.id, "title": o.title, "quarter": o.quarter, + "dimension": o.dimension, "progress": o.progress, + "confidence": o.confidence, + "key_results": [ + {"title": kr.title, "status": kr.status, "progress": kr.progress} + for kr in krs + ] + }) + if q in ("actions", "all"): acts = db.query(ActionPlan).limit(30).all() result["actions"] = [ @@ -526,6 +541,44 @@ def bot_import_excel( # ── 自然语言查询 ── +@router.post("/okr/create") +def bot_okr_create( + title: str = Query(...), + quarter: str = Query(...), + dimension: Optional[str] = Query(None), + bot: dict = Depends(verify_bot_key), + db: Session = Depends(get_db), +): + """Bot创建OKR目标""" + from app.models import Objective + obj = Objective(title=title, quarter=quarter, dimension=dimension, owner=bot["name"]) + db.add(obj) + db.commit() + db.refresh(obj) + return {"ok": True, "id": obj.id, "title": obj.title, "confidence": obj.confidence} + + +@router.get("/okr/list") +def bot_okr_list( + quarter: Optional[str] = Query(None), + bot: dict = Depends(verify_bot_key), + db: Session = Depends(get_db), +): + """Bot列出OKR(含KR进度)""" + from app.models import Objective + q = db.query(Objective) + if quarter: + q = q.filter(Objective.quarter == quarter) + objs = q.order_by(Objective.quarter.desc()).all() + return {"total": len(objs), "items": [ + {"id": o.id, "title": o.title, "quarter": o.quarter, + "dimension": o.dimension, "progress": o.progress, + "confidence": o.confidence, "status": o.status, + "kr_count": db.query(func.count(ActionPlan.id)).filter(ActionPlan.objective_id == o.id).scalar() or 0} + for o in objs + ]} + + @router.get("/nlp") def bot_nlp( intent: str = Query("overview"), @@ -544,6 +597,7 @@ def bot_nlp( "成本": "cost", "成本分析": "cost", "战略": "maps", "战略地图": "maps", "行动": "actions", "改善": "actions", + "okr": "okr", "目标": "okr", "季度目标": "okr", } resolved = m.get(intent, intent) return bot_query(q=resolved, bot=bot, db=db) diff --git a/backend/app/api/okr.py b/backend/app/api/okr.py new file mode 100644 index 00000000..78d08adb --- /dev/null +++ b/backend/app/api/okr.py @@ -0,0 +1,106 @@ +""" +OKR目标管理 API — 季度目标 + 关键结果 + KPI联动 +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import func +from datetime import datetime +from typing import Optional +from app.database import get_db +from app.auth_middleware import require_role +from app.models import Objective, ActionPlan + +router = APIRouter(prefix="/api/cma/okr", tags=["OKR目标管理"], + dependencies=[Depends(require_role("ceo", "finance", "it"))], +) + + +@router.get("") +def list_objectives( + quarter: Optional[str] = Query(None, description="筛选季度: 2026Q3"), + dimension: Optional[str] = Query(None), + status: Optional[str] = Query(None), + db: Session = Depends(get_db), +): + """列出OKR目标""" + q = db.query(Objective) + if quarter: + q = q.filter(Objective.quarter == quarter) + if dimension: + q = q.filter(Objective.dimension == dimension) + if status: + q = q.filter(Objective.status == status) + objs = q.order_by(Objective.quarter.desc(), Objective.id).all() + results = [] + for o in objs: + krs = db.query(ActionPlan).filter(ActionPlan.objective_id == o.id).all() + kr_summary = [ + {"id": kr.id, "title": kr.title, "status": kr.status, "progress": kr.progress} + for kr in krs + ] + results.append({ + "id": o.id, "title": o.title, "description": o.description, + "dimension": o.dimension, "quarter": o.quarter, + "owner": o.owner, "status": o.status, "progress": o.progress, + "confidence": o.confidence, + "key_results": kr_summary, + "kr_count": len(krs), + "kr_completed": sum(1 for kr in krs if kr.status == "completed"), + "created_at": o.created_at.isoformat() if o.created_at else None, + }) + return {"total": len(results), "items": results} + + +@router.post("") +def create_objective( + title: str = Query(...), + quarter: str = Query(...), + description: Optional[str] = Query(None), + dimension: Optional[str] = Query(None), + owner: Optional[str] = Query(None), + db: Session = Depends(get_db), +): + """创建OKR目标""" + obj = Objective(title=title, quarter=quarter, description=description, + dimension=dimension, owner=owner) + db.add(obj) + db.commit() + db.refresh(obj) + return {"ok": True, "id": obj.id, "title": obj.title} + + +@router.get("/{obj_id}") +def get_objective(obj_id: int, db: Session = Depends(get_db)): + """获取单个OKR详情""" + obj = db.query(Objective).filter(Objective.id == obj_id).first() + if not obj: + raise HTTPException(404, "目标不存在") + krs = db.query(ActionPlan).filter(ActionPlan.objective_id == obj_id).all() + return { + "objective": { + "id": obj.id, "title": obj.title, "description": obj.description, + "dimension": obj.dimension, "quarter": obj.quarter, + "owner": obj.owner, "status": obj.status, "progress": obj.progress, + "confidence": obj.confidence, + }, + "key_results": [ + {"id": kr.id, "title": kr.title, "kpi_id": kr.kpi_id, + "status": kr.status, "progress": kr.progress, + "due_date": kr.due_date.isoformat() if kr.due_date else None, + "assignee": kr.assignee} + for kr in krs + ] + } + + +@router.patch("/{obj_id}") +def update_objective(obj_id: int, db: Session = Depends(get_db)): + """更新OKR进度(通过查询ActionPlan自动计算)""" + obj = db.query(Objective).filter(Objective.id == obj_id).first() + if not obj: + raise HTTPException(404, "目标不存在") + krs = db.query(ActionPlan).filter(ActionPlan.objective_id == obj_id).all() + if krs: + obj.progress = sum(kr.progress for kr in krs) // len(krs) + db.commit() + return {"ok": True, "id": obj_id, "progress": obj.progress} diff --git a/backend/app/api/okr_templates.py b/backend/app/api/okr_templates.py new file mode 100644 index 00000000..967545f4 --- /dev/null +++ b/backend/app/api/okr_templates.py @@ -0,0 +1,115 @@ +""" +OKR模板库 API — 按维度筛选 + 用户自定义模板 +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from typing import Optional +from app.database import get_db +from app.auth_middleware import require_role +from app.models import OKRTemplate + +router = APIRouter(prefix="/api/cma/okr-templates", tags=["OKR模板库"], + dependencies=[Depends(require_role("ceo", "finance", "it"))], +) + + +@router.get("") +def list_okr_templates( + dimension: Optional[str] = Query(None, description="按维度筛选: finance/customer/process/learning"), + source: Optional[str] = Query(None, description="按来源筛选: system/user/industry_pack"), + industry_tag: Optional[str] = Query(None, description="按行业标签筛选"), + active_only: bool = Query(True, description="仅返回启用模板"), + db: Session = Depends(get_db), +): + """列出 OKR 模板,支持按维度筛选""" + q = db.query(OKRTemplate) + if dimension: + q = q.filter(OKRTemplate.dimension == dimension) + if source: + q = q.filter(OKRTemplate.source == source) + if industry_tag: + q = q.filter(OKRTemplate.industry_tag == industry_tag) + if active_only: + q = q.filter(OKRTemplate.is_active == 1) + templates = q.order_by(OKRTemplate.sort_order, OKRTemplate.id).all() + return { + "total": len(templates), + "items": [ + { + "id": t.id, + "name": t.name, + "description": t.description, + "dimension": t.dimension, + "layer": t.layer, + "industry_tag": t.industry_tag, + "preset_krs": t.preset_krs, + "source": t.source, + "use_count": t.use_count, + "sort_order": t.sort_order, + "is_active": t.is_active, + "created_at": t.created_at.isoformat() if t.created_at else None, + } + for t in templates + ], + } + + +@router.get("/{template_id}") +def get_okr_template(template_id: int, db: Session = Depends(get_db)): + """获取单个 OKR 模板详情""" + t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first() + if not t: + raise HTTPException(404, "模板不存在") + return { + "id": t.id, + "name": t.name, + "description": t.description, + "dimension": t.dimension, + "layer": t.layer, + "industry_tag": t.industry_tag, + "preset_krs": t.preset_krs, + "source": t.source, + "use_count": t.use_count, + "sort_order": t.sort_order, + "is_active": t.is_active, + } + + +@router.post("") +def create_okr_template(data: dict, db: Session = Depends(get_db)): + """用户自定义 OKR 模板""" + name = data.get("name", "").strip() + if not name: + raise HTTPException(400, "模板名称不能为空") + dimension = data.get("dimension", "") + if dimension not in ("finance", "customer", "process", "learning"): + raise HTTPException(400, "维度无效,必须是 finance/customer/process/learning") + preset_krs = data.get("preset_krs", []) + if not isinstance(preset_krs, list) or len(preset_krs) == 0: + raise HTTPException(400, "至少需要一个预设KR") + + t = OKRTemplate( + name=name, + description=data.get("description", ""), + dimension=dimension, + layer=data.get("layer", "level3"), + industry_tag=data.get("industry_tag", "general"), + preset_krs=preset_krs, + source="user", + sort_order=data.get("sort_order", 0), + ) + db.add(t) + db.commit() + db.refresh(t) + return {"ok": True, "id": t.id, "name": t.name} + + +@router.post("/{template_id}/use") +def increment_use_count(template_id: int, db: Session = Depends(get_db)): + """增加模板使用次数""" + t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first() + if not t: + raise HTTPException(404, "模板不存在") + t.use_count = (t.use_count or 0) + 1 + db.commit() + return {"ok": True, "use_count": t.use_count} diff --git a/backend/app/main.py b/backend/app/main.py index cce2bd9a..5640d682 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from dotenv import load_dotenv from app.database import init_db -from app.api import auth, kpis, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers +from app.api import auth, kpis, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates from app.utils.cache import clear_all as clear_cache, delete as delete_cache from scripts.erp_sync import run_sync as run_erp_sync from app.auth_middleware import require_auth @@ -64,6 +64,8 @@ app.include_router(data_quality.router) app.include_router(bi_reports.router) app.include_router(entities.router) app.include_router(bsc_layers.router) +app.include_router(okr.router) +app.include_router(okr_templates.router) @app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception): diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 5bdbaf61..0ecdb8a4 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -166,12 +166,30 @@ class RolePermission(Base): updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) +class Objective(Base): + """OKR目标""" + __tablename__ = "objectives" + id = Column(Integer, primary_key=True, index=True) + title = Column(String(200), nullable=False, comment="目标标题") + description = Column(Text, nullable=True, comment="目标描述") + dimension = Column(String(50), nullable=True, comment="关联维度: finance/customer/process/learning") + strategic_map_id = Column(Integer, ForeignKey("strategic_maps.id"), nullable=True, comment="关联战略地图") + quarter = Column(String(20), nullable=False, comment="季度: 2026Q3") + owner = Column(String(100), nullable=True, comment="负责人") + status = Column(String(20), default="active", comment="active/completed/cancelled") + progress = Column(Integer, default=0, comment="整体进度 0-100") + confidence = Column(Integer, default=5, comment="信心指数 1-10") + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + class ActionPlan(Base): """改善行动计划""" __tablename__ = "action_plans" id = Column(Integer, primary_key=True, index=True) alert_id = Column(Integer, ForeignKey("kpi_alerts.id"), nullable=True, comment="关联预警") kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI") + objective_id = Column(Integer, ForeignKey("objectives.id"), nullable=True, comment="关联OKR目标") title = Column(String(200), nullable=False, comment="计划标题") description = Column(Text, nullable=True, comment="详细描述") assignee = Column(String(100), nullable=True, comment="负责人") @@ -326,3 +344,21 @@ class KPITemplate(Base): created_by = Column(Integer) created_at = Column(DateTime, server_default=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + +class OKRTemplate(Base): + """OKR模板库""" + __tablename__ = "okr_templates" + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False, comment="O名称") + description = Column(Text, comment="O描述") + dimension = Column(String(20), nullable=False, comment="finance/customer/process/learning") + layer = Column(String(20), default="level1", comment="level1/level2/level3") + industry_tag = Column(String(50), default="general", comment="行业标签") + preset_krs = Column(JSON, nullable=False, comment="预设关键结果列表") + source = Column(String(20), default="system", comment="system/user/industry_pack") + use_count = Column(Integer, default=0, comment="使用次数") + sort_order = Column(Integer, default=0) + is_active = Column(Integer, default=1) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/backend/scripts/seed_okr_templates.py b/backend/scripts/seed_okr_templates.py new file mode 100644 index 00000000..cc17663a --- /dev/null +++ b/backend/scripts/seed_okr_templates.py @@ -0,0 +1,252 @@ +""" +OKR模板库 — 建表 + 插入14个CMA标准模板种子数据 +""" +import sys +import os +import logging + +# 添加项目根目录到Python路径 +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from app.database import get_engine, get_session_local, Base +from app.models import OKRTemplate + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("seed_okr_templates") + +# ── 14个CMA标准模板 ── +SEED_TEMPLATES = [ + # 💰 财务层(4个) + { + "name": "扩大收入规模", + "description": "通过新市场开拓、新产品线、新客户群实现收入增长", + "dimension": "finance", + "layer": "level1", + "industry_tag": "general", + "source": "system", + "sort_order": 1, + "preset_krs": [ + {"name": "营收增长率≥20%", "target_value": "≥20%", "weight": 40, "sort_order": 1}, + {"name": "新客户收入占比≥15%", "target_value": "≥15%", "weight": 30, "sort_order": 2}, + {"name": "客单价提升≥10%", "target_value": "≥10%", "weight": 30, "sort_order": 3}, + ], + }, + { + "name": "优化成本结构", + "description": "通过成本降低和费用管控提升盈利能力", + "dimension": "finance", + "layer": "level1", + "industry_tag": "general", + "source": "system", + "sort_order": 2, + "preset_krs": [ + {"name": "毛利率提升≥3%", "target_value": "≥3%", "weight": 40, "sort_order": 1}, + {"name": "管理费用率≤15%", "target_value": "≤15%", "weight": 30, "sort_order": 2}, + {"name": "销售费用率≤10%", "target_value": "≤10%", "weight": 30, "sort_order": 3}, + ], + }, + { + "name": "提升资产效率", + "description": "提高资产周转速度,释放沉淀资金", + "dimension": "finance", + "layer": "level1", + "industry_tag": "general", + "source": "system", + "sort_order": 3, + "preset_krs": [ + {"name": "库存周转天数≤45天", "target_value": "≤45天", "weight": 34, "sort_order": 1}, + {"name": "应收账款周转天数≤45天", "target_value": "≤45天", "weight": 33, "sort_order": 2}, + {"name": "总资产周转率≥1.0", "target_value": "≥1.0", "weight": 33, "sort_order": 3}, + ], + }, + { + "name": "保障现金流安全", + "description": "确保经营现金流为正,防范流动性风险", + "dimension": "finance", + "layer": "level1", + "industry_tag": "general", + "source": "system", + "sort_order": 4, + "preset_krs": [ + {"name": "现金比率≥20%", "target_value": "≥20%", "weight": 34, "sort_order": 1}, + {"name": "经营现金流为正", "target_value": "为正", "weight": 33, "sort_order": 2}, + {"name": "自由现金流≥0", "target_value": "≥0", "weight": 33, "sort_order": 3}, + ], + }, + # 👥 客户层(3个) + { + "name": "产品领先", + "description": "提供行业最优的产品质量和功能", + "dimension": "customer", + "layer": "level1", + "industry_tag": "general", + "source": "system", + "sort_order": 5, + "preset_krs": [ + {"name": "产品合格率≥99%", "target_value": "≥99%", "weight": 34, "sort_order": 1}, + {"name": "NPS净推荐值≥60", "target_value": "≥60", "weight": 33, "sort_order": 2}, + {"name": "市场份额提升≥2%", "target_value": "≥2%", "weight": 33, "sort_order": 3}, + ], + }, + { + "name": "客户亲密", + "description": "建立深度客户关系,提升客户粘性", + "dimension": "customer", + "layer": "level1", + "industry_tag": "general", + "source": "system", + "sort_order": 6, + "preset_krs": [ + {"name": "客户满意度≥90%", "target_value": "≥90%", "weight": 34, "sort_order": 1}, + {"name": "大客户留存率≥95%", "target_value": "≥95%", "weight": 33, "sort_order": 2}, + {"name": "服务响应时间≤2小时", "target_value": "≤2小时", "weight": 33, "sort_order": 3}, + ], + }, + { + "name": "卓越运营", + "description": "以最优的价格和最高的便利性服务客户", + "dimension": "customer", + "layer": "level1", + "industry_tag": "general", + "source": "system", + "sort_order": 7, + "preset_krs": [ + {"name": "价格竞争力行业TOP3", "target_value": "TOP3", "weight": 34, "sort_order": 1}, + {"name": "按时交付率≥98%", "target_value": "≥98%", "weight": 33, "sort_order": 2}, + {"name": "客户投诉率≤1%", "target_value": "≤1%", "weight": 33, "sort_order": 3}, + ], + }, + # ⚙️ 流程层(4个) + { + "name": "运营高效顺畅", + "description": "优化供应链和交付流程,提升运营效率", + "dimension": "process", + "layer": "level1", + "industry_tag": "general", + "source": "system", + "sort_order": 8, + "preset_krs": [ + {"name": "交付及时率≥95%", "target_value": "≥95%", "weight": 34, "sort_order": 1}, + {"name": "库存周转≤45天", "target_value": "≤45天", "weight": 33, "sort_order": 2}, + {"name": "流程自动化率≥30%", "target_value": "≥30%", "weight": 33, "sort_order": 3}, + ], + }, + { + "name": "客户管理有序", + "description": "建立标准化客户管理流程", + "dimension": "process", + "layer": "level1", + "industry_tag": "general", + "source": "system", + "sort_order": 9, + "preset_krs": [ + {"name": "新客获取成本下降≥10%", "target_value": "≥10%", "weight": 34, "sort_order": 1}, + {"name": "客户流失率≤5%", "target_value": "≤5%", "weight": 33, "sort_order": 2}, + {"name": "客户线索转化率≥20%", "target_value": "≥20%", "weight": 33, "sort_order": 3}, + ], + }, + { + "name": "创新驱动增长", + "description": "通过产品和服务创新驱动业务增长", + "dimension": "process", + "layer": "level1", + "industry_tag": "general", + "source": "system", + "sort_order": 10, + "preset_krs": [ + {"name": "新产品收入占比≥15%", "target_value": "≥15%", "weight": 34, "sort_order": 1}, + {"name": "研发投入占比≥5%", "target_value": "≥5%", "weight": 33, "sort_order": 2}, + {"name": "年度创新提案≥12项", "target_value": "≥12项", "weight": 33, "sort_order": 3}, + ], + }, + { + "name": "合规稳健运营", + "description": "确保合规运营,防范法律和税务风险", + "dimension": "process", + "layer": "level1", + "industry_tag": "general", + "source": "system", + "sort_order": 11, + "preset_krs": [ + {"name": "合规事件0起", "target_value": "0起", "weight": 34, "sort_order": 1}, + {"name": "审计通过率100%", "target_value": "100%", "weight": 33, "sort_order": 2}, + {"name": "税务申报及时率100%", "target_value": "100%", "weight": 33, "sort_order": 3}, + ], + }, + # 📚 学习层(3个) + { + "name": "团队能力升级", + "description": "提升员工核心技能和关键岗位胜任度", + "dimension": "learning", + "layer": "level1", + "industry_tag": "general", + "source": "system", + "sort_order": 12, + "preset_krs": [ + {"name": "关键岗位培训完成率100%", "target_value": "100%", "weight": 34, "sort_order": 1}, + {"name": "认证持有率≥80%", "target_value": "≥80%", "weight": 33, "sort_order": 2}, + {"name": "人均培训时长≥40小时/年", "target_value": "≥40小时/年", "weight": 33, "sort_order": 3}, + ], + }, + { + "name": "数字系统赋能", + "description": "通过数字化系统提升工作效率", + "dimension": "learning", + "layer": "level1", + "industry_tag": "general", + "source": "system", + "sort_order": 13, + "preset_krs": [ + {"name": "系统覆盖率≥90%", "target_value": "≥90%", "weight": 34, "sort_order": 1}, + {"name": "数据自动化率≥70%", "target_value": "≥70%", "weight": 33, "sort_order": 2}, + {"name": "报表自动生成率≥80%", "target_value": "≥80%", "weight": 33, "sort_order": 3}, + ], + }, + { + "name": "组织协同对齐", + "description": "建立高绩效文化和战略共识", + "dimension": "learning", + "layer": "level1", + "industry_tag": "general", + "source": "system", + "sort_order": 14, + "preset_krs": [ + {"name": "员工满意度≥85%", "target_value": "≥85%", "weight": 34, "sort_order": 1}, + {"name": "战略认知度≥80%", "target_value": "≥80%", "weight": 33, "sort_order": 2}, + {"name": "跨部门协作评分≥4分", "target_value": "≥4分", "weight": 33, "sort_order": 3}, + ], + }, +] + + +def ensure_table(): + """确保 okr_templates 表存在""" + Base.metadata.create_all(bind=get_engine()) + logger.info("表 okr_templates 已就绪") + + +def seed_templates(): + """插入种子数据(如果表为空)""" + session = get_session_local()() + try: + existing = session.query(OKRTemplate).filter(OKRTemplate.source == "system").count() + if existing > 0: + logger.info(f"已有 {existing} 条系统模板,跳过种子数据") + return + for item in SEED_TEMPLATES: + t = OKRTemplate(**item) + session.add(t) + session.commit() + logger.info(f"✔ 已插入 {len(SEED_TEMPLATES)} 条 CMA 标准模板") + except Exception as e: + session.rollback() + logger.error(f"种子数据插入失败: {e}") + raise + finally: + session.close() + + +if __name__ == "__main__": + ensure_table() + seed_templates() + logger.info("OKR模板库初始化完成") diff --git a/frontend/src/components/map-canvas/MapCanvasDialogs.vue b/frontend/src/components/map-canvas/MapCanvasDialogs.vue index 9c616243..b34bd8ce 100644 --- a/frontend/src/components/map-canvas/MapCanvasDialogs.vue +++ b/frontend/src/components/map-canvas/MapCanvasDialogs.vue @@ -1,47 +1,114 @@ - +
@@ -81,7 +150,9 @@ - +
@@ -121,7 +192,9 @@ - +
@@ -145,3 +218,557 @@ + + + + diff --git a/frontend/src/views/MapCanvas.vue b/frontend/src/views/MapCanvas.vue index 073c9601..96872a31 100644 --- a/frontend/src/views/MapCanvas.vue +++ b/frontend/src/views/MapCanvas.vue @@ -120,6 +120,7 @@ :kpi-detail-list="kpiDetailList" :plan-popup-obj-name="planPopupObjName" :plan-popup-list="planPopupList" + :editing-layer-key="editingLayerKey" @update:show-node-dialog="v => showNodeDialog = v" @update:show-causality-dialog="v => showCausalityDialog = v" @update:show-versions="v => showVersions = v" @@ -195,7 +196,20 @@ const editingNodeData = ref(null) const editingLayerKey = ref('') const dialogRefreshKey = ref(0) const dialogForm = reactive({ name: '', description: '', icon: 'target', targetValue: null, currentValue: null, unit: '%', owner: '', isLeading: false, kpis: [] as string[] }) -function onSaveDialog() { +function onSaveDialog(data: any) { + // 新格式:O+KR 结构(来自两步弹窗) + if (data.o_name) { + if (!data.o_name?.trim()) { ElMessage.warning('请输入目标名称'); return } + onNodeSave({ + ...data, + name: data.o_name.trim(), + description: data.o_description?.trim() || '', + layer: editingLayerKey.value, + icon: 'target', + }) + return + } + // 旧格式兼容(保留) if (!dialogForm.name?.trim()) { ElMessage.warning('请输入目标名称'); return } onNodeSave({ ...dialogForm, name: dialogForm.name.trim(), layer: editingLayerKey.value }) showNodeDialog.value = false @@ -544,6 +558,9 @@ function onNodeSave(data: any) { layer: layer, kpis: data.kpis || [], icon: data.icon || 'target', + // O+KR 扩展字段 + krs: data.krs || [], // 关键结果数组 [{name, target_value, weight, sort_order}] + template_name: data.template_name || null, // 来源模板名 } if (editingNodeData.value?._index != null && editingNodeData.value._dimKey === layer) { @@ -569,6 +586,7 @@ function onNodeSave(data: any) { } editingNodeData.value = null + showNodeDialog.value = false debouncedSaveCanvas() nextTick(triggerRecalc) }