Phase 1: OKR模板 — 两步弹窗(O+KR结构) + 14个CMA模板 + okr_templates表 + API
MapCanvasDialogs.vue: - 节点编辑弹窗改为两步交互(模板选择→O+KR编辑) - 第一步:按维度展示对应模板(财务4/客户3/流程4/学习3) - 第二步:编辑O名称+描述+3个KR(名称/目标值/权重) - 14个CMA标准模板硬编码在前端 - 支持添加/删除KR、自定义目标跳过模板 MapCanvas.vue: - 传递 editingLayerKey 到弹窗 - onSaveDialog 适配新 O+KR 数据结构 - onNodeSave 存储 krs 和 template_name 字段 Backend: - OKRTemplate 模型 (okr_templates 表) - okr_templates API: GET(按维度筛选) + POST(用户自定义) + increment use_count - seed_okr_templates.py 迁移脚本(建表+14条种子数据) - 在 main.py 注册新路由
This commit is contained in:
@@ -13,7 +13,7 @@ from app.models import (
|
|||||||
User, StrategicMap, KPIDefinition, KPITemplate, KPIValue,
|
User, StrategicMap, KPIDefinition, KPITemplate, KPIValue,
|
||||||
DataSourceConfig, KPIAlert, OperationLog, NotificationChannel,
|
DataSourceConfig, KPIAlert, OperationLog, NotificationChannel,
|
||||||
NotificationLog, RolePermission, ActionPlan, OrgNode,
|
NotificationLog, RolePermission, ActionPlan, OrgNode,
|
||||||
StrategicMapVersion, MapObjective,
|
StrategicMapVersion, MapObjective, Objective,
|
||||||
)
|
)
|
||||||
from app.models.budget_plan import BudgetPlan
|
from app.models.budget_plan import BudgetPlan
|
||||||
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
|
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
|
||||||
@@ -451,6 +451,21 @@ def bot_query(
|
|||||||
for c in sc
|
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"):
|
if q in ("actions", "all"):
|
||||||
acts = db.query(ActionPlan).limit(30).all()
|
acts = db.query(ActionPlan).limit(30).all()
|
||||||
result["actions"] = [
|
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")
|
@router.get("/nlp")
|
||||||
def bot_nlp(
|
def bot_nlp(
|
||||||
intent: str = Query("overview"),
|
intent: str = Query("overview"),
|
||||||
@@ -544,6 +597,7 @@ def bot_nlp(
|
|||||||
"成本": "cost", "成本分析": "cost",
|
"成本": "cost", "成本分析": "cost",
|
||||||
"战略": "maps", "战略地图": "maps",
|
"战略": "maps", "战略地图": "maps",
|
||||||
"行动": "actions", "改善": "actions",
|
"行动": "actions", "改善": "actions",
|
||||||
|
"okr": "okr", "目标": "okr", "季度目标": "okr",
|
||||||
}
|
}
|
||||||
resolved = m.get(intent, intent)
|
resolved = m.get(intent, intent)
|
||||||
return bot_query(q=resolved, bot=bot, db=db)
|
return bot_query(q=resolved, bot=bot, db=db)
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
+3
-1
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from app.database import init_db
|
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 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 scripts.erp_sync import run_sync as run_erp_sync
|
||||||
from app.auth_middleware import require_auth
|
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(bi_reports.router)
|
||||||
app.include_router(entities.router)
|
app.include_router(entities.router)
|
||||||
app.include_router(bsc_layers.router)
|
app.include_router(bsc_layers.router)
|
||||||
|
app.include_router(okr.router)
|
||||||
|
app.include_router(okr_templates.router)
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def global_exception_handler(request: Request, exc: Exception):
|
async def global_exception_handler(request: Request, exc: Exception):
|
||||||
|
|||||||
@@ -166,12 +166,30 @@ class RolePermission(Base):
|
|||||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
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):
|
class ActionPlan(Base):
|
||||||
"""改善行动计划"""
|
"""改善行动计划"""
|
||||||
__tablename__ = "action_plans"
|
__tablename__ = "action_plans"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
alert_id = Column(Integer, ForeignKey("kpi_alerts.id"), nullable=True, comment="关联预警")
|
alert_id = Column(Integer, ForeignKey("kpi_alerts.id"), nullable=True, comment="关联预警")
|
||||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
|
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="计划标题")
|
title = Column(String(200), nullable=False, comment="计划标题")
|
||||||
description = Column(Text, nullable=True, comment="详细描述")
|
description = Column(Text, nullable=True, comment="详细描述")
|
||||||
assignee = Column(String(100), nullable=True, comment="负责人")
|
assignee = Column(String(100), nullable=True, comment="负责人")
|
||||||
@@ -326,3 +344,21 @@ class KPITemplate(Base):
|
|||||||
created_by = Column(Integer)
|
created_by = Column(Integer)
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=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())
|
||||||
|
|||||||
@@ -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模板库初始化完成")
|
||||||
@@ -1,47 +1,114 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- 节点编辑弹窗 -->
|
<!-- ==========================================================
|
||||||
<MyDialog :model-value="showNodeDialog" @update:model-value="$emit('update:showNodeDialog', $event)" :title="isEditing ? '编辑目标' : '添加目标'" :width="520">
|
第一步:选择模板弹窗
|
||||||
<div class="mc-dialog-body">
|
========================================================== -->
|
||||||
|
<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 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 v-if="selectedTemplateIdx === idx" class="mc-tmpl-check">✓</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">步骤 2/2 — 编辑目标与关键结果</div>
|
||||||
|
|
||||||
<div class="mc-form-item">
|
<div class="mc-form-item">
|
||||||
<label>目标名称 <span class="mc-required">*</span></label>
|
<label>🎯 目标名称 <span class="mc-required">*</span></label>
|
||||||
<input v-model="form.name" class="mc-input" placeholder="请输入目标名称" ref="dialogNameRef" />
|
<input v-model="form.o_name" class="mc-input" placeholder="请输入目标名称" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mc-form-item">
|
<div class="mc-form-item">
|
||||||
<label>描述</label>
|
<label>📝 目标描述</label>
|
||||||
<textarea v-model="form.description" class="mc-input mc-textarea" rows="3" placeholder="目标描述(可选)"></textarea>
|
<textarea v-model="form.o_description" class="mc-input mc-textarea" rows="2" placeholder="目标描述(可选)"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="mc-form-item">
|
|
||||||
<label>图标</label>
|
<div class="mc-kr-section">
|
||||||
<div class="mc-icon-picker">
|
<div class="mc-kr-header">
|
||||||
<button v-for="(ico, key) in iconOptions" :key="key"
|
<span>关键结果({{ form.krs.length }}条)</span>
|
||||||
class="mc-icon-btn" :class="{ active: form.icon === key }"
|
<el-button size="small" type="primary" plain @click="addKr">+ 添加KR</el-button>
|
||||||
@click="form.icon = key" :title="ico">{{ iconEmoji(key) }}</button>
|
</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>目标值</label>
|
||||||
|
<input v-model="kr.target_value" class="mc-input mc-kr-input" placeholder="如 ≥18%" />
|
||||||
|
</div>
|
||||||
|
<div class="mc-kr-field">
|
||||||
|
<label>权重</label>
|
||||||
|
<select v-model="kr.weight" class="mc-input mc-kr-input">
|
||||||
|
<option value="10">10%</option>
|
||||||
|
<option value="20">20%</option>
|
||||||
|
<option value="25">25%</option>
|
||||||
|
<option value="30">30%</option>
|
||||||
|
<option value="33">33%</option>
|
||||||
|
<option value="40">40%</option>
|
||||||
|
<option value="50">50%</option>
|
||||||
|
<option value="60">60%</option>
|
||||||
|
<option value="70">70%</option>
|
||||||
|
<option value="80">80%</option>
|
||||||
|
<option value="100">100%</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mc-form-item">
|
|
||||||
<label>目标值 <span class="mc-hint">(CMA字段)</span></label>
|
|
||||||
<input v-model="form.targetValue" class="mc-input" type="number" placeholder="目标值" />
|
|
||||||
</div>
|
|
||||||
<div class="mc-row">
|
|
||||||
<input v-model="form.currentValue" class="mc-input mc-input-half" type="number" placeholder="当前值" />
|
|
||||||
<input v-model="form.unit" class="mc-input mc-input-half" placeholder="单位(如%、万元)" />
|
|
||||||
</div>
|
|
||||||
<div class="mc-row">
|
|
||||||
<input v-model="form.owner" class="mc-input" placeholder="责任人" />
|
|
||||||
</div>
|
|
||||||
<div class="mc-form-item">
|
|
||||||
<label>指标类型</label>
|
|
||||||
<label class="mc-radio-label"><input type="radio" v-model="form.isLeading" :value="false" /> 滞后指标(结果)</label>
|
|
||||||
<label class="mc-radio-label"><input type="radio" v-model="form.isLeading" :value="true" /> 领先指标(驱动)</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button size="small" @click="$emit('update:showNodeDialog', false)">取消</el-button>
|
<div v-if="dialogStep === 1" class="mc-footer-buttons">
|
||||||
<el-button size="small" type="primary" @click="$emit('save-node-dialog')">保存</el-button>
|
<el-button size="small" @click="onCancel">取消</el-button>
|
||||||
|
<el-button size="small" plain @click="skipTemplate">✏️ 自定义目标</el-button>
|
||||||
|
<el-button size="small" type="primary" :disabled="selectedTemplateIdx === null" @click="nextStep">
|
||||||
|
下一步
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="dialogStep === 2" class="mc-footer-buttons">
|
||||||
|
<el-button 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>
|
</template>
|
||||||
</MyDialog>
|
</MyDialog>
|
||||||
|
|
||||||
<!-- KPI因果链推荐弹窗 -->
|
<!-- ==========================================================
|
||||||
|
KPI 因果链推荐弹窗(不变)
|
||||||
|
========================================================== -->
|
||||||
<MyDialog :model-value="showCausalityDialog" @update:model-value="$emit('update:showCausalityDialog', $event)" title="KPI因果链推荐" :width="680">
|
<MyDialog :model-value="showCausalityDialog" @update:model-value="$emit('update:showCausalityDialog', $event)" title="KPI因果链推荐" :width="680">
|
||||||
<div style="max-height:400px;overflow-y:auto;">
|
<div style="max-height:400px;overflow-y:auto;">
|
||||||
<div v-if="causalityLoading" style="text-align:center;padding:20px;color:#999;">加载中...</div>
|
<div v-if="causalityLoading" style="text-align:center;padding:20px;color:#999;">加载中...</div>
|
||||||
@@ -64,7 +131,9 @@
|
|||||||
</template>
|
</template>
|
||||||
</MyDialog>
|
</MyDialog>
|
||||||
|
|
||||||
<!-- 版本历史弹窗 -->
|
<!-- ==========================================================
|
||||||
|
版本历史弹窗(不变)
|
||||||
|
========================================================== -->
|
||||||
<MyDialog :model-value="showVersions" @update:model-value="$emit('update:showVersions', $event)" title="版本历史" :width="620">
|
<MyDialog :model-value="showVersions" @update:model-value="$emit('update:showVersions', $event)" title="版本历史" :width="620">
|
||||||
<div style="max-height:400px;overflow-y:auto;">
|
<div style="max-height:400px;overflow-y:auto;">
|
||||||
<table class="mc-table" v-if="versions.length > 0">
|
<table class="mc-table" v-if="versions.length > 0">
|
||||||
@@ -81,7 +150,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</MyDialog>
|
</MyDialog>
|
||||||
|
|
||||||
<!-- KPI详情浮层 -->
|
<!-- ==========================================================
|
||||||
|
KPI 详情浮层(不变)
|
||||||
|
========================================================== -->
|
||||||
<MyDialog :model-value="showKpiDetail" @update:model-value="$emit('update:showKpiDetail', $event)" :title="'KPI详情'" :width="560">
|
<MyDialog :model-value="showKpiDetail" @update:model-value="$emit('update:showKpiDetail', $event)" :title="'KPI详情'" :width="560">
|
||||||
<div style="max-height:360px;overflow-y:auto;">
|
<div style="max-height:360px;overflow-y:auto;">
|
||||||
<div v-if="kpiDetailList.length === 0" style="text-align:center;color:#999;padding:20px;">
|
<div v-if="kpiDetailList.length === 0" style="text-align:center;color:#999;padding:20px;">
|
||||||
@@ -121,7 +192,9 @@
|
|||||||
</template>
|
</template>
|
||||||
</MyDialog>
|
</MyDialog>
|
||||||
|
|
||||||
<!-- 行动方案列表弹窗 -->
|
<!-- ==========================================================
|
||||||
|
行动方案列表弹窗(不变)
|
||||||
|
========================================================== -->
|
||||||
<MyDialog :model-value="showPlanPopup" @update:model-value="$emit('update:showPlanPopup', $event)" :title="'行动方案'" :width="600">
|
<MyDialog :model-value="showPlanPopup" @update:model-value="$emit('update:showPlanPopup', $event)" :title="'行动方案'" :width="600">
|
||||||
<div style="max-height:400px;overflow-y:auto;">
|
<div style="max-height:400px;overflow-y:auto;">
|
||||||
<div v-if="planPopupList.length === 0" style="text-align:center;color:#999;padding:20px;">
|
<div v-if="planPopupList.length === 0" style="text-align:center;color:#999;padding:20px;">
|
||||||
@@ -145,3 +218,557 @@
|
|||||||
</template>
|
</template>
|
||||||
</MyDialog>
|
</MyDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from "vue"
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// ── 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)
|
||||||
|
|
||||||
|
// 内部表单(O+KR结构)
|
||||||
|
const form = ref<{
|
||||||
|
o_name: string
|
||||||
|
o_description: string
|
||||||
|
krs: { name: string; target_value: string; weight: 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"
|
||||||
|
return (CMA_TEMPLATES as any)[key] || []
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogTitle = computed(() => {
|
||||||
|
return props.isEditing ? "编辑目标" : "添加目标"
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 方法 ──
|
||||||
|
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: "", target_value: "", weight: "33" },
|
||||||
|
{ name: "", target_value: "", weight: "33" },
|
||||||
|
{ name: "", target_value: "", weight: "34" },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => ({
|
||||||
|
name: kr.name,
|
||||||
|
target_value: kr.target_value,
|
||||||
|
weight: String(kr.weight),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevStep() {
|
||||||
|
dialogStep.value = 1
|
||||||
|
selectedTemplateIdx.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function addKr() {
|
||||||
|
form.value.krs.push({ name: "", target_value: "", weight: "33" })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeKr(idx: number) {
|
||||||
|
form.value.krs.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSave() {
|
||||||
|
if (!form.value.o_name?.trim()) {
|
||||||
|
// 简单提示 — 父组件会检测
|
||||||
|
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,
|
||||||
|
dimension: props.editingLayerKey,
|
||||||
|
krs: form.value.krs.map((kr, i) => ({
|
||||||
|
name: kr.name,
|
||||||
|
target_value: kr.target_value,
|
||||||
|
weight: parseInt(kr.weight) || 33,
|
||||||
|
sort_order: i + 1,
|
||||||
|
})),
|
||||||
|
// 兼容旧字段(父组件 onNodeSave 需要 name)
|
||||||
|
name: form.value.o_name.trim(),
|
||||||
|
description: form.value.o_description.trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancel() {
|
||||||
|
emit("update:showNodeDialog", false)
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听弹窗打开 → 重置状态
|
||||||
|
watch(
|
||||||
|
() => props.showNodeDialog,
|
||||||
|
(val) => {
|
||||||
|
if (!val) {
|
||||||
|
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>
|
||||||
|
|||||||
@@ -120,6 +120,7 @@
|
|||||||
:kpi-detail-list="kpiDetailList"
|
:kpi-detail-list="kpiDetailList"
|
||||||
:plan-popup-obj-name="planPopupObjName"
|
:plan-popup-obj-name="planPopupObjName"
|
||||||
:plan-popup-list="planPopupList"
|
:plan-popup-list="planPopupList"
|
||||||
|
:editing-layer-key="editingLayerKey"
|
||||||
@update:show-node-dialog="v => showNodeDialog = v"
|
@update:show-node-dialog="v => showNodeDialog = v"
|
||||||
@update:show-causality-dialog="v => showCausalityDialog = v"
|
@update:show-causality-dialog="v => showCausalityDialog = v"
|
||||||
@update:show-versions="v => showVersions = v"
|
@update:show-versions="v => showVersions = v"
|
||||||
@@ -195,7 +196,20 @@ const editingNodeData = ref<any>(null)
|
|||||||
const editingLayerKey = ref('')
|
const editingLayerKey = ref('')
|
||||||
const dialogRefreshKey = ref(0)
|
const dialogRefreshKey = ref(0)
|
||||||
const dialogForm = reactive({ name: '', description: '', icon: 'target', targetValue: null, currentValue: null, unit: '%', owner: '', isLeading: false, kpis: [] as string[] })
|
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 }
|
if (!dialogForm.name?.trim()) { ElMessage.warning('请输入目标名称'); return }
|
||||||
onNodeSave({ ...dialogForm, name: dialogForm.name.trim(), layer: editingLayerKey.value })
|
onNodeSave({ ...dialogForm, name: dialogForm.name.trim(), layer: editingLayerKey.value })
|
||||||
showNodeDialog.value = false
|
showNodeDialog.value = false
|
||||||
@@ -544,6 +558,9 @@ function onNodeSave(data: any) {
|
|||||||
layer: layer,
|
layer: layer,
|
||||||
kpis: data.kpis || [],
|
kpis: data.kpis || [],
|
||||||
icon: data.icon || 'target',
|
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) {
|
if (editingNodeData.value?._index != null && editingNodeData.value._dimKey === layer) {
|
||||||
@@ -569,6 +586,7 @@ function onNodeSave(data: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
editingNodeData.value = null
|
editingNodeData.value = null
|
||||||
|
showNodeDialog.value = false
|
||||||
debouncedSaveCanvas()
|
debouncedSaveCanvas()
|
||||||
nextTick(triggerRecalc)
|
nextTick(triggerRecalc)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user