feat(KR): 行动挂KR完成 — action_plans.kr_id字段+API+前端目标/KR选择+KR中文名自动填+地图krs端点
- action_plans表加kr_id字段(关联krs.id),create/update/list支持kr_id读写
- 创建行动选KR时自动继承objective_id;列表附kr_title
- 前端行动方案库加'所属目标/KR'两级联动选择,选中KR自动带出KPI
- OKR详情页KR卡片下显示达成行动列表(O→KR→行动三层)
- MapCanvasDialogs onKpiChange选中KPI自动填kr.name=kpi.kpi_name(中文)
- 编辑回填英文code KR名转中文显示兜底
- maps.py 新增GET /{map_id}/krs端点(MapReview战略回顾会KR进度)
- 数据修复: krs表9条英文code title→中文名(按kpi_definitions映射)
- 数据迁移: 高置信行动挂KR(费用率→kr10,应收→kr14); 地图32空KR清理
- okr list的kr_summary补metric_kpi_id(前端联动用)
- 新增6个kr_id测试(test_action_plans),全量547通过
This commit is contained in:
@@ -9,7 +9,7 @@ from calendar import monthrange
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_role, require_auth
|
||||
from app.models import ActionPlan, KPIAlert, KPIDefinition, User, Objective
|
||||
from app.models import ActionPlan, KPIAlert, KPIDefinition, User, Objective, KR
|
||||
|
||||
logger = logging.getLogger("cma.action_plans")
|
||||
|
||||
@@ -64,6 +64,7 @@ def plan_to_dict(p: ActionPlan) -> dict:
|
||||
"alert_id": p.alert_id,
|
||||
"kpi_id": p.kpi_id,
|
||||
"objective_id": p.objective_id,
|
||||
"kr_id": p.kr_id,
|
||||
"title": p.title,
|
||||
"description": p.description,
|
||||
"assignee": p.assignee,
|
||||
@@ -87,6 +88,7 @@ def list_plans(
|
||||
status: Optional[str] = None,
|
||||
kpi_id: Optional[int] = None,
|
||||
alert_id: Optional[int] = None,
|
||||
keyword: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
@@ -100,6 +102,8 @@ def list_plans(
|
||||
query = query.filter(ActionPlan.kpi_id == kpi_id)
|
||||
if alert_id:
|
||||
query = query.filter(ActionPlan.alert_id == alert_id)
|
||||
if keyword:
|
||||
query = query.filter(ActionPlan.title.like(f"%{keyword}%"))
|
||||
|
||||
# business角色只看自己的
|
||||
if current_user.role == "business":
|
||||
@@ -115,6 +119,16 @@ def list_plans(
|
||||
# 附带KPI名称
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
|
||||
item["kpi_name"] = kpi.kpi_name if kpi else "未知KPI"
|
||||
item["kpi_code"] = kpi.kpi_code if kpi else None
|
||||
item["kpi_dimension"] = kpi.dimension if kpi else None
|
||||
# 附带KR信息(行动挂KR 2026-08-27)
|
||||
if p.kr_id is not None:
|
||||
kr = db.query(KR).filter(KR.id == p.kr_id).first()
|
||||
item["kr_title"] = kr.title if kr else None
|
||||
item["kr_metric_kpi_id"] = kr.metric_kpi_id if kr else None
|
||||
else:
|
||||
item["kr_title"] = None
|
||||
item["kr_metric_kpi_id"] = None
|
||||
result.append(item)
|
||||
|
||||
return {"data": result}
|
||||
@@ -137,6 +151,16 @@ def create_plan(
|
||||
if not kpi_ent or kpi_ent.entity_id != entity_id:
|
||||
raise HTTPException(404, "关联KPI不存在")
|
||||
|
||||
# 行动挂KR (2026-08-27): kr_id 需存在且属于当前企业
|
||||
kr_id = data.get("kr_id")
|
||||
if kr_id:
|
||||
kr_ent = db.query(KR).filter(KR.id == kr_id, KR.entity_id == entity_id).first()
|
||||
if not kr_ent:
|
||||
raise HTTPException(404, "关联KR不存在")
|
||||
# 未显式传objective_id时从KR继承目标
|
||||
if not data.get("objective_id"):
|
||||
data["objective_id"] = kr_ent.objective_id
|
||||
|
||||
due_date = datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None
|
||||
|
||||
# 校验截止日期与关联Objective的季度匹配
|
||||
@@ -150,6 +174,7 @@ def create_plan(
|
||||
alert_id=data.get("alert_id"),
|
||||
kpi_id=data["kpi_id"],
|
||||
objective_id=objective_id,
|
||||
kr_id=kr_id,
|
||||
title=data["title"],
|
||||
description=data.get("description"),
|
||||
assignee=data.get("assignee"),
|
||||
@@ -214,6 +239,21 @@ def update_plan(
|
||||
plan.assignee = data["assignee"]
|
||||
if "priority" in data:
|
||||
plan.priority = data["priority"]
|
||||
if "kr_id" in data:
|
||||
# 行动挂KR: 支持置空(null)或改挂
|
||||
if data["kr_id"] is None:
|
||||
plan.kr_id = None
|
||||
else:
|
||||
kr_ent = db.query(KR).filter(KR.id == data["kr_id"]).first()
|
||||
if not kr_ent:
|
||||
raise HTTPException(404, "关联KR不存在")
|
||||
plan.kr_id = kr_ent.id
|
||||
if data.get("objective_id") is not None:
|
||||
plan.objective_id = data["objective_id"]
|
||||
elif plan.objective_id is None:
|
||||
plan.objective_id = kr_ent.objective_id
|
||||
if "objective_id" in data:
|
||||
plan.objective_id = data["objective_id"]
|
||||
if "due_date" in data:
|
||||
plan.due_date = datetime.fromisoformat(data["due_date"]) if data["due_date"] else None
|
||||
if "status" in data:
|
||||
|
||||
@@ -533,6 +533,71 @@ def _parse_kr_date(v):
|
||||
# ── 战略回顾会 聚合接口 ──────────────────────
|
||||
|
||||
|
||||
@router.get("/{map_id}/krs")
|
||||
def get_map_krs(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""战略回顾会 KR进度: 从krs表读取地图各目标的KR (KR修复 2026-08-27)
|
||||
|
||||
地图dimensions JSON中每个带krs的目标节点已同步到objectives+krs表,
|
||||
此处按 节点._objective_id(或 entity+name 回查)取KR。
|
||||
"""
|
||||
from app.models import KPIDefinition, KR, Objective
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
dims = m.dimensions
|
||||
if isinstance(dims, str):
|
||||
try:
|
||||
dims = json.loads(dims)
|
||||
except Exception:
|
||||
dims = []
|
||||
dims = dims or []
|
||||
|
||||
obj_ids = []
|
||||
for dim in dims:
|
||||
for obj in dim.get("objectives", []):
|
||||
oid = obj.get("_objective_id")
|
||||
if oid:
|
||||
obj_ids.append(int(oid))
|
||||
continue
|
||||
# 兼容无回写字段的旧节点: 按 entity+name 回查
|
||||
name = (obj.get("name") or "").strip()
|
||||
if name:
|
||||
o = db.query(Objective).filter(
|
||||
Objective.entity_id == entity_id,
|
||||
Objective.title == name,
|
||||
).first()
|
||||
if o:
|
||||
obj_ids.append(o.id)
|
||||
|
||||
result = []
|
||||
if obj_ids:
|
||||
krs = db.query(KR).filter(
|
||||
KR.entity_id == entity_id,
|
||||
KR.objective_id.in_(obj_ids),
|
||||
).order_by(KR.objective_id, KR.sort_order, KR.id).all()
|
||||
for kr in krs:
|
||||
mkpi = None
|
||||
if kr.metric_kpi_id:
|
||||
mkpi = db.query(KPIDefinition).filter(KPIDefinition.id == kr.metric_kpi_id).first()
|
||||
result.append({
|
||||
"id": kr.id,
|
||||
"objective_id": kr.objective_id,
|
||||
"title": kr.title,
|
||||
"metric_kpi_id": kr.metric_kpi_id,
|
||||
"metric_kpi_code": mkpi.kpi_code if mkpi else None,
|
||||
"metric_kpi_name": mkpi.kpi_name if mkpi else None,
|
||||
"operator": _parse_kr_operator_target(kr.operator)[0] if hasattr(kr, "operator") and kr.operator else ">=",
|
||||
"weight": float(kr.weight) if kr.weight is not None else None,
|
||||
"target_value": float(kr.target_value) if kr.target_value is not None else None,
|
||||
"current_value": float(kr.current_value) if kr.current_value is not None else None,
|
||||
"progress": kr.progress or 0,
|
||||
"status": kr.status,
|
||||
"due_date": kr.due_date.isoformat() if kr.due_date else None,
|
||||
})
|
||||
return {"data": result}
|
||||
|
||||
|
||||
@router.get("/{map_id}/review")
|
||||
def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""战略回顾会:返回目标状态、KPI值、改善行动
|
||||
|
||||
@@ -192,7 +192,8 @@ def list_objectives(
|
||||
krs = db.query(KR).filter(KR.objective_id == o.id).order_by(KR.id).all()
|
||||
kr_summary = [
|
||||
{"id": kr.id, "title": kr.title, "status": kr.status, "progress": kr.progress,
|
||||
"operator": _parse_operator(kr.operator), "weight": _num(kr.weight)}
|
||||
"operator": _parse_operator(kr.operator), "weight": _num(kr.weight),
|
||||
"metric_kpi_id": kr.metric_kpi_id}
|
||||
for kr in krs
|
||||
]
|
||||
results.append({
|
||||
|
||||
@@ -220,6 +220,7 @@ class ActionPlan(Base):
|
||||
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目标")
|
||||
kr_id = Column(Integer, ForeignKey("krs.id"), nullable=True, comment="关联KR (行动挂KR 2026-08-27)")
|
||||
title = Column(String(200), nullable=False, comment="计划标题")
|
||||
description = Column(Text, nullable=True, comment="详细描述")
|
||||
assignee = Column(String(100), nullable=True, comment="负责人")
|
||||
|
||||
@@ -6,7 +6,32 @@ from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
|
||||
from app.models import ActionPlan
|
||||
from app.models import ActionPlan, Objective, KR
|
||||
|
||||
|
||||
def create_test_objective_kr(db: Session, **kwargs):
|
||||
"""创建测试目标+KR(行动挂KR 2026-08-27)"""
|
||||
obj = Objective(
|
||||
entity_id=kwargs.get("entity_id", 1),
|
||||
title=kwargs.get("objective_title", "测试目标"),
|
||||
quarter="2026Q3",
|
||||
status="active",
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
kr = KR(
|
||||
entity_id=kwargs.get("entity_id", 1),
|
||||
objective_id=obj.id,
|
||||
title=kwargs.get("kr_title", "测试KR"),
|
||||
operator=">=",
|
||||
weight=50,
|
||||
target_value=100,
|
||||
)
|
||||
db.add(kr)
|
||||
db.commit()
|
||||
db.refresh(kr)
|
||||
return obj, kr
|
||||
|
||||
|
||||
def create_test_plan(db: Session, **kwargs) -> ActionPlan:
|
||||
@@ -109,7 +134,7 @@ class TestActionPlans:
|
||||
assert data["data"][0]["title"] == "进行中"
|
||||
|
||||
def test_filter_by_keyword(self, client: TestClient, db: Session):
|
||||
"""关键词搜索"""
|
||||
"""关键词搜索:keyword 应过滤 title(期望行为,2026-08-27 TDD示范)"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
@@ -119,8 +144,9 @@ class TestActionPlans:
|
||||
resp = client.get("/api/cma/action-plans?keyword=营收", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# API当前未实现keyword过滤,返回全部2条
|
||||
assert len(data["data"]) == 2
|
||||
# 期望行为:keyword 过滤生效,只返回标题含"营收"的1条
|
||||
assert len(data["data"]) == 1
|
||||
assert data["data"][0]["title"] == "提升营收计划"
|
||||
|
||||
def test_update_plan(self, client: TestClient, db: Session):
|
||||
"""更新行动计划"""
|
||||
@@ -226,3 +252,90 @@ class TestActionPlans:
|
||||
assert data["pending"] == 1
|
||||
assert data["in_progress"] == 1
|
||||
assert data["completed"] == 1
|
||||
|
||||
# ── 行动挂KR (2026-08-27) ──
|
||||
|
||||
def test_create_plan_with_kr_id(self, client: TestClient, db: Session):
|
||||
"""创建行动时挂KR:返回kr_id,objective_id从KR自动继承"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
obj, kr = create_test_objective_kr(db)
|
||||
|
||||
resp = client.post(
|
||||
"/api/cma/action-plans",
|
||||
headers=auth_header(token),
|
||||
json={
|
||||
"title": "降低费用率行动",
|
||||
"kpi_id": kpi.id,
|
||||
"kr_id": kr.id,
|
||||
"assignee": "张三",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["kr_id"] == kr.id
|
||||
# 未显式传objective_id时从KR继承
|
||||
assert data["objective_id"] == obj.id
|
||||
|
||||
def test_create_plan_kr_not_found(self, client: TestClient, db: Session):
|
||||
"""kr_id不存在 → 404"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
|
||||
resp = client.post(
|
||||
"/api/cma/action-plans",
|
||||
headers=auth_header(token),
|
||||
json={"title": "行动", "kpi_id": kpi.id, "kr_id": 99999},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_list_includes_kr_title(self, client: TestClient, db: Session):
|
||||
"""列表返回kr_title"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
obj, kr = create_test_objective_kr(db)
|
||||
create_test_plan(db, kpi_id=kpi.id, kr_id=kr.id, title="挂KR的行动")
|
||||
|
||||
resp = client.get("/api/cma/action-plans", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
item = data[0]
|
||||
assert item["kr_id"] == kr.id
|
||||
assert item["kr_title"] == "测试KR"
|
||||
|
||||
def test_update_plan_kr_id(self, client: TestClient, db: Session):
|
||||
"""更新行动可改挂KR"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
obj1, kr1 = create_test_objective_kr(db, kr_title="KR一")
|
||||
obj2, kr2 = create_test_objective_kr(db, kr_title="KR二")
|
||||
plan = create_test_plan(db, kpi_id=kpi.id, kr_id=kr1.id, title="行动")
|
||||
|
||||
resp = client.put(
|
||||
f"/api/cma/action-plans/{plan.id}",
|
||||
headers=auth_header(token),
|
||||
json={"kr_id": kr2.id},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["kr_id"] == kr2.id
|
||||
|
||||
def test_update_plan_kr_clear(self, client: TestClient, db: Session):
|
||||
"""更新行动可置空kr_id"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
obj, kr = create_test_objective_kr(db)
|
||||
plan = create_test_plan(db, kpi_id=kpi.id, kr_id=kr.id, title="行动")
|
||||
|
||||
resp = client.put(
|
||||
f"/api/cma/action-plans/{plan.id}",
|
||||
headers=auth_header(token),
|
||||
json={"kr_id": None},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["kr_id"] is None
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# CMA 丰田式体检报告(2026-08-26)— 真落地 vs 伪上线
|
||||
|
||||
> 方法:丰田五原则(看板/5Why/Andon/过量生产/先流程后系统)对照 CMA 各模块真实数据量
|
||||
> 参考:~/WB_WS/_wisdom/reference/b2b-toyota-style-landing-check.md
|
||||
|
||||
## 一、真落地(有真实业务流支撑 → 聚焦)
|
||||
|
||||
| 模块 | 数据量 | 判断 |
|
||||
|:--|:--|:--|
|
||||
| KPI字典 + 实际值 | 94 KPI / 1674 值 | ✅ 核心引擎,持续写入 |
|
||||
| 预警 + 规则 | 191 预警 / 78 规则 | ✅ Andon 机制已落地(异常即暴露) |
|
||||
| 预算计划 | 134 | ✅ 真在用 |
|
||||
| 成本(标准+实际) | 163 | ✅ 真在用 |
|
||||
| 战略地图 | 2(酣客/博海各1) | ✅ 双账套真实使用 |
|
||||
| 改善行动 + 因果链 | 18 + 50 | ✅ 与预警/OKR联动 |
|
||||
| 操作日志 | 163 | ✅ 审计流 |
|
||||
| 知识文章 | 36 | ✅ 在用 |
|
||||
| 数据源配置 | 12 | ✅ 数据管道 |
|
||||
|
||||
## 二、疑似伪需求(丰田"过量生产"候选 → 不做清单)
|
||||
|
||||
| 模块 | 数据量 | 诊断 | 建议 |
|
||||
|:--|:--|:--|:--|
|
||||
| **OKR模板库** | 32个模板,**仅1个被用** | 🔴 典型过量生产:做了 32 个"以后可能用得上"的模板,实际只有1个被应用 | **冻结归档**:保留 Top5 真实模板(按 use_count+行业),其余 is_active=0;或做模板应用引导提升复用 |
|
||||
| **BI报表** | **0 份报表** | 🔴 功能完整(列表/模板/导出)但零使用 | **冻结**:无真实需求,等客户要求再启用 |
|
||||
| **通知渠道** | 0 渠道 / 0 日志 | 🔴 通知模块(渠道配置/测试/日志)零使用——预警走前端展示即可 | **冻结**:不做推送通知,预警保持在页面内 |
|
||||
| 费用报销 | 13 条 | 🟡 demo 级 | 观察:真实报销流程启动后再评估 |
|
||||
| 导入日志 | 0 | 🟡 导入功能存在但无记录(可能走bot_bridge) | 确认数据入口后决定 |
|
||||
|
||||
## 三、丰田五原则对照结论
|
||||
|
||||
1. **看板**:核心引擎(KPI/预算/成本/预警)都是"业务痛"驱动 ✅;OKR模板/BI/通知是"想要"非"需要" ❌
|
||||
2. **5Why**:CMA 开发已用 Grill-Me/6段式任务书 ✅
|
||||
3. **Andon**:预警红黄绿 + 预测偏差告警 + Bot健康看板 ✅ 已超标
|
||||
4. **过量生产**:**32模板/BI/通知 = 最大的浪费**(开发了没人用的功能,还占回归测试面)🔴
|
||||
5. **先流程后系统**:协作铁律/流程纪律 ✅
|
||||
|
||||
## 四、建议动作(待确认后执行)
|
||||
|
||||
- [ ] OKR模板库冻结:is_active=0 除 Top5 外全部(可随时恢复,不删数据)
|
||||
- [ ] BI报表/通知渠道:前端菜单隐藏(模块保留,后端不动)
|
||||
- [ ] 后续新需求过"三问":是否真实痛点?数据基础就绪?有人会用?
|
||||
Vendored
+1
@@ -59,6 +59,7 @@ declare module 'vue' {
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElText: typeof import('element-plus/es')['ElText']
|
||||
ElTimeline: typeof import('element-plus/es')['ElTimeline']
|
||||
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
|
||||
@@ -649,19 +649,28 @@ function autoDivideWeights() {
|
||||
ElMessage.success(`已自动平分: ${form.value.krs.map(k => k.weight).join('/')}`)
|
||||
}
|
||||
|
||||
// ── 关联KPI变化 → 自动继承方向(从KPI阈值解析) ──
|
||||
// ── 关联KPI变化 → 自动填中文名 + 继承方向(KR英文名修复 2026-08-27) ──
|
||||
function onKpiChange(ki: number) {
|
||||
const kr = form.value.krs[ki]
|
||||
if (!kr) return
|
||||
const kpi = kpiOptions.value.find((k: any) => k.kpi_code === kr.kpi_code)
|
||||
if (!kpi) return
|
||||
const kpiName = kpi.kpi_name || kpi.kpi_code
|
||||
// 选中KPI自动填中文名:名称空 / 还是英文code时替换(用户已手填中文则保留)
|
||||
const curName = (kr.name || "").trim()
|
||||
if (!curName || curName === kpi.kpi_code || (curName === curName.toUpperCase() && curName.includes("_"))) {
|
||||
kr.name = kpiName
|
||||
}
|
||||
// 从KPI阈值解析方向/目标值并继承(仅当用户未手动设置时)
|
||||
const threshold = kpi.threshold_green || kpi.threshold_yellow || ""
|
||||
if (!threshold) return
|
||||
if (!threshold) {
|
||||
ElMessage.success(`已关联KPI: ${kpiName}`)
|
||||
return
|
||||
}
|
||||
const parsed = parseKrTarget(threshold)
|
||||
// 仅当用户未手动设置方向/目标值时继承
|
||||
if (!kr.operator || kr.operator === ">=") kr.operator = parsed.operator
|
||||
if (kr.target_value == null) kr.target_value = parsed.value
|
||||
ElMessage.success(`已继承KPI方向: ${kr.operator} ${kr.target_value}`)
|
||||
ElMessage.success(`已关联KPI: ${kpiName}(方向 ${kr.operator} ${kr.target_value})`)
|
||||
}
|
||||
|
||||
function formatTargetValue(idx: number) {
|
||||
@@ -747,8 +756,15 @@ watch(
|
||||
const obj = props.editingObj
|
||||
const existingKrs = (obj.krs || []).map((kr: any) => {
|
||||
const parsed = parseKrTarget(kr.target_value)
|
||||
// 显示兜底: 旧数据KR名是英文code → 转中文(KR英文名修复 2026-08-27)
|
||||
let krName = kr.name || ""
|
||||
const curName = String(krName).trim()
|
||||
if (curName && curName === curName.toUpperCase() && curName.includes("_")) {
|
||||
const hit = kpiOptions.value.find((k: any) => k.kpi_code === curName)
|
||||
if (hit) krName = hit.kpi_name || hit.kpi_code
|
||||
}
|
||||
return {
|
||||
name: kr.name || "",
|
||||
name: krName || "",
|
||||
operator: kr.operator || parsed.operator,
|
||||
target_value: kr.target_value != null && typeof kr.target_value === "number"
|
||||
? kr.target_value : parsed.value,
|
||||
|
||||
@@ -71,6 +71,12 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="所属KR" width="160">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.kr_title" style="font-size:12px;">{{ row.kr_title }}</span>
|
||||
<span v-else style="font-size:12px;color:#ccc;">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="维度" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="dimTagType(row.kpi_dimension)">{{ dimLabel(row.kpi_dimension) }}</el-tag>
|
||||
@@ -157,6 +163,16 @@
|
||||
<el-option v-for="k in kpiOptions" :key="k.id" :label="`${k.kpi_code} ${k.kpi_name}`" :value="k.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属目标">
|
||||
<el-select v-model="form.objective_id" placeholder="选择OKR目标(可选)" clearable filterable style="width:100%;" :teleported="false" @change="onObjectiveChange">
|
||||
<el-option v-for="o in objectiveOptions" :key="o.id" :label="`${o.title}(${o.kr_count || 0} KR)`" :value="o.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属KR">
|
||||
<el-select v-model="form.kr_id" placeholder="选择KR(行动是KR的达成手段)" clearable filterable style="width:100%;" :teleported="false" :disabled="!form.objective_id" @change="onKrChange">
|
||||
<el-option v-for="kr in currentKrs" :key="kr.id" :label="`${kr.title}(${kr.progress || 0}%)`" :value="kr.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="详细描述(可选)" />
|
||||
</el-form-item>
|
||||
@@ -331,7 +347,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { actionPlanApi, kpiApi, userApi } from '../api/index'
|
||||
import { actionPlanApi, kpiApi, userApi, okrApi } from '../api/index'
|
||||
import MyDialog from '../components/MyDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -352,6 +368,7 @@ const selectedRows = ref<any[]>([])
|
||||
|
||||
const kpiOptions = ref<any[]>([])
|
||||
const userOptions = ref<string[]>([])
|
||||
const objectiveOptions = ref<any[]>([])
|
||||
|
||||
// ── 筛选 ──
|
||||
const filterStatus = ref('')
|
||||
@@ -450,6 +467,33 @@ async function loadUsers() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── 目标/KR加载(行动挂KR 2026-08-27) ──
|
||||
async function loadObjectives() {
|
||||
try {
|
||||
const r: any = await okrApi.list({})
|
||||
objectiveOptions.value = r.items || r.data || []
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 当前目标下的KR(两级联动)
|
||||
const currentKrs = computed(() => {
|
||||
const obj = objectiveOptions.value.find((o: any) => o.id === form.value.objective_id)
|
||||
return obj?.key_results || []
|
||||
})
|
||||
|
||||
function onObjectiveChange() {
|
||||
form.value.kr_id = null
|
||||
}
|
||||
|
||||
function onKrChange() {
|
||||
const kr = currentKrs.value.find((k: any) => k.id === form.value.kr_id)
|
||||
if (!kr) return
|
||||
// 选中KR自动带出KPI(若尚未选择)
|
||||
if (kr.metric_kpi_id && !form.value.kpi_id) {
|
||||
form.value.kpi_id = kr.metric_kpi_id
|
||||
}
|
||||
}
|
||||
|
||||
// ── 新建/编辑 ──
|
||||
const showForm = ref(false)
|
||||
const formTitle = ref('')
|
||||
@@ -459,6 +503,8 @@ const form = ref({
|
||||
title: '',
|
||||
target_value: '',
|
||||
kpi_id: null as number | null,
|
||||
objective_id: null as number | null,
|
||||
kr_id: null as number | null,
|
||||
description: '',
|
||||
assignee: '',
|
||||
priority: 'medium',
|
||||
@@ -471,7 +517,7 @@ const form = ref({
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
formTitle.value = '新建行动方案'
|
||||
form.value = { title: '', target_value: '', kpi_id: null, description: '', assignee: '', priority: 'medium', due_date: null, status: 'pending', progress: 0, result: '' }
|
||||
form.value = { title: '', target_value: '', kpi_id: null, objective_id: null, kr_id: null, description: '', assignee: '', priority: 'medium', due_date: null, status: 'pending', progress: 0, result: '' }
|
||||
// 如果路由携带 kpi_id 参数,自动填入
|
||||
if (route.query.kpi_id) {
|
||||
form.value.kpi_id = Number(route.query.kpi_id)
|
||||
@@ -486,6 +532,8 @@ function editPlan(row: any) {
|
||||
title: row.title,
|
||||
target_value: row.target_value || '',
|
||||
kpi_id: row.kpi_id,
|
||||
objective_id: row.objective_id || null,
|
||||
kr_id: row.kr_id || null,
|
||||
description: row.description || '',
|
||||
assignee: row.assignee || '',
|
||||
priority: row.priority || 'medium',
|
||||
@@ -613,6 +661,7 @@ onMounted(() => {
|
||||
loadData()
|
||||
loadKpis()
|
||||
loadUsers()
|
||||
loadObjectives()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -63,6 +63,16 @@
|
||||
<span v-if="kr.due_date" class="kr-meta-item">截止: {{ kr.due_date.slice(0, 10) }}</span>
|
||||
</div>
|
||||
<el-progress :percentage="kr.progress || 0" :stroke-width="6" />
|
||||
<!-- 行动挂KR: KR下的达成行动 (2026-08-27) -->
|
||||
<div v-if="actionsOfKr(kr.id).length > 0" class="kr-actions">
|
||||
<div class="kr-actions-title">达成行动({{ actionsOfKr(kr.id).length }})</div>
|
||||
<div v-for="ap in actionsOfKr(kr.id)" :key="ap.id" class="kr-action-row">
|
||||
<span class="kr-action-title">{{ ap.title }}</span>
|
||||
<span v-if="ap.assignee" class="kr-action-meta">@{{ ap.assignee }}</span>
|
||||
<el-tag size="small" :type="statusTagType(ap.status)" class="kr-action-tag">{{ statusLabel(ap.status) }}</el-tag>
|
||||
<el-progress :percentage="ap.progress || 0" :stroke-width="4" style="width:80px;" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
@@ -136,7 +146,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||
import { okrApi, ontologyApi } from '../api/index'
|
||||
import { okrApi, ontologyApi, actionPlanApi } from '../api/index'
|
||||
import OkrDecomposition from '../components/okr/OkrDecomposition.vue'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -147,6 +157,12 @@ const objective = ref<any>(null)
|
||||
const trace = ref<any>(null)
|
||||
const traceLoading = ref(false)
|
||||
const activeTab = ref('krs')
|
||||
const actionPlans = ref<any[]>([])
|
||||
|
||||
// KR下的行动列表(行动挂KR 2026-08-27: O→KR→行动三层)
|
||||
function actionsOfKr(krId: number) {
|
||||
return actionPlans.value.filter((p: any) => p.kr_id === krId)
|
||||
}
|
||||
|
||||
const DIM_LABELS: Record<string, string> = {
|
||||
finance: '财务层', customer: '客户层', process: '流程层', learning: '学习成长层',
|
||||
@@ -180,6 +196,14 @@ async function loadData() {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载行动方案(按kr_id关联,供KR下展示)
|
||||
async function loadActions() {
|
||||
try {
|
||||
const r: any = await actionPlanApi.list({ page_size: 500 })
|
||||
actionPlans.value = r.data || []
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadTrace() {
|
||||
if (!okrId.value) return
|
||||
traceLoading.value = true
|
||||
@@ -197,6 +221,7 @@ async function loadTrace() {
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
loadTrace()
|
||||
loadActions()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -282,6 +307,41 @@ onMounted(() => {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
/* ── KR下的达成行动 ── */
|
||||
.kr-actions {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #409eff;
|
||||
}
|
||||
.kr-actions-title {
|
||||
font-size: 12px;
|
||||
color: #409eff;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.kr-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.kr-action-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.kr-action-meta {
|
||||
color: #909399;
|
||||
font-size: 11px;
|
||||
}
|
||||
.kr-action-tag {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.empty-tab {
|
||||
text-align: center;
|
||||
color: #C0C4CC;
|
||||
|
||||
Reference in New Issue
Block a user